feat(cli): add JSON output format for 'uc machine ls', remove MACHINE ID column from table output

This commit is contained in:
Pasha Sviderski
2026-06-24 20:02:56 +10:00
parent ad43569fe5
commit 1d5c71730f
3 changed files with 197 additions and 5 deletions
+23 -4
View File
@@ -2,6 +2,7 @@ package machine
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"net/netip" "net/netip"
"strings" "strings"
@@ -14,19 +15,29 @@ import (
) )
func NewListCommand() *cobra.Command { func NewListCommand() *cobra.Command {
var output string
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "ls", Use: "ls",
Aliases: []string{"list"}, Aliases: []string{"list"},
Short: "List machines in a cluster.", Short: "List machines in a cluster.",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI) uncli := cmd.Context().Value("cli").(*cli.CLI)
return list(cmd.Context(), uncli) return list(cmd.Context(), uncli, output)
}, },
} }
cmd.Flags().StringVarP(&output, "output", "o", "",
"Output format: 'json' or empty for a human-readable table.")
return cmd return cmd
} }
func list(ctx context.Context, uncli *cli.CLI) error { func list(ctx context.Context, uncli *cli.CLI, output string) error {
if output != "" && output != "json" {
return fmt.Errorf("unsupported output format '%s' (supported: json)", output)
}
client, err := uncli.ConnectCluster(ctx) client, err := uncli.ConnectCluster(ctx)
if err != nil { if err != nil {
return fmt.Errorf("connect to cluster: %w", err) return fmt.Errorf("connect to cluster: %w", err)
@@ -38,10 +49,19 @@ func list(ctx context.Context, uncli *cli.CLI) error {
return fmt.Errorf("list machines: %w", err) return fmt.Errorf("list machines: %w", err)
} }
if output == "json" {
data, err := json.MarshalIndent(machines.ToNative(), "", " ")
if err != nil {
return fmt.Errorf("marshal machines: %w", err)
}
fmt.Println(string(data))
return nil
}
// Print the list of machines in a table format. // Print the list of machines in a table format.
t := tui.NewTable() t := tui.NewTable()
t.Headers("NAME", "STATE", "ADDRESS", "PUBLIC IP", "WIREGUARD ENDPOINTS", t.Headers("NAME", "STATE", "ADDRESS", "PUBLIC IP", "WIREGUARD ENDPOINTS",
"OS", "KERNEL", "ARCH", "DOCKER", "VERSION", "MACHINE ID") "OS", "KERNEL", "ARCH", "DOCKER", "VERSION")
for _, member := range machines { for _, member := range machines {
m := member.Machine m := member.Machine
@@ -96,7 +116,6 @@ func list(ctx context.Context, uncli *cli.CLI) error {
arch, arch,
dockerVersion, dockerVersion,
daemonVersion, daemonVersion,
member.Machine.Id,
) )
} }
+94 -1
View File
@@ -1,6 +1,11 @@
package api package api
import "github.com/psviderski/uncloud/internal/machine/api/pb" import (
"net/netip"
"strings"
"github.com/psviderski/uncloud/internal/machine/api/pb"
)
// MachineFilter defines criteria to filter machines in ListMachines. // MachineFilter defines criteria to filter machines in ListMachines.
type MachineFilter struct { type MachineFilter struct {
@@ -21,3 +26,91 @@ func (m MachineMembersList) FindByNameOrID(nameOrID string) *pb.MachineMember {
return nil return nil
} }
// ToNative returns the Go native representation of the protobuf machine members.
func (m MachineMembersList) ToNative() []MachineMember {
infos := make([]MachineMember, len(m))
for i, member := range m {
infos[i] = machineMemberFromProto(member)
}
return infos
}
// MachineMember is the JSON-serializable view of a machine member.
type MachineMember struct {
ID string
Name string
State string
Network MachineNetwork
PublicIP netip.Addr
DaemonVersion string
DockerVersion string
Hostname string
Arch string
OSPrettyName string
KernelVersion string
}
// MachineNetwork describes a machine's WireGuard network configuration.
type MachineNetwork struct {
Subnet netip.Prefix
ManagementIP netip.Addr
Endpoints []netip.AddrPort
// PublicKey is the WireGuard public key.
PublicKey []byte
}
func machineMemberFromProto(pbMember *pb.MachineMember) MachineMember {
m := pbMember.Machine
member := MachineMember{
ID: m.Id,
Name: m.Name,
State: capitalise(pbMember.State.String()),
DaemonVersion: m.DaemonVersion,
DockerVersion: m.DockerVersion,
Hostname: m.Hostname,
Arch: m.Arch,
OSPrettyName: m.OsPrettyName,
KernelVersion: m.KernelVersion,
}
if m.PublicIp != nil {
if ip, err := m.PublicIp.ToAddr(); err == nil {
member.PublicIP = ip
}
}
if m.Network != nil {
network := MachineNetwork{
PublicKey: m.Network.PublicKey,
}
if m.Network.Subnet != nil {
if subnet, err := m.Network.Subnet.ToPrefix(); err == nil {
network.Subnet = subnet
}
}
if m.Network.ManagementIp != nil {
if ip, err := m.Network.ManagementIp.ToAddr(); err == nil {
network.ManagementIP = ip
}
}
endpoints := make([]netip.AddrPort, 0, len(m.Network.Endpoints))
for _, ep := range m.Network.Endpoints {
if addrPort, err := ep.ToAddrPort(); err == nil {
endpoints = append(endpoints, addrPort)
}
}
network.Endpoints = endpoints
member.Network = network
}
return member
}
func capitalise(s string) string {
if s == "" {
return ""
}
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
}
+80
View File
@@ -0,0 +1,80 @@
package api
import (
"encoding/json"
"net/netip"
"testing"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMachineMembersList_Info(t *testing.T) {
t.Parallel()
publicKey := []byte{0x01, 0x02, 0x03, 0x04}
members := MachineMembersList{
{
Machine: &pb.MachineInfo{
Id: "abc123",
Name: "vm-1",
Hostname: "vm-1.example.com",
Arch: "amd64",
OsPrettyName: "Ubuntu 24.04.4 LTS",
KernelVersion: "6.8.0-31-generic",
DockerVersion: "27.1.1",
DaemonVersion: "0.9.0",
PublicIp: pb.NewIP(netip.MustParseAddr("203.0.113.5")),
Network: &pb.NetworkConfig{
Subnet: pb.NewIPPrefix(netip.MustParsePrefix("10.210.0.0/24")),
ManagementIp: pb.NewIP(netip.MustParseAddr("10.210.0.1")),
Endpoints: []*pb.IPPort{
pb.NewIPPort(netip.MustParseAddrPort("203.0.113.5:51820")),
},
PublicKey: publicKey,
},
},
State: pb.MachineMember_UP,
},
}
infos := members.ToNative()
require.Len(t, infos, 1)
assert.Equal(t, MachineMember{
ID: "abc123",
Name: "vm-1",
State: "Up",
Hostname: "vm-1.example.com",
Arch: "amd64",
OSPrettyName: "Ubuntu 24.04.4 LTS",
KernelVersion: "6.8.0-31-generic",
DockerVersion: "27.1.1",
DaemonVersion: "0.9.0",
PublicIP: netip.MustParseAddr("203.0.113.5"),
Network: MachineNetwork{
Subnet: netip.MustParsePrefix("10.210.0.0/24"),
ManagementIP: netip.MustParseAddr("10.210.0.1"),
Endpoints: []netip.AddrPort{netip.MustParseAddrPort("203.0.113.5:51820")},
PublicKey: publicKey,
},
}, infos[0])
// The JSON output must use PascalCase keys to stay consistent with `docker inspect` and the
// rest of Uncloud's JSON output.
data, err := json.Marshal(infos)
require.NoError(t, err)
out := string(data)
for _, key := range []string{
`"ID"`, `"Name"`, `"State"`, `"Hostname"`, `"Arch"`, `"OSPrettyName"`, `"KernelVersion"`,
`"DockerVersion"`, `"DaemonVersion"`, `"PublicIP"`, `"Network"`,
`"Subnet"`, `"ManagementIP"`, `"Endpoints"`, `"PublicKey"`,
} {
assert.Contains(t, out, key)
}
assert.Contains(t, out, `"PublicIP":"203.0.113.5"`)
assert.Contains(t, out, `"Subnet":"10.210.0.0/24"`)
assert.Contains(t, out, `"Endpoints":["203.0.113.5:51820"]`)
assert.Contains(t, out, `"PublicKey":"AQIDBA=="`)
}