diff --git a/cmd/talosctl/cmd/talos/get.go b/cmd/talosctl/cmd/talos/get.go index ff43e5d97..f216db81f 100644 --- a/cmd/talosctl/cmd/talos/get.go +++ b/cmd/talosctl/cmd/talos/get.go @@ -278,7 +278,7 @@ func CompleteNodes(cmd *cobra.Command, args []string, toComplete string) ([]stri func init() { getCmd.Flags().StringVar(&getCmdFlags.namespace, "namespace", "", "resource namespace (default is to use default namespace per resource)") - getCmd.Flags().StringVarP(&getCmdFlags.output, "output", "o", "table", "output mode (json, table, yaml)") + getCmd.Flags().StringVarP(&getCmdFlags.output, "output", "o", "table", "output mode (json, table, yaml, jsonpath)") getCmd.Flags().BoolVarP(&getCmdFlags.watch, "watch", "w", false, "watch resource changes") getCmd.Flags().BoolVarP(&getCmdFlags.insecure, "insecure", "i", false, "get resources using the insecure (encrypted with no auth) maintenance service") cli.Should(getCmd.RegisterFlagCompletionFunc("output", output.CompleteOutputArg)) diff --git a/cmd/talosctl/cmd/talos/output/json.go b/cmd/talosctl/cmd/talos/output/json.go index 64d67610c..34a6c65a9 100644 --- a/cmd/talosctl/cmd/talos/output/json.go +++ b/cmd/talosctl/cmd/talos/output/json.go @@ -6,7 +6,7 @@ package output import ( "encoding/json" - "os" + "io" "strings" "github.com/cosi-project/runtime/pkg/resource" @@ -18,11 +18,14 @@ import ( // JSON outputs resources in JSON format. type JSON struct { withEvents bool + writer io.Writer } // NewJSON initializes JSON resource output. -func NewJSON() *JSON { - return &JSON{} +func NewJSON(writer io.Writer) *JSON { + return &JSON{ + writer: writer, + } } // WriteHeader implements output.Writer interface. @@ -32,23 +35,23 @@ func (j *JSON) WriteHeader(definition *meta.ResourceDefinition, withEvents bool) return nil } -// WriteResource implements output.Writer interface. -func (j *JSON) WriteResource(node string, r resource.Resource, event state.EventType) error { +// prepareEncodableData prepares the data of a resource to be encoded as JSON and populates it with some extra information. +func (j *JSON) prepareEncodableData(node string, r resource.Resource, event state.EventType) (map[string]interface{}, error) { out, err := resource.MarshalYAML(r) if err != nil { - return err + return nil, err } yamlBytes, err := yaml.Marshal(out) if err != nil { - return err + return nil, err } var data map[string]interface{} err = yaml.Unmarshal(yamlBytes, &data) if err != nil { - return err + return nil, err } data["node"] = node @@ -57,12 +60,26 @@ func (j *JSON) WriteResource(node string, r resource.Resource, event state.Event data["event"] = strings.ToLower(event.String()) } - enc := json.NewEncoder(os.Stdout) + return data, nil +} + +func writeAsIndentedJSON(wr io.Writer, data interface{}) error { + enc := json.NewEncoder(wr) enc.SetIndent("", " ") return enc.Encode(data) } +// WriteResource implements output.Writer interface. +func (j *JSON) WriteResource(node string, r resource.Resource, event state.EventType) error { + data, err := j.prepareEncodableData(node, r, event) + if err != nil { + return err + } + + return writeAsIndentedJSON(j.writer, data) +} + // Flush implements output.Writer interface. func (j *JSON) Flush() error { return nil diff --git a/cmd/talosctl/cmd/talos/output/jsonpath.go b/cmd/talosctl/cmd/talos/output/jsonpath.go new file mode 100644 index 000000000..3728e223a --- /dev/null +++ b/cmd/talosctl/cmd/talos/output/jsonpath.go @@ -0,0 +1,124 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package output + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "reflect" + + "github.com/cosi-project/runtime/pkg/resource" + "github.com/cosi-project/runtime/pkg/resource/meta" + "github.com/cosi-project/runtime/pkg/state" + "k8s.io/client-go/third_party/forked/golang/template" + "k8s.io/client-go/util/jsonpath" +) + +// JSONPath outputs resources in JSONPath format. +type JSONPath struct { + jsonPath *jsonpath.JSONPath + json *JSON + writer io.Writer +} + +// NewJSONPath initializes JSONPath resource output. +func NewJSONPath(writer io.Writer, jsonPath *jsonpath.JSONPath) *JSONPath { + return &JSONPath{ + jsonPath: jsonPath, + json: NewJSON(writer), + writer: writer, + } +} + +// WriteHeader implements output.Writer interface. +func (j *JSONPath) WriteHeader(definition *meta.ResourceDefinition, withEvents bool) error { + return j.json.WriteHeader(definition, withEvents) +} + +// printResult prints a reflect.Value as JSON if it's a map, array, slice or struct. +// But if it's just a 'scalar' type it prints it as a mere string. +func printResult(wr io.Writer, result reflect.Value) error { + kind := result.Kind() + if kind == reflect.Interface { + kind = result.Elem().Kind() + } + + outputJSON := kind == reflect.Map || + kind == reflect.Array || + kind == reflect.Slice || + kind == reflect.Struct + + var text []byte + + var err error + + if outputJSON { + text, err = json.MarshalIndent(result.Interface(), "", " ") + if err != nil { + return err + } + } else { + text, err = valueToText(result) + } + + if err != nil { + return err + } + + text = append(text, '\n') + + if _, err = wr.Write(text); err != nil { + return err + } + + return nil +} + +// valueToText translates reflect value to corresponding text. +func valueToText(v reflect.Value) ([]byte, error) { + iface, ok := template.PrintableValue(v) + if !ok { + return nil, fmt.Errorf("can't translate type %s to text", v.Type()) + } + + var buffer bytes.Buffer + + fmt.Fprint(&buffer, iface) + + return buffer.Bytes(), nil +} + +// WriteResource implements output.Writer interface. +func (j *JSONPath) WriteResource(node string, r resource.Resource, event state.EventType) error { + data, err := j.json.prepareEncodableData(node, r, event) + if err != nil { + return err + } + + results, err := j.jsonPath.FindResults(data) + if err != nil { + return fmt.Errorf("error finding result for jsonpath: %w", err) + } + + j.jsonPath.EnableJSONOutput(true) + + for _, resultGroup := range results { + for _, result := range resultGroup { + err = printResult(j.writer, result) + if err != nil { + return fmt.Errorf("error generating jsonpath results: %w", err) + } + } + } + + return nil +} + +// Flush implements output.Writer interface. +func (j *JSONPath) Flush() error { + return nil +} diff --git a/cmd/talosctl/cmd/talos/output/jsonpath_test.go b/cmd/talosctl/cmd/talos/output/jsonpath_test.go new file mode 100644 index 000000000..1f8dfa539 --- /dev/null +++ b/cmd/talosctl/cmd/talos/output/jsonpath_test.go @@ -0,0 +1,64 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package output_test + +import ( + "bytes" + "testing" + + "github.com/cosi-project/runtime/pkg/state" + "github.com/stretchr/testify/assert" + "k8s.io/client-go/util/jsonpath" + + "github.com/talos-systems/talos/cmd/talosctl/cmd/talos/output" + "github.com/talos-systems/talos/pkg/machinery/resources/hardware" +) + +func TestWriteResource(t *testing.T) { + node := "123.123.123.123" + event := state.Created + + t.Run("prints scalar values on one line", func(tt *testing.T) { + var buf bytes.Buffer + + // given + expectedID := "myCPU" + processorResource := hardware.NewProcessorInfo(expectedID) + jsonPath := jsonpath.New("talos") + assert.Nil(t, jsonPath.Parse("{.metadata.id}")) + + // when + testObj := output.NewJSONPath(&buf, jsonPath) + err := testObj.WriteResource(node, processorResource, event) + + // then + assert.Nil(t, err) + + assert.Equal(t, expectedID+"\n", buf.String()) + }) + + t.Run("prints complex values as JSON", func(tt *testing.T) { + var buf bytes.Buffer + + // given + expectedMetadata := `{ + "coreCount": 2 +} +` + processorResource := hardware.NewProcessorInfo("myCPU") + processorResource.TypedSpec().CoreCount = 2 + jsonPath := jsonpath.New("talos") + assert.Nil(t, jsonPath.Parse("{.spec}")) + + // when + testObj := output.NewJSONPath(&buf, jsonPath) + err := testObj.WriteResource(node, processorResource, event) + + // then + assert.Nil(t, err) + + assert.Equal(t, expectedMetadata, buf.String()) + }) +} diff --git a/cmd/talosctl/cmd/talos/output/output.go b/cmd/talosctl/cmd/talos/output/output.go index 242c29cf1..e0f674147 100644 --- a/cmd/talosctl/cmd/talos/output/output.go +++ b/cmd/talosctl/cmd/talos/output/output.go @@ -7,11 +7,14 @@ package output import ( "fmt" + "os" + "strings" "github.com/cosi-project/runtime/pkg/resource" "github.com/cosi-project/runtime/pkg/resource/meta" "github.com/cosi-project/runtime/pkg/state" "github.com/spf13/cobra" + "k8s.io/client-go/util/jsonpath" ) // Writer interface. @@ -23,13 +26,25 @@ type Writer interface { // NewWriter builds writer from type. func NewWriter(format string) (Writer, error) { - switch format { - case "table": - return NewTable(), nil - case "yaml": - return NewYAML(), nil - case "json": - return NewJSON(), nil + writer := os.Stdout + + switch { + case format == "table": + return NewTable(writer), nil + case format == "yaml": + return NewYAML(writer), nil + case format == "json": + return NewJSON(writer), nil + case strings.HasPrefix(format, "jsonpath="): + path := format[len("jsonpath="):] + + jp := jsonpath.New("talos") + + if err := jp.Parse(path); err != nil { + return nil, fmt.Errorf("error parsing jsonpath: %w", err) + } + + return NewJSONPath(writer, jp), nil default: return nil, fmt.Errorf("output format %q is not supported", format) } @@ -37,5 +52,5 @@ func NewWriter(format string) (Writer, error) { // CompleteOutputArg represents tab completion for `--output` argument. func CompleteOutputArg(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{"json", "table", "yaml"}, cobra.ShellCompDirectiveNoFileComp + return []string{"json", "table", "yaml", "jsonpath"}, cobra.ShellCompDirectiveNoFileComp } diff --git a/cmd/talosctl/cmd/talos/output/table.go b/cmd/talosctl/cmd/talos/output/table.go index 320053362..6a82dbfa8 100644 --- a/cmd/talosctl/cmd/talos/output/table.go +++ b/cmd/talosctl/cmd/talos/output/table.go @@ -7,7 +7,7 @@ package output import ( "bytes" "fmt" - "os" + "io" "strings" "text/tabwriter" @@ -29,9 +29,9 @@ type Table struct { type dynamicColumn func(value interface{}) (string, error) // NewTable initializes table resource output. -func NewTable() *Table { +func NewTable(writer io.Writer) *Table { output := &Table{} - output.w.Init(os.Stdout, 0, 0, 3, ' ', 0) + output.w.Init(writer, 0, 0, 3, ' ', 0) return output } diff --git a/cmd/talosctl/cmd/talos/output/yaml.go b/cmd/talosctl/cmd/talos/output/yaml.go index e08fa182e..cbb341447 100644 --- a/cmd/talosctl/cmd/talos/output/yaml.go +++ b/cmd/talosctl/cmd/talos/output/yaml.go @@ -6,7 +6,7 @@ package output import ( "fmt" - "os" + "io" "strings" "github.com/cosi-project/runtime/pkg/resource" @@ -19,11 +19,14 @@ import ( type YAML struct { needDashes bool withEvents bool + writer io.Writer } // NewYAML initializes YAML resource output. -func NewYAML() *YAML { - return &YAML{} +func NewYAML(writer io.Writer) *YAML { + return &YAML{ + writer: writer, + } } // WriteHeader implements output.Writer interface. @@ -41,18 +44,18 @@ func (y *YAML) WriteResource(node string, r resource.Resource, event state.Event } if y.needDashes { - fmt.Fprintln(os.Stdout, "---") + fmt.Fprintln(y.writer, "---") } y.needDashes = true - fmt.Fprintf(os.Stdout, "node: %s\n", node) + fmt.Fprintf(y.writer, "node: %s\n", node) if y.withEvents { - fmt.Fprintf(os.Stdout, "event: %s\n", strings.ToLower(event.String())) + fmt.Fprintf(y.writer, "event: %s\n", strings.ToLower(event.String())) } - return yaml.NewEncoder(os.Stdout).Encode(out) + return yaml.NewEncoder(y.writer).Encode(out) } // Flush implements output.Writer interface. diff --git a/internal/integration/cli/jsonpath.go b/internal/integration/cli/jsonpath.go new file mode 100644 index 000000000..1256d2a79 --- /dev/null +++ b/internal/integration/cli/jsonpath.go @@ -0,0 +1,67 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +//go:build integration_cli + +package cli + +import ( + "regexp" + "time" + + "github.com/talos-systems/go-retry/retry" + + "github.com/talos-systems/talos/internal/integration/base" + "github.com/talos-systems/talos/pkg/machinery/config/types/v1alpha1/machine" +) + +// JSONPathSuite verifies dmesg command. +type JSONPathSuite struct { + base.CLISuite +} + +// SuiteName ... +func (suite *JSONPathSuite) SuiteName() string { + return "cli.JSONPathSuite" +} + +// TestGetScalarPropertyWithJSONPath verifies that the jsonpath filter to the get command can return scalar data. +func (suite *JSONPathSuite) TestGetScalarPropertyWithJSONPath() { + node := suite.RandomDiscoveredNodeInternalIP() + + suite.RunCLI([]string{"get", "--nodes", node, "etcfilestatus", "--output", `jsonpath='{.metadata.namespace}'`}, + base.StdoutShouldMatch(regexp.MustCompile("files")), + base.WithRetry(retry.Constant(15*time.Second, retry.WithUnits(time.Second))), + ) +} + +// TestGetWithJSONPathWildcard verifies that the jsonpath filter to the get command accepts a wildcard operator. +// It is handy when 'get' requests a list of resources. +func (suite *JSONPathSuite) TestGetWithJSONPathWildcard() { + node := suite.RandomDiscoveredNodeInternalIP(machine.TypeControlPlane) + + suite.RunCLI([]string{"get", "--nodes", node, "manifests", "--output", `jsonpath='{.spec[*].metadata.name}'`}, + base.StdoutShouldMatch(regexp.MustCompile("kube-proxy")), + base.StdoutShouldMatch(regexp.MustCompile("coredns")), + base.StdoutShouldMatch(regexp.MustCompile("kube-dns")), + base.StdoutShouldMatch(regexp.MustCompile("kubeconfig-in-cluster")), + base.WithRetry(retry.Constant(15*time.Second, retry.WithUnits(time.Second))), + ) +} + +// TestGetComplexPropertyWithJSONPath verifies that the jsonpath filter to the get command can return JSON. +func (suite *JSONPathSuite) TestGetComplexPropertyWithJSONPath() { + node := suite.RandomDiscoveredNodeInternalIP() + + const jsonMetadataRegex = `\{\s*"created":\s".*",\s*"id":\s".*",\s*\s*"namespace":\s".*",\s*"owner":\s".*",\s*"phase":\s".*",\s*"type":\s".*",\s*"updated":\s".*",\s*"version":\s\d\n\}` + + suite.RunCLI([]string{"get", "--nodes", node, "etcfilestatus", "--output", `jsonpath='{.metadata}'`}, + base.StdoutShouldMatch(regexp.MustCompile(jsonMetadataRegex)), + base.WithRetry(retry.Constant(15*time.Second, retry.WithUnits(time.Second))), + ) +} + +func init() { + allSuites = append(allSuites, new(JSONPathSuite)) +} diff --git a/website/content/v1.3/reference/cli.md b/website/content/v1.3/reference/cli.md index 0404156f3..b747039db 100644 --- a/website/content/v1.3/reference/cli.md +++ b/website/content/v1.3/reference/cli.md @@ -1364,7 +1364,7 @@ talosctl get [] [flags] -h, --help help for get -i, --insecure get resources using the insecure (encrypted with no auth) maintenance service --namespace string resource namespace (default is to use default namespace per resource) - -o, --output string output mode (json, table, yaml) (default "table") + -o, --output string output mode (json, table, yaml, jsonpath) (default "table") -w, --watch watch resource changes ```