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:
Simon Law
2026-06-29 15:39:17 -07:00
committed by Simon Law
parent ca91eafce5
commit 5cbe32e0bc
5 changed files with 584 additions and 3 deletions

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

View File

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

View 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 doesnt 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()
}

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

View File

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