diff --git a/cmd/uncloud/context/ls.go b/cmd/uncloud/context/ls.go index e9eb4b98..d2b74b8c 100644 --- a/cmd/uncloud/context/ls.go +++ b/cmd/uncloud/context/ls.go @@ -1,34 +1,58 @@ package context import ( + "encoding/json" "fmt" "maps" "slices" "github.com/psviderski/uncloud/internal/cli" + "github.com/psviderski/uncloud/internal/cli/config" + "github.com/psviderski/uncloud/internal/cli/output" "github.com/psviderski/uncloud/internal/cli/tui" "github.com/spf13/cobra" ) +type listOptions struct { + output string +} + func NewListCommand() *cobra.Command { + opts := listOptions{} + cmd := &cobra.Command{ Use: "ls", Aliases: []string{"list"}, Short: "List available cluster contexts.", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { uncli := cmd.Context().Value("cli").(*cli.CLI) - return list(uncli) + return list(uncli, opts) + }, + PreRunE: func(cmd *cobra.Command, args []string) error { + return output.FlagValue(opts.output) }, } + output.Flag(cmd, &opts.output) + return cmd } -func list(uncli *cli.CLI) error { +func list(uncli *cli.CLI, opts listOptions) error { if uncli.Config == nil { return fmt.Errorf("context management is not available: Uncloud configuration file is not being used") } + if opts.output == "json" { + type Contexts struct { // Wrap in Contexts type to create array of Contexts. + Contexts map[string]*config.Context `json:"Contexts"` + } + data, _ := json.MarshalIndent(Contexts{uncli.Config.Contexts}, "", " ") + fmt.Println(string(data)) + return nil + } + if len(uncli.Config.Contexts) == 0 { fmt.Println("No contexts found") return nil diff --git a/cmd/uncloud/image/ls.go b/cmd/uncloud/image/ls.go index 324a7e08..3cfc35c4 100644 --- a/cmd/uncloud/image/ls.go +++ b/cmd/uncloud/image/ls.go @@ -2,6 +2,7 @@ package image import ( "context" + "encoding/json" "fmt" "os" "slices" @@ -17,6 +18,7 @@ import ( "github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli/completion" + "github.com/psviderski/uncloud/internal/cli/output" "github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/pkg/api" "github.com/spf13/cobra" @@ -25,6 +27,7 @@ import ( type listOptions struct { machines []string nameFilter string + output string } func NewListCommand() *cobra.Command { @@ -57,11 +60,15 @@ func NewListCommand() *cobra.Command { uncli := cmd.Context().Value("cli").(*cli.CLI) return list(cmd.Context(), uncli, opts) }, + PreRunE: func(cmd *cobra.Command, args []string) error { + return output.FlagValue(opts.output) + }, } cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil, "Filter images by machine name or ID. Can be specified multiple times or as a comma-separated list. "+ "(default is include all machines)") + output.Flag(cmd, &opts.output) completion.MachinesFlag(cmd) @@ -114,6 +121,12 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error { // Collect all images from all machines. var rows []imageRow + // Wrapper struct for json output. + type Images struct { + Images []image.Summary + } + images := Images{Images: []image.Summary{}} + for _, machineImages := range clusterImages { // Get machine name for better readability. machineName := machineImages.Metadata.Machine @@ -126,6 +139,11 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error { store = "containerd" } + if opts.output == "json" { + images.Images = append(images.Images, machineImages.Images...) + continue + } + // Process each image for this machine. for _, img := range machineImages.Images { // Show the first 12 chars without 'sha256:' as the image ID like Docker does. @@ -171,6 +189,12 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error { } } + if opts.output == "json" { + data, _ := json.MarshalIndent(images, "", " ") + fmt.Println(string(data)) + return nil + } + if len(rows) == 0 { if opts.nameFilter != "" { fmt.Printf("No images matching '%s' found.\n", opts.nameFilter) diff --git a/cmd/uncloud/machine/ls.go b/cmd/uncloud/machine/ls.go index e04c15d8..db46541b 100644 --- a/cmd/uncloud/machine/ls.go +++ b/cmd/uncloud/machine/ls.go @@ -2,30 +2,46 @@ package machine import ( "context" + "encoding/json" "fmt" "net/netip" "strings" "github.com/psviderski/uncloud/internal/cli" + "github.com/psviderski/uncloud/internal/cli/output" "github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/internal/machine/network" + "github.com/psviderski/uncloud/pkg/api" "github.com/spf13/cobra" ) +type listOptions struct { + output string +} + func NewListCommand() *cobra.Command { + opts := listOptions{} + cmd := &cobra.Command{ Use: "ls", Aliases: []string{"list"}, Short: "List machines in a cluster.", + Args: cobra.NoArgs, 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, opts) + }, + PreRunE: func(cmd *cobra.Command, args []string) error { + return output.FlagValue(opts.output) }, } + + output.Flag(cmd, &opts.output) + return cmd } -func list(ctx context.Context, uncli *cli.CLI) error { +func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error { client, err := uncli.ConnectCluster(ctx) if err != nil { return fmt.Errorf("connect to cluster: %w", err) @@ -37,6 +53,16 @@ func list(ctx context.Context, uncli *cli.CLI) error { return fmt.Errorf("list machines: %w", err) } + if opts.output == "json" { + // Wrap in Machines type to create array of Machines. + type Machines struct { + Machines api.MachineMembersList `json:"Machines"` + } + data, _ := json.MarshalIndent(Machines{machines}, "", " ") + 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", "MACHINE ID") diff --git a/cmd/uncloud/volume/ls.go b/cmd/uncloud/volume/ls.go index e6b6d343..d8d19ac1 100644 --- a/cmd/uncloud/volume/ls.go +++ b/cmd/uncloud/volume/ls.go @@ -2,12 +2,14 @@ package volume import ( "context" + "encoding/json" "fmt" "slices" "strings" "github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli/completion" + "github.com/psviderski/uncloud/internal/cli/output" "github.com/psviderski/uncloud/internal/cli/tui" "github.com/psviderski/uncloud/pkg/api" "github.com/spf13/cobra" @@ -16,6 +18,7 @@ import ( type listOptions struct { machines []string quiet bool + output string } func NewListCommand() *cobra.Command { @@ -25,10 +28,14 @@ func NewListCommand() *cobra.Command { Use: "ls", Aliases: []string{"list"}, Short: "List volumes across all machines in the cluster.", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { uncli := cmd.Context().Value("cli").(*cli.CLI) return list(cmd.Context(), uncli, opts) }, + PreRunE: func(cmd *cobra.Command, args []string) error { + return output.FlagValue(opts.output) + }, } cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil, @@ -36,6 +43,7 @@ func NewListCommand() *cobra.Command { "(default is include all machines)") cmd.Flags().BoolVarP(&opts.quiet, "quiet", "q", false, "Only display volume names.") + output.Flag(cmd, &opts.output) completion.MachinesFlag(cmd) @@ -63,6 +71,15 @@ func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error { return fmt.Errorf("list volumes: %w", err) } + if opts.output == "json" { + type Volumes struct { // Wrap in Volumes type to create array of Volumes. + Volumes []api.MachineVolume `json:"Volumes"` + } + data, _ := json.MarshalIndent(Volumes{volumes}, "", " ") + fmt.Println(string(data)) + return nil + } + if len(volumes) == 0 { if !opts.quiet { fmt.Println("No volumes found.") diff --git a/internal/cli/config/config.go b/internal/cli/config/config.go index 34548767..697b4ca3 100644 --- a/internal/cli/config/config.go +++ b/internal/cli/config/config.go @@ -9,8 +9,8 @@ import ( ) type Config struct { - CurrentContext string `yaml:"current_context"` - Contexts map[string]*Context `yaml:"contexts"` + CurrentContext string `yaml:"current_context" json:"CurrentContext"` + Contexts map[string]*Context `yaml:"contexts" json:"Contexts"` // path is the file path config is read from. path string diff --git a/internal/cli/config/connection.go b/internal/cli/config/connection.go index 4762be99..19f3fe5c 100644 --- a/internal/cli/config/connection.go +++ b/internal/cli/config/connection.go @@ -13,20 +13,20 @@ import ( type MachineConnection struct { // SSH uses the system ssh CLI command to connect. This is the default SSH connection method. - SSH SSHDestination `yaml:"ssh,omitempty"` + SSH SSHDestination `yaml:"ssh,omitempty" json:"SSH,omitempty"` // SSHCLI is a backward-compatible alias for SSH. - SSHCLI SSHDestination `yaml:"ssh_cli,omitempty"` + SSHCLI SSHDestination `yaml:"ssh_cli,omitempty" json:"SSHCli,omitempty"` // SSHGo uses Go's built-in SSH library to connect. - SSHGo SSHDestination `yaml:"ssh_go,omitempty"` - SSHKeyFile string `yaml:"ssh_key_file,omitempty"` + SSHGo SSHDestination `yaml:"ssh_go,omitempty" json:"SSHGo,omitempty"` + SSHKeyFile string `yaml:"ssh_key_file,omitempty" json:"SSHKey,omitempty"` // TCP is the address and port of the machine's API server. // The pointer is used to omit the field when not set. Otherwise, yaml marshalling includes an empty object. - TCP *netip.AddrPort `yaml:"tcp,omitempty"` + TCP *netip.AddrPort `yaml:"tcp,omitempty" json:"TCP,omitempty"` // Unix is the path to the machine's API unix socket. - Unix string `yaml:"unix,omitempty"` - Host string `yaml:"host,omitempty"` - PublicKey secret.Secret `yaml:"public_key,omitempty"` - MachineID string `yaml:"machine_id,omitempty"` + Unix string `yaml:"unix,omitempty" json:"Unix,omitempty"` + Host string `yaml:"host,omitempty" json:"Host,omitempty"` + PublicKey secret.Secret `yaml:"public_key,omitempty" json:"PublicKey,omitempty"` + MachineID string `yaml:"machine_id,omitempty" json:"MachineID,omitempty"` } func (c *MachineConnection) String() string { diff --git a/internal/cli/config/context.go b/internal/cli/config/context.go index 60ca8043..5bc8ff41 100644 --- a/internal/cli/config/context.go +++ b/internal/cli/config/context.go @@ -1,8 +1,8 @@ package config type Context struct { - Name string `yaml:"-"` - Connections []MachineConnection `yaml:"connections"` + Name string `yaml:"-" json:"-"` + Connections []MachineConnection `yaml:"connections" json:"Connections"` } func (c *Context) SetDefaultConnection(index int) { diff --git a/internal/cli/output/output.go b/internal/cli/output/output.go new file mode 100644 index 00000000..87826f09 --- /dev/null +++ b/internal/cli/output/output.go @@ -0,0 +1,22 @@ +package output + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func Flag(cmd *cobra.Command, value *string) { + cmd.Flags().StringVarP(value, "output", "o", "", + "Output format. Currently only 'json' is supported.") +} + +func FlagValue(value string) error { + switch value { + case "json": + return nil + case "": + return nil + } + return fmt.Errorf("invalid --output format, want 'json'") +} diff --git a/internal/machine/api/pb/common.go b/internal/machine/api/pb/common.go index 8eea6fad..bba7c288 100644 --- a/internal/machine/api/pb/common.go +++ b/internal/machine/api/pb/common.go @@ -27,6 +27,14 @@ func (ip *IP) Equal(other *IP) bool { return bytes.Equal(ip.Ip, other.Ip) } +func (ip *IP) MarshalJSON() ([]byte, error) { + if ip == nil { + return nil, nil + } + addr, _ := ip.ToAddr() + return []byte("\"" + addr.String() + "\""), nil +} + func NewIPPort(ap netip.AddrPort) *IPPort { return &IPPort{Ip: NewIP(ap.Addr()), Port: uint32(ap.Port())} } @@ -39,6 +47,14 @@ func (ipp *IPPort) ToAddrPort() (netip.AddrPort, error) { return netip.AddrPortFrom(addr, uint16(ipp.Port)), nil } +func (ipp *IPPort) MarshalJSON() ([]byte, error) { + if ipp == nil { + return nil, nil + } + addrPort, _ := ipp.ToAddrPort() + return []byte("\"" + addrPort.String() + "\""), nil +} + func NewIPPrefix(p netip.Prefix) *IPPrefix { return &IPPrefix{Ip: NewIP(p.Addr()), Bits: uint32(p.Bits())} } diff --git a/website/docs/9-cli-reference/uc_ctx_ls.md b/website/docs/9-cli-reference/uc_ctx_ls.md index 63569dfe..d9897476 100644 --- a/website/docs/9-cli-reference/uc_ctx_ls.md +++ b/website/docs/9-cli-reference/uc_ctx_ls.md @@ -9,7 +9,8 @@ uc ctx ls [flags] ## Options ``` - -h, --help help for ls + -h, --help help for ls + -o, --output string Output format. Currently only 'json' is supported. ``` ## Options inherited from parent commands diff --git a/website/docs/9-cli-reference/uc_image_ls.md b/website/docs/9-cli-reference/uc_image_ls.md index 701149d3..a8c37e62 100644 --- a/website/docs/9-cli-reference/uc_image_ls.md +++ b/website/docs/9-cli-reference/uc_image_ls.md @@ -34,6 +34,7 @@ uc image ls [REPO:[TAG]] [flags] ``` -h, --help help for ls -m, --machine strings Filter images by machine name or ID. Can be specified multiple times or as a comma-separated list. (default is include all machines) + -o, --output string Output format. Currently only 'json' is supported. ``` ## Options inherited from parent commands diff --git a/website/docs/9-cli-reference/uc_images.md b/website/docs/9-cli-reference/uc_images.md index aaf173c3..7158073e 100644 --- a/website/docs/9-cli-reference/uc_images.md +++ b/website/docs/9-cli-reference/uc_images.md @@ -34,6 +34,7 @@ uc images [IMAGE] [flags] ``` -h, --help help for images -m, --machine strings Filter images by machine name or ID. Can be specified multiple times or as a comma-separated list. (default is include all machines) + -o, --output string Output format. Currently only 'json' is supported. ``` ## Options inherited from parent commands diff --git a/website/docs/9-cli-reference/uc_machine_ls.md b/website/docs/9-cli-reference/uc_machine_ls.md index 37ae0b18..8d5bf2ee 100644 --- a/website/docs/9-cli-reference/uc_machine_ls.md +++ b/website/docs/9-cli-reference/uc_machine_ls.md @@ -9,7 +9,8 @@ uc machine ls [flags] ## Options ``` - -h, --help help for ls + -h, --help help for ls + -o, --output string Output format. Currently only 'json' is supported. ``` ## Options inherited from parent commands diff --git a/website/docs/9-cli-reference/uc_volume_ls.md b/website/docs/9-cli-reference/uc_volume_ls.md index a1123dc3..693abf2f 100644 --- a/website/docs/9-cli-reference/uc_volume_ls.md +++ b/website/docs/9-cli-reference/uc_volume_ls.md @@ -11,6 +11,7 @@ uc volume ls [flags] ``` -h, --help help for ls -m, --machine strings Filter volumes by machine name or ID. Can be specified multiple times or as a comma-separated list. (default is include all machines) + -o, --output string Output format. Currently only 'json' is supported. -q, --quiet Only display volume names. ```