From 1d5c71730fc2e7f3c6f9411af60e899bff113c2b Mon Sep 17 00:00:00 2001 From: Pasha Sviderski Date: Wed, 24 Jun 2026 20:02:56 +1000 Subject: [PATCH] feat(cli): add JSON output format for 'uc machine ls', remove MACHINE ID column from table output --- cmd/uncloud/machine/ls.go | 27 +++++++++-- pkg/api/machine.go | 95 ++++++++++++++++++++++++++++++++++++++- pkg/api/machine_test.go | 80 +++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 pkg/api/machine_test.go diff --git a/cmd/uncloud/machine/ls.go b/cmd/uncloud/machine/ls.go index 69fcf96d..0b6dde1c 100644 --- a/cmd/uncloud/machine/ls.go +++ b/cmd/uncloud/machine/ls.go @@ -2,6 +2,7 @@ package machine import ( "context" + "encoding/json" "fmt" "net/netip" "strings" @@ -14,19 +15,29 @@ import ( ) func NewListCommand() *cobra.Command { + var output string + cmd := &cobra.Command{ Use: "ls", Aliases: []string{"list"}, Short: "List machines in a cluster.", RunE: func(cmd *cobra.Command, args []string) error { 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 } -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) if err != nil { 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) } + 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. t := tui.NewTable() 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 { m := member.Machine @@ -96,7 +116,6 @@ func list(ctx context.Context, uncli *cli.CLI) error { arch, dockerVersion, daemonVersion, - member.Machine.Id, ) } diff --git a/pkg/api/machine.go b/pkg/api/machine.go index 8c16c0db..7b483edd 100644 --- a/pkg/api/machine.go +++ b/pkg/api/machine.go @@ -1,6 +1,11 @@ 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. type MachineFilter struct { @@ -21,3 +26,91 @@ func (m MachineMembersList) FindByNameOrID(nameOrID string) *pb.MachineMember { 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:]) +} diff --git a/pkg/api/machine_test.go b/pkg/api/machine_test.go new file mode 100644 index 00000000..c8aaad2e --- /dev/null +++ b/pkg/api/machine_test.go @@ -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=="`) +}