Skip to content

Commit

Permalink
feat: jsonpath filter for talosctl get outputs
Browse files Browse the repository at this point in the history
We add a filter to the `talosctl get` command that allows users to
specify a jsonpath filter. Now they can reduce the information that is
printed to only the parts they are interested in.

Fixes #6109

Signed-off-by: Philipp Sauter <[email protected]>
  • Loading branch information
Philipp Sauter committed Sep 27, 2022
1 parent 6bd3cca commit f17cdee
Show file tree
Hide file tree
Showing 9 changed files with 319 additions and 29 deletions.
2 changes: 1 addition & 1 deletion cmd/talosctl/cmd/talos/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
35 changes: 26 additions & 9 deletions cmd/talosctl/cmd/talos/output/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ package output

import (
"encoding/json"
"os"
"io"
"strings"

"github.com/cosi-project/runtime/pkg/resource"
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down
124 changes: 124 additions & 0 deletions cmd/talosctl/cmd/talos/output/jsonpath.go
Original file line number Diff line number Diff line change
@@ -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
}
64 changes: 64 additions & 0 deletions cmd/talosctl/cmd/talos/output/jsonpath_test.go
Original file line number Diff line number Diff line change
@@ -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())
})
}
31 changes: 23 additions & 8 deletions cmd/talosctl/cmd/talos/output/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -23,19 +26,31 @@ 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)
}
}

// 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
}
6 changes: 3 additions & 3 deletions cmd/talosctl/cmd/talos/output/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ package output
import (
"bytes"
"fmt"
"os"
"io"
"strings"
"text/tabwriter"

Expand All @@ -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
}
Expand Down
Loading

0 comments on commit f17cdee

Please sign in to comment.