feat: jsonpath filter for talosctl get outputs

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 <philipp.sauter@siderolabs.com>
This commit is contained in:
Philipp Sauter 2022-09-26 15:20:22 +02:00
parent 6bd3cca1a8
commit f17cdee167
No known key found for this signature in database
GPG Key ID: D3F8AF32D62A348D
9 changed files with 319 additions and 29 deletions

View File

@ -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))

View File

@ -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

View File

@ -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
}

View File

@ -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())
})
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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.

View File

@ -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))
}

View File

@ -1364,7 +1364,7 @@ talosctl get <type> [<id>] [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
```