diff --git a/cmd/client/color.go b/cmd/client/color.go index 71384a1..0bbdd07 100644 --- a/cmd/client/color.go +++ b/cmd/client/color.go @@ -3,6 +3,7 @@ package main import ( + "encoding/json" "strings" cli "git.ipng.ch/ipng/golang-cli" @@ -10,10 +11,10 @@ import ( // formatError returns a user-friendly error string. gRPC status errors are // unwrapped to show only the server's message (no "rpc error: code = ..." -// boilerplate). The result is wrapped in red when color is enabled. The label() -// helper and the ANSI palette now live in the golang-cli library (cli.Label, -// cli.Red, ...); only this gRPC-specific unwrap stays here, wired in as -// App.FormatError. +// boilerplate). In JSON mode it returns the message as a {"error": "..."} +// document (so failures are machine-readable, matching the data/`{}` a success +// prints); otherwise it wraps the message in red when color is enabled. +// App.FormatError prints the result. func formatError(err error) string { msg := err.Error() // google.golang.org/grpc/status errors format as: @@ -21,6 +22,10 @@ func formatError(err error) string { if i := strings.Index(msg, " desc = "); i >= 0 { msg = msg[i+len(" desc = "):] } + if cli.IsJSON() { + b, _ := json.Marshal(map[string]string{"error": msg}) + return string(b) + } if cli.ColorEnabled() { return cli.Red + msg + cli.Reset } diff --git a/cmd/client/commands.go b/cmd/client/commands.go index 2e55ced..9ee12e2 100644 --- a/cmd/client/commands.go +++ b/cmd/client/commands.go @@ -230,6 +230,9 @@ func buildTree() *node { } root.Children = []*node{show, set, watch, configNode, syncNode, quit, exit} + // In -json mode, make every silent successful command print "{}" (see + // wrapJSON); show/return-data commands emit their own JSON first. + wrapJSON(root) return root } @@ -272,6 +275,9 @@ func runShowVPPInfo(ctx context.Context, client grpcapi.MaglevClient, _ []string if err != nil { return err } + if cli.IsJSON() { + return emitProto(info) + } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("version"), info.Version) _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("build-date"), info.BuildDate) @@ -297,6 +303,9 @@ func runShowVPPLBState(ctx context.Context, client grpcapi.MaglevClient, _ []str if err != nil { return err } + if cli.IsJSON() { + return emitProto(state) + } // ---- global config ---- w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) @@ -354,6 +363,9 @@ func runShowVPPLBCounters(ctx context.Context, client grpcapi.MaglevClient, _ [] if err != nil { return err } + if cli.IsJSON() { + return emitProto(resp) + } if len(resp.Vips) == 0 { fmt.Println("(no counters — VPP disconnected or scrape pending)") @@ -417,18 +429,18 @@ func runSyncVPPLBState(ctx context.Context, client grpcapi.MaglevClient, args [] name := args[0] req.FrontendName = &name } - if _, err := client.SyncVPPLBState(ctx, req); err != nil { - return err - } - if req.FrontendName != nil { - fmt.Printf("synced frontend %q to VPP\n", *req.FrontendName) - } else { - fmt.Println("synced full LB state to VPP") - } - return nil + _, err := client.SyncVPPLBState(ctx, req) + return err } func runShowVersion(_ context.Context, _ grpcapi.MaglevClient, _ []string) error { + if cli.IsJSON() { + return emitJSON(map[string]string{ + "version": buildinfo.Version(), + "commit": buildinfo.Commit(), + "built": buildinfo.Date(), + }) + } fmt.Printf("maglevc %s (commit %s, built %s)\n", buildinfo.Version(), buildinfo.Commit(), buildinfo.Date()) return nil @@ -445,6 +457,9 @@ func runShowFrontends(ctx context.Context, client grpcapi.MaglevClient, _ []stri if err != nil { return err } + if cli.IsJSON() { + return emitProto(resp) + } for _, name := range resp.FrontendNames { fmt.Println(name) } @@ -461,6 +476,9 @@ func runShowFrontend(ctx context.Context, client grpcapi.MaglevClient, args []st if err != nil { return err } + if cli.IsJSON() { + return emitProto(info) + } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("name"), info.Name) @@ -522,6 +540,9 @@ func runShowBackends(ctx context.Context, client grpcapi.MaglevClient, _ []strin if err != nil { return err } + if cli.IsJSON() { + return emitProto(resp) + } for _, name := range resp.BackendNames { fmt.Println(name) } @@ -538,6 +559,9 @@ func runShowBackend(ctx context.Context, client grpcapi.MaglevClient, args []str if err != nil { return err } + if cli.IsJSON() { + return emitProto(info) + } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("name"), info.Name) _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("address"), info.Address) @@ -577,6 +601,9 @@ func runShowHealthChecks(ctx context.Context, client grpcapi.MaglevClient, _ []s if err != nil { return err } + if cli.IsJSON() { + return emitProto(resp) + } for _, name := range resp.Names { fmt.Println(name) } @@ -593,6 +620,9 @@ func runShowHealthCheck(ctx context.Context, client grpcapi.MaglevClient, args [ if err != nil { return err } + if cli.IsJSON() { + return emitProto(info) + } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("name"), info.Name) _, _ = fmt.Fprintf(w, "%s\t%s\n", cli.Label("type"), info.Type) @@ -640,12 +670,8 @@ func runPauseBackend(ctx context.Context, client grpcapi.MaglevClient, args []st } ctx, cancel := context.WithTimeout(ctx, callTimeout) defer cancel() - info, err := client.PauseBackend(ctx, &grpcapi.BackendRequest{Name: args[0]}) - if err != nil { - return err - } - fmt.Printf("%s: setting state to '%s'\n", info.Name, info.State) - return nil + _, err := client.PauseBackend(ctx, &grpcapi.BackendRequest{Name: args[0]}) + return err } func runResumeBackend(ctx context.Context, client grpcapi.MaglevClient, args []string) error { @@ -654,12 +680,8 @@ func runResumeBackend(ctx context.Context, client grpcapi.MaglevClient, args []s } ctx, cancel := context.WithTimeout(ctx, callTimeout) defer cancel() - info, err := client.ResumeBackend(ctx, &grpcapi.BackendRequest{Name: args[0]}) - if err != nil { - return err - } - fmt.Printf("%s: setting state to '%s'\n", info.Name, info.State) - return nil + _, err := client.ResumeBackend(ctx, &grpcapi.BackendRequest{Name: args[0]}) + return err } func runSetFrontendPoolBackendWeight(ctx context.Context, client grpcapi.MaglevClient, args []string) error { @@ -681,34 +703,14 @@ func setFrontendPoolBackendWeight(ctx context.Context, client grpcapi.MaglevClie } ctx, cancel := context.WithTimeout(ctx, callTimeout) defer cancel() - info, err := client.SetFrontendPoolBackendWeight(ctx, &grpcapi.SetWeightRequest{ + _, err = client.SetFrontendPoolBackendWeight(ctx, &grpcapi.SetWeightRequest{ Frontend: frontendName, Pool: poolName, Backend: backendName, Weight: int32(weight), Flush: flush, }) - if err != nil { - return err - } - // Print the updated pool so the user can confirm the new weight. - for _, pool := range info.Pools { - if pool.Name != poolName { - continue - } - for _, pb := range pool.Backends { - if pb.Name == backendName { - flushNote := "" - if flush { - flushNote = " (flushed)" - } - fmt.Printf("%s pool %s backend %s: weight set to %d%s\n", - info.Name, pool.Name, pb.Name, pb.Weight, flushNote) - return nil - } - } - } - return nil + return err } func runEnableBackend(ctx context.Context, client grpcapi.MaglevClient, args []string) error { @@ -717,12 +719,8 @@ func runEnableBackend(ctx context.Context, client grpcapi.MaglevClient, args []s } ctx, cancel := context.WithTimeout(ctx, callTimeout) defer cancel() - info, err := client.EnableBackend(ctx, &grpcapi.BackendRequest{Name: args[0]}) - if err != nil { - return err - } - fmt.Printf("%s: enabled, state is '%s'\n", info.Name, info.State) - return nil + _, err := client.EnableBackend(ctx, &grpcapi.BackendRequest{Name: args[0]}) + return err } func runDisableBackend(ctx context.Context, client grpcapi.MaglevClient, args []string) error { @@ -731,12 +729,8 @@ func runDisableBackend(ctx context.Context, client grpcapi.MaglevClient, args [] } ctx, cancel := context.WithTimeout(ctx, callTimeout) defer cancel() - info, err := client.DisableBackend(ctx, &grpcapi.BackendRequest{Name: args[0]}) - if err != nil { - return err - } - fmt.Printf("%s: disabled, state is '%s'\n", info.Name, info.State) - return nil + _, err := client.DisableBackend(ctx, &grpcapi.BackendRequest{Name: args[0]}) + return err } func runConfigCheck(ctx context.Context, client grpcapi.MaglevClient, _ []string) error { @@ -746,6 +740,9 @@ func runConfigCheck(ctx context.Context, client grpcapi.MaglevClient, _ []string if err != nil { return err } + if cli.IsJSON() { + return emitProto(resp) + } if resp.Ok { fmt.Println("config ok") return nil @@ -764,7 +761,6 @@ func runConfigReload(ctx context.Context, client grpcapi.MaglevClient, _ []strin return err } if resp.Ok { - fmt.Println("config reloaded") return nil } if resp.ParseError != "" { diff --git a/cmd/client/json.go b/cmd/client/json.go new file mode 100644 index 0000000..659793e --- /dev/null +++ b/cmd/client/json.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "encoding/json" + "fmt" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + + cli "git.ipng.ch/ipng/golang-cli" + "git.ipng.ch/ipng/vpp-maglev/internal/grpcapi" +) + +// JSON output (-json). The protobuf response is the model: in JSON mode each +// command emits it via the shared cli.EmitJSON (protojson field names/enums); in +// text mode it keeps its curated tabwriter painter (which robot tests parse, and +// which carries column alignment JSON does not need). A few commands print only +// a confirmation in text mode — those emit the returned proto in JSON, or, when +// there is nothing to return, rely on wrapJSON to print "{}". Errors render as +// {"error": "..."} (see color.go). + +// jsonEmitted records whether the current command produced JSON output of its +// own. Commands run serially, so a package var is safe. wrapJSON resets it +// before each command and appends "{}" for a successful command that produced +// nothing. +var jsonEmitted bool + +// emitProto emits one proto message as JSON via the shared renderer. +func emitProto(m proto.Message) error { + raw, err := protoField(m) + if err != nil { + return err + } + jsonEmitted = true + return cli.EmitJSON(raw) +} + +// emitJSON emits a composite value (maps/structs) as JSON. +func emitJSON(v any) error { + jsonEmitted = true + return cli.EmitJSON(v) +} + +// protoField marshals a proto message to compact JSON (proto field names and +// enum spellings). +func protoField(m proto.Message) (json.RawMessage, error) { + b, err := protojson.Marshal(m) + if err != nil { + return nil, fmt.Errorf("marshal json: %w", err) + } + return json.RawMessage(b), nil +} + +// wrapJSON decorates every runnable node so that, in -json mode, a command that +// succeeds without emitting output of its own (e.g. sync) still produces "{}", +// giving every command a uniform JSON contract. Commands that show or return +// data emit it (setting jsonEmitted); errors propagate to App.FormatError, which +// renders them as {"error": "..."}. In text mode the wrapper is a no-op. +func wrapJSON(root *node) { + seen := map[*node]bool{} + var walk func(*node) + walk = func(n *node) { + if n == nil || seen[n] { + return + } + seen[n] = true + if n.Run != nil { + orig := n.Run + n.Run = func(ctx context.Context, c grpcapi.MaglevClient, a []string) error { + jsonEmitted = false + err := orig(ctx, c, a) + if err == nil && cli.IsJSON() && !jsonEmitted { + return emitJSON(struct{}{}) + } + return err + } + } + for _, ch := range n.Children { + walk(ch) + } + } + walk(root) +} diff --git a/cmd/client/main.go b/cmd/client/main.go index 1894e8b..8930159 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -33,6 +33,7 @@ func main() { Date: buildinfo.Date(), Prompt: "maglev> ", Root: buildTree(), + JSON: true, // commands render via cli.IsJSON() (see json.go) DefaultServer: "localhost:9090", ServerEnv: "MAGLEV_SERVER", Connect: connect, diff --git a/cmd/client/watch.go b/cmd/client/watch.go index b0055ad..17b45e5 100644 --- a/cmd/client/watch.go +++ b/cmd/client/watch.go @@ -26,7 +26,8 @@ func dynWatchEventOpts(_ context.Context, _ grpcapi.MaglevClient, _ []string) [] // All tokens after 'events' are captured as args by the circular slot node in the tree. // If none of log/backend/frontend are mentioned, all three default to true. func runWatchEvents(ctx context.Context, client grpcapi.MaglevClient, args []string) error { - var maxEvents int // 0 = unlimited + jsonEmitted = true // watch streams its own JSON event lines; never append "{}" + var maxEvents int // 0 = unlimited var wantLog, wantBackend, wantFrontend bool logLevel := "" anyExplicit := false diff --git a/go.mod b/go.mod index 70e0841..81d5796 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module git.ipng.ch/ipng/vpp-maglev go 1.25.0 require ( - git.ipng.ch/ipng/golang-cli v1.3.0 + git.ipng.ch/ipng/golang-cli v1.4.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 diff --git a/go.sum b/go.sum index d9fd393..b8054fe 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ git.ipng.ch/ipng/golang-cli v1.3.0 h1:E26W55czJSl+kmSXpwFWxYHsnaT84unsNBtfdM3iT4M= git.ipng.ch/ipng/golang-cli v1.3.0/go.mod h1:8yeV4X7MF5hKQnxnYYJKyqby4P58EtH82zd36BrNbqY= +git.ipng.ch/ipng/golang-cli v1.4.0 h1:KpvVqkieYoH3en7ay0QGmagiYcXJoUAiU49OLx3eZGw= +git.ipng.ch/ipng/golang-cli v1.4.0/go.mod h1:8yeV4X7MF5hKQnxnYYJKyqby4P58EtH82zd36BrNbqY= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=