From 5cbe32e0bc0acd4ef596f70b596997ee551a0cc2 Mon Sep 17 00:00:00 2001 From: Simon Law Date: Mon, 29 Jun 2026 15:39:17 -0700 Subject: [PATCH] cmd/tailscale/cli/jsonoutput: add support for --format=json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .../cli/jsonoutput/example_format_test.go | 41 ++ .../jsonoutput/example_schemaversion_test.go | 4 +- cmd/tailscale/cli/jsonoutput/format.go | 130 ++++++ cmd/tailscale/cli/jsonoutput/format_test.go | 404 ++++++++++++++++++ cmd/tailscale/cli/jsonoutput/jsonoutput.go | 8 +- 5 files changed, 584 insertions(+), 3 deletions(-) create mode 100644 cmd/tailscale/cli/jsonoutput/example_format_test.go create mode 100644 cmd/tailscale/cli/jsonoutput/format.go create mode 100644 cmd/tailscale/cli/jsonoutput/format_test.go diff --git a/cmd/tailscale/cli/jsonoutput/example_format_test.go b/cmd/tailscale/cli/jsonoutput/example_format_test.go new file mode 100644 index 000000000..bea6a722a --- /dev/null +++ b/cmd/tailscale/cli/jsonoutput/example_format_test.go @@ -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} +} diff --git a/cmd/tailscale/cli/jsonoutput/example_schemaversion_test.go b/cmd/tailscale/cli/jsonoutput/example_schemaversion_test.go index 5d5246a83..2e9bc2e77 100644 --- a/cmd/tailscale/cli/jsonoutput/example_schemaversion_test.go +++ b/cmd/tailscale/cli/jsonoutput/example_schemaversion_test.go @@ -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} } diff --git a/cmd/tailscale/cli/jsonoutput/format.go b/cmd/tailscale/cli/jsonoutput/format.go new file mode 100644 index 000000000..422f76860 --- /dev/null +++ b/cmd/tailscale/cli/jsonoutput/format.go @@ -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() +} diff --git a/cmd/tailscale/cli/jsonoutput/format_test.go b/cmd/tailscale/cli/jsonoutput/format_test.go new file mode 100644 index 000000000..2b324aa56 --- /dev/null +++ b/cmd/tailscale/cli/jsonoutput/format_test.go @@ -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 ", 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) + } + }) + } +} diff --git a/cmd/tailscale/cli/jsonoutput/jsonoutput.go b/cmd/tailscale/cli/jsonoutput/jsonoutput.go index 41b605eea..1b062b816 100644 --- a/cmd/tailscale/cli/jsonoutput/jsonoutput.go +++ b/cmd/tailscale/cli/jsonoutput/jsonoutput.go @@ -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) }