mirror of
https://github.com/siderolabs/talos.git
synced 2026-05-09 06:16:16 +02:00
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:
parent
6bd3cca1a8
commit
f17cdee167
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
124
cmd/talosctl/cmd/talos/output/jsonpath.go
Normal file
124
cmd/talosctl/cmd/talos/output/jsonpath.go
Normal 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
|
||||
}
|
||||
64
cmd/talosctl/cmd/talos/output/jsonpath_test.go
Normal file
64
cmd/talosctl/cmd/talos/output/jsonpath_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
67
internal/integration/cli/jsonpath.go
Normal file
67
internal/integration/cli/jsonpath.go
Normal 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))
|
||||
}
|
||||
@ -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
|
||||
```
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user