mirror of
https://github.com/tailscale/tailscale.git
synced 2026-07-15 01:53:08 -04:00
cmd/tailscale/cli/jsonoutput: add support for --format=json
`tailscale netcheck` is the only command that doesn’t support the `--json` flag, but rather requires `--format=json`. This patch adds a flag.Value named jsonoutput.Format that handles a boolean `--json` flag, a versioned `--json=2` flag, and an optional `--format=json-line` flag. Updates #17613 Updates #17366 Updates tailscale/corp#33033 Signed-off-by: Simon Law <sfllaw@tailscale.com>
This commit is contained in:
41
cmd/tailscale/cli/jsonoutput/example_format_test.go
Normal file
41
cmd/tailscale/cli/jsonoutput/example_format_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package jsonoutput_test
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
|
||||
"tailscale.com/cmd/tailscale/cli/jsonoutput"
|
||||
)
|
||||
|
||||
func ExampleFormat_JSONBool() {
|
||||
var args struct {
|
||||
format jsonoutput.Format
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("ExampleFormat", flag.ExitOnError)
|
||||
fs.Var(&args.format, "format", `output format; empty (for human-readable), "json" or "json-line"`)
|
||||
fs.Var(args.format.JSONBool(), "json", "output in JSON format")
|
||||
|
||||
fs.Parse([]string{"-json"})
|
||||
fmt.Printf(`{format: %q, set: %t, version: %d}`, args.format, args.format.IsSet, args.format.Version)
|
||||
// Output:
|
||||
// {format: "json", set: true, version: 1}
|
||||
}
|
||||
|
||||
func ExampleFormat_JSONSchemaVersion() {
|
||||
var args struct {
|
||||
format jsonoutput.Format
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet("ExampleFormat", flag.ExitOnError)
|
||||
fs.Var(&args.format, "format", `output format; empty (for human-readable), "json" or "json-line"`)
|
||||
fs.Var(args.format.JSONSchemaVersion(), "json", "output in JSON format")
|
||||
|
||||
fs.Parse([]string{"-json=2", "-format=json-line"})
|
||||
fmt.Printf(`{format: %q, set: %t, version: %d}`, args.format, args.format.IsSet, args.format.Version)
|
||||
// Output:
|
||||
// {format: "json-line", set: true, version: 2}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ func ExampleSchemaVersion() {
|
||||
fs.Var(&args.json, "json", "output in JSON format")
|
||||
|
||||
fs.Parse([]string{"-json=2"})
|
||||
fmt.Printf(`{set: %t, value: %d}`, args.json.IsSet, args.json.Version)
|
||||
fmt.Printf(`{set: %t, version: %d}`, args.json.IsSet, args.json.Version)
|
||||
// Output:
|
||||
// {set: true, value: 2}
|
||||
// {set: true, version: 2}
|
||||
}
|
||||
|
||||
130
cmd/tailscale/cli/jsonoutput/format.go
Normal file
130
cmd/tailscale/cli/jsonoutput/format.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package jsonoutput
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var _ flag.Value = new(Format)
|
||||
|
||||
// Format implements the [flag.Value] interface,
|
||||
// supporting a combination of both -format and -json flags.
|
||||
//
|
||||
// For some commands, like tailscale netcheck or tailscale routecheck,
|
||||
// the user can specify the output format using the -format flag:
|
||||
//
|
||||
// tailscale routecheck -format=json-line.
|
||||
//
|
||||
// Setting this flag to "json" or "json-line" implies that the -json flag is set.
|
||||
type Format struct {
|
||||
s string
|
||||
SchemaVersion
|
||||
}
|
||||
|
||||
// String returns the default value which is printed in the CLI help text.
|
||||
func (f Format) String() string {
|
||||
return string(f.s)
|
||||
}
|
||||
|
||||
// Set is called when the user passes the flag as a command-line argument.
|
||||
func (f *Format) Set(s string) error {
|
||||
f.s = s
|
||||
isJSON := f.isJSON()
|
||||
f.IsSet = isJSON
|
||||
if !isJSON {
|
||||
f.Version = 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsBoolFlag reports that this [flag.Value] can be set without an argument.
|
||||
// This is the magic interface that makes -name equivalent to -name=true
|
||||
// rather than using the next command-line argument.
|
||||
func (f *Format) IsBoolFlag() bool {
|
||||
return false // requires argument, overrides [SchemaVersion.IsBoolFlag]
|
||||
}
|
||||
|
||||
// isJSON reports whether f represents a format based on JSON.
|
||||
func (f *Format) isJSON() bool {
|
||||
return f.s == "json" || f.s == "json-line"
|
||||
}
|
||||
|
||||
// setJSON either sets or clears the format value if it doesn’t match x.
|
||||
func (f *Format) setJSON(x bool) {
|
||||
switch x {
|
||||
case f.isJSON():
|
||||
return // no change
|
||||
case true:
|
||||
f.s = "json"
|
||||
case false:
|
||||
f.s = "" // clear
|
||||
f.Version = 0
|
||||
}
|
||||
}
|
||||
|
||||
// JSONBool returns a [flag.Value] for a boolean -json flag
|
||||
// which is aware of the underlying format.
|
||||
func (f *Format) JSONBool() flag.Value {
|
||||
return &formatBool{f}
|
||||
}
|
||||
|
||||
type formatBool struct {
|
||||
*Format
|
||||
}
|
||||
|
||||
// String returns the default value which is printed in the CLI help text.
|
||||
func (f formatBool) String() string {
|
||||
return strconv.FormatBool(f.isJSON())
|
||||
}
|
||||
|
||||
// Set is called when the user passes the flag as a command-line argument.
|
||||
func (f *formatBool) Set(s string) error {
|
||||
if _, err := strconv.ParseBool(s); err != nil {
|
||||
return errors.New("parse error")
|
||||
}
|
||||
f.SchemaVersion.Set(s)
|
||||
f.setJSON(f.IsSet)
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsBoolFlag reports that this [flag.Value] can be set without an argument.
|
||||
// This is the magic interface that makes -name equivalent to -name=true
|
||||
// rather than using the next command-line argument.
|
||||
func (f *formatBool) IsBoolFlag() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// JSONSchemaVersion returns a [flag.Value] for a [SchemaVersion] -json flag
|
||||
// which is aware of the underlying format.
|
||||
func (f *Format) JSONSchemaVersion() flag.Value {
|
||||
return &formatSchemaVersion{f}
|
||||
}
|
||||
|
||||
type formatSchemaVersion struct {
|
||||
*Format
|
||||
}
|
||||
|
||||
// String returns the default value which is printed in the CLI help text.
|
||||
func (f formatSchemaVersion) String() string {
|
||||
return f.SchemaVersion.String()
|
||||
}
|
||||
|
||||
// Set is called when the user passes the flag as a command-line argument.
|
||||
func (f *formatSchemaVersion) Set(s string) error {
|
||||
if err := f.SchemaVersion.Set(s); err != nil {
|
||||
return err
|
||||
}
|
||||
f.setJSON(f.IsSet)
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsBoolFlag reports that this [flag.Value] can be set without an argument.
|
||||
// This is the magic interface that makes -name equivalent to -name=true
|
||||
// rather than using the next command-line argument.
|
||||
func (f *formatSchemaVersion) IsBoolFlag() bool {
|
||||
return f.SchemaVersion.IsBoolFlag()
|
||||
}
|
||||
404
cmd/tailscale/cli/jsonoutput/format_test.go
Normal file
404
cmd/tailscale/cli/jsonoutput/format_test.go
Normal file
@@ -0,0 +1,404 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package jsonoutput_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"tailscale.com/cmd/tailscale/cli/jsonoutput"
|
||||
)
|
||||
|
||||
func TestFormat(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
flags []string
|
||||
wantString string
|
||||
wantIsSet bool
|
||||
wantVersion int
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "no-flags",
|
||||
flags: []string{},
|
||||
wantString: "",
|
||||
wantIsSet: false,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "format-json",
|
||||
flags: []string{"-format=json"},
|
||||
wantString: "json",
|
||||
wantIsSet: true,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "format-json-line",
|
||||
flags: []string{"-format=json-line"},
|
||||
wantString: "json-line",
|
||||
wantIsSet: true,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "format-not-json",
|
||||
flags: []string{"-format=xml"},
|
||||
wantString: "xml",
|
||||
wantIsSet: false,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "bool-bare",
|
||||
flags: []string{"-bool"},
|
||||
wantString: "json",
|
||||
wantIsSet: true,
|
||||
wantVersion: 1,
|
||||
},
|
||||
{
|
||||
name: "bool-true",
|
||||
flags: []string{"-bool=true"},
|
||||
wantString: "json",
|
||||
wantIsSet: true,
|
||||
wantVersion: 1,
|
||||
},
|
||||
{
|
||||
name: "bool-false",
|
||||
flags: []string{"-bool=false"},
|
||||
wantString: "",
|
||||
wantIsSet: false,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "bool-invalid",
|
||||
flags: []string{"-bool=2"},
|
||||
wantErr: "parse error",
|
||||
},
|
||||
{
|
||||
name: "version-bare",
|
||||
flags: []string{"-version"},
|
||||
wantString: "json",
|
||||
wantIsSet: true,
|
||||
wantVersion: 1,
|
||||
},
|
||||
{
|
||||
name: "version-true",
|
||||
flags: []string{"-version=true"},
|
||||
wantString: "json",
|
||||
wantIsSet: true,
|
||||
wantVersion: 1,
|
||||
},
|
||||
{
|
||||
name: "version-false",
|
||||
flags: []string{"-version=false"},
|
||||
wantString: "",
|
||||
wantIsSet: false,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "version-number",
|
||||
flags: []string{"-version=2"},
|
||||
wantString: "json",
|
||||
wantIsSet: true,
|
||||
wantVersion: 2,
|
||||
},
|
||||
{
|
||||
name: "version-zero",
|
||||
flags: []string{"-version=0"},
|
||||
wantString: "json",
|
||||
wantIsSet: true,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "version-invalid",
|
||||
flags: []string{"-version=1.0"},
|
||||
wantErr: "parse error",
|
||||
},
|
||||
{
|
||||
name: "format-xml-then-bool-true",
|
||||
flags: []string{"-format=xml", "-bool"},
|
||||
wantString: "json",
|
||||
wantIsSet: true,
|
||||
wantVersion: 1,
|
||||
},
|
||||
{
|
||||
name: "format-json-then-bool-true",
|
||||
flags: []string{"-format=json", "-bool"},
|
||||
wantString: "json",
|
||||
wantIsSet: true,
|
||||
wantVersion: 1,
|
||||
},
|
||||
{
|
||||
name: "format-json-line-then-bool-true",
|
||||
flags: []string{"-format=json-line", "-bool"},
|
||||
wantString: "json-line",
|
||||
wantIsSet: true,
|
||||
wantVersion: 1,
|
||||
},
|
||||
{
|
||||
name: "format-xml-then-bool-false",
|
||||
flags: []string{"-format=xml", "-bool=false"},
|
||||
wantString: "xml",
|
||||
wantIsSet: false,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "format-json-then-bool-false",
|
||||
flags: []string{"-format=json", "-bool=false"},
|
||||
wantString: "",
|
||||
wantIsSet: false,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "format-json-line-then-bool-false",
|
||||
flags: []string{"-format=json-line", "-bool=false"},
|
||||
wantString: "",
|
||||
wantIsSet: false,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "bool-true-then-format-xml",
|
||||
flags: []string{"-bool", "-format=xml"},
|
||||
wantString: "xml",
|
||||
wantIsSet: false,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "bool-true-then-format-json",
|
||||
flags: []string{"-bool", "--format=json"},
|
||||
wantString: "json",
|
||||
wantIsSet: true,
|
||||
wantVersion: 1,
|
||||
},
|
||||
{
|
||||
name: "bool-true-then-format-json-line",
|
||||
flags: []string{"-bool", "--format=json-line"},
|
||||
wantString: "json-line",
|
||||
wantIsSet: true,
|
||||
wantVersion: 1,
|
||||
},
|
||||
{
|
||||
name: "bool-false-then-format-xml",
|
||||
flags: []string{"-bool=false", "--format=xml"},
|
||||
wantString: "xml",
|
||||
wantIsSet: false,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "bool-false-then-format-json",
|
||||
flags: []string{"-bool=false", "--format=json"},
|
||||
wantString: "json",
|
||||
wantIsSet: true,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "bool-false-then-format-json-line",
|
||||
flags: []string{"-bool=false", "--format=json-line"},
|
||||
wantString: "json-line",
|
||||
wantIsSet: true,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "format-xml-then-version-true",
|
||||
flags: []string{"-format=xml", "-version"},
|
||||
wantString: "json",
|
||||
wantIsSet: true,
|
||||
wantVersion: 1,
|
||||
},
|
||||
{
|
||||
name: "format-json-then-version-true",
|
||||
flags: []string{"-format=json", "-version"},
|
||||
wantString: "json",
|
||||
wantIsSet: true,
|
||||
wantVersion: 1,
|
||||
},
|
||||
{
|
||||
name: "format-json-line-then-version-true",
|
||||
flags: []string{"-format=json-line", "-version"},
|
||||
wantString: "json-line",
|
||||
wantIsSet: true,
|
||||
wantVersion: 1,
|
||||
},
|
||||
{
|
||||
name: "format-xml-then-version-number",
|
||||
flags: []string{"-format=xml", "-version=2"},
|
||||
wantString: "json",
|
||||
wantIsSet: true,
|
||||
wantVersion: 2,
|
||||
},
|
||||
{
|
||||
name: "format-json-then-version-number",
|
||||
flags: []string{"-format=json", "-version=2"},
|
||||
wantString: "json",
|
||||
wantIsSet: true,
|
||||
wantVersion: 2,
|
||||
},
|
||||
{
|
||||
name: "format-json-line-then-version-number",
|
||||
flags: []string{"-format=json-line", "-version=2"},
|
||||
wantString: "json-line",
|
||||
wantIsSet: true,
|
||||
wantVersion: 2,
|
||||
},
|
||||
{
|
||||
name: "format-xml-then-version-zero",
|
||||
flags: []string{"-format=xml", "-version=0"},
|
||||
wantString: "json",
|
||||
wantIsSet: true,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "format-json-then-version-zero",
|
||||
flags: []string{"-format=json", "-version=0"},
|
||||
wantString: "json",
|
||||
wantIsSet: true,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "format-json-line-then-version-zero",
|
||||
flags: []string{"-format=json-line", "-version=0"},
|
||||
wantString: "json-line",
|
||||
wantIsSet: true,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "format-xml-then-version-false",
|
||||
flags: []string{"-format=xml", "-version=false"},
|
||||
wantString: "xml",
|
||||
wantIsSet: false,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "format-json-then-version-false",
|
||||
flags: []string{"-format=json", "-version=false"},
|
||||
wantString: "",
|
||||
wantIsSet: false,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "format-json-line-then-version-false",
|
||||
flags: []string{"-format=json-line", "-version=false"},
|
||||
wantString: "",
|
||||
wantIsSet: false,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "version-true-then-format-xml",
|
||||
flags: []string{"-version", "-format=xml"},
|
||||
wantString: "xml",
|
||||
wantIsSet: false,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "version-true-then-format-json",
|
||||
flags: []string{"-version", "--format=json"},
|
||||
wantString: "json",
|
||||
wantIsSet: true,
|
||||
wantVersion: 1,
|
||||
},
|
||||
{
|
||||
name: "version-true-then-format-json-line",
|
||||
flags: []string{"-version", "--format=json-line"},
|
||||
wantString: "json-line",
|
||||
wantIsSet: true,
|
||||
wantVersion: 1,
|
||||
},
|
||||
{
|
||||
name: "version-false-then-format-xml",
|
||||
flags: []string{"-version=false", "--format=xml"},
|
||||
wantString: "xml",
|
||||
wantIsSet: false,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "version-false-then-format-json",
|
||||
flags: []string{"-version=false", "--format=json"},
|
||||
wantString: "json",
|
||||
wantIsSet: true,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "version-false-then-format-json-line",
|
||||
flags: []string{"-version=false", "--format=json-line"},
|
||||
wantString: "json-line",
|
||||
wantIsSet: true,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "version-number-then-format-xml",
|
||||
flags: []string{"-version=2", "--format=xml"},
|
||||
wantString: "xml",
|
||||
wantIsSet: false,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "version-number-then-format-json",
|
||||
flags: []string{"-version=2", "--format=json"},
|
||||
wantString: "json",
|
||||
wantIsSet: true,
|
||||
wantVersion: 2,
|
||||
},
|
||||
{
|
||||
name: "version-number-then-format-json-line",
|
||||
flags: []string{"-version=2", "--format=json-line"},
|
||||
wantString: "json-line",
|
||||
wantIsSet: true,
|
||||
wantVersion: 2,
|
||||
},
|
||||
{
|
||||
name: "version-zero-then-format-xml",
|
||||
flags: []string{"-version=0", "--format=xml"},
|
||||
wantString: "xml",
|
||||
wantIsSet: false,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "version-zero-then-format-json",
|
||||
flags: []string{"-version=0", "--format=json"},
|
||||
wantString: "json",
|
||||
wantIsSet: true,
|
||||
wantVersion: 0,
|
||||
},
|
||||
{
|
||||
name: "version-zero-then-format-json-line",
|
||||
flags: []string{"-version=0", "--format=json-line"},
|
||||
wantString: "json-line",
|
||||
wantIsSet: true,
|
||||
wantVersion: 0,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var f jsonoutput.Format
|
||||
fs := flag.NewFlagSet("", flag.ContinueOnError)
|
||||
fs.Var(&f, "format", "")
|
||||
fs.Var(f.JSONBool(), "bool", "")
|
||||
fs.Var(f.JSONSchemaVersion(), "version", "")
|
||||
fs.SetOutput(io.Discard) // silence
|
||||
|
||||
t.Logf("flags: %q", tc.flags)
|
||||
if err := fs.Parse(tc.flags); err != nil {
|
||||
// Unwrap the header added by FlagSet.failf:
|
||||
// `invalid boolean value "invalid" for -json: `
|
||||
bits := strings.SplitN(err.Error(), ": ", 2)
|
||||
err := errors.New(bits[len(bits)-1])
|
||||
if tc.wantErr == "" && err != nil {
|
||||
t.Fatalf("err %s, want <nil>", err)
|
||||
} else if tc.wantErr != "" && (err == nil || err.Error() != tc.wantErr) {
|
||||
t.Fatalf("err %v, want %s", err, tc.wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
if got := f.String(); got != tc.wantString {
|
||||
t.Errorf("Format.String %q, want %q", got, tc.wantString)
|
||||
}
|
||||
if got := f.IsSet; got != tc.wantIsSet {
|
||||
t.Errorf("Format.IsSet %t, want %t", got, tc.wantIsSet)
|
||||
}
|
||||
if got := f.Version; got != tc.wantVersion {
|
||||
t.Errorf("Format.Version %d, want %d", got, tc.wantVersion)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,12 @@
|
||||
// Passing just the boolean flag will always return 1, to preserve
|
||||
// compatibility with scripts written before we versioned our output.
|
||||
//
|
||||
// This package provides a [Format] flag type that allows callers to specify
|
||||
// which output format the command should print.
|
||||
// This flag provides [Format.JSONBool] and [Format.JSONSchemaVersion] methods
|
||||
// to support combining both -format=json and -format=json-line options
|
||||
// with either boolean or versioned -json flags.
|
||||
//
|
||||
// This package also provides [ResponseEnvelope] which is used to provide the
|
||||
// set of fields common to all versioned JSON output.
|
||||
package jsonoutput
|
||||
@@ -44,7 +50,7 @@ type SchemaVersion struct {
|
||||
}
|
||||
|
||||
// String returns the default value which is printed in the CLI help text.
|
||||
func (v *SchemaVersion) String() string {
|
||||
func (v SchemaVersion) String() string {
|
||||
if v.IsSet {
|
||||
return strconv.Itoa(v.Version)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user