From 2f5a2c63443d7a09987b45388cab8a32b79776e4 Mon Sep 17 00:00:00 2001 From: Pavel Sviderski Date: Thu, 10 Apr 2025 20:33:03 +1000 Subject: [PATCH] feat(volumes): new 'volume create|inspect|ls|rm' CLI commands to manage volumes --- cmd/uncloud/main.go | 2 + cmd/uncloud/service/ls.go | 1 + cmd/uncloud/volume/create.go | 148 ++++++++++++++++++++++++++++++++++ cmd/uncloud/volume/inspect.go | 81 +++++++++++++++++++ cmd/uncloud/volume/ls.go | 114 ++++++++++++++++++++++++++ cmd/uncloud/volume/rm.go | 121 +++++++++++++++++++++++++++ cmd/uncloud/volume/root.go | 19 +++++ 7 files changed, 486 insertions(+) create mode 100644 cmd/uncloud/volume/create.go create mode 100644 cmd/uncloud/volume/inspect.go create mode 100644 cmd/uncloud/volume/ls.go create mode 100644 cmd/uncloud/volume/rm.go create mode 100644 cmd/uncloud/volume/root.go diff --git a/cmd/uncloud/main.go b/cmd/uncloud/main.go index 4804791e..1add1cc0 100644 --- a/cmd/uncloud/main.go +++ b/cmd/uncloud/main.go @@ -11,6 +11,7 @@ import ( "github.com/psviderski/uncloud/cmd/uncloud/dns" "github.com/psviderski/uncloud/cmd/uncloud/machine" "github.com/psviderski/uncloud/cmd/uncloud/service" + "github.com/psviderski/uncloud/cmd/uncloud/volume" "github.com/psviderski/uncloud/internal/cli" "github.com/psviderski/uncloud/internal/cli/config" "github.com/psviderski/uncloud/internal/fs" @@ -82,6 +83,7 @@ func main() { service.NewRmCommand(), service.NewRunCommand(), service.NewScaleCommand(), + volume.NewRootCommand(), ) cobra.CheckErr(cmd.Execute()) } diff --git a/cmd/uncloud/service/ls.go b/cmd/uncloud/service/ls.go index e876038a..9e9aec01 100644 --- a/cmd/uncloud/service/ls.go +++ b/cmd/uncloud/service/ls.go @@ -12,6 +12,7 @@ import ( ) func NewListCommand() *cobra.Command { + // TODO(lhf): rename to context var cluster string cmd := &cobra.Command{ Use: "ls", diff --git a/cmd/uncloud/volume/create.go b/cmd/uncloud/volume/create.go new file mode 100644 index 00000000..003dafb1 --- /dev/null +++ b/cmd/uncloud/volume/create.go @@ -0,0 +1,148 @@ +package volume + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/charmbracelet/huh" + "github.com/docker/docker/api/types/volume" + "github.com/psviderski/uncloud/internal/cli" + "github.com/psviderski/uncloud/internal/machine/api/pb" + "github.com/psviderski/uncloud/pkg/api" + "github.com/spf13/cobra" +) + +type createOptions struct { + driver string + driverOpts []string + labels []string + machine string + context string +} + +func NewCreateCommand() *cobra.Command { + opts := createOptions{} + + cmd := &cobra.Command{ + Use: "create [FLAGS] VOLUME_NAME", + Short: "Create a volume on a specific machine.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + uncli := cmd.Context().Value("cli").(*cli.CLI) + opts.driver = strings.TrimSpace(opts.driver) + + volumeName := args[0] + if volumeName == "" { + return fmt.Errorf("volume name is required") + } + + return create(cmd.Context(), uncli, volumeName, opts) + }, + } + + cmd.Flags().StringVarP(&opts.driver, "driver", "d", "local", + "Volume driver to use.") + cmd.Flags().StringSliceVarP(&opts.driverOpts, "opt", "o", nil, + "Driver specific options in the form of 'key=value' pairs. Can be specified multiple times.") + cmd.Flags().StringSliceVarP(&opts.labels, "label", "l", nil, + "Labels to assign to the volume in the form of 'key=value' pairs. Can be specified multiple times.") + cmd.Flags().StringVarP(&opts.machine, "machine", "m", "", + "Name or ID of the machine to create the volume on.") + cmd.Flags().StringVarP(&opts.context, "context", "c", "", + "Name of the cluster context. (default is the current context)") + + return cmd +} + +func create(ctx context.Context, uncli *cli.CLI, name string, opts createOptions) error { + client, err := uncli.ConnectCluster(ctx, opts.context) + if err != nil { + return fmt.Errorf("connect to cluster: %w", err) + } + defer client.Close() + + // Parse driver options. + driverOpts := make(map[string]string) + for _, opt := range opts.driverOpts { + k, v, ok := strings.Cut(opt, "=") + if !ok { + return fmt.Errorf("invalid driver option format: '%s' (expected key=value)", opt) + } + driverOpts[k] = v + } + + // Parse labels. + labels := make(map[string]string) + for _, label := range opts.labels { + k, v, ok := strings.Cut(label, "=") + if !ok { + return fmt.Errorf("invalid label format: '%s' (expected key=value)", label) + } + labels[k] = v + } + + // List machines and filter by the specified machine name or ID. + // If no machine is specified, prompt the user to select one. + machines, err := client.ListMachines(ctx) + if err != nil { + return fmt.Errorf("list machines: %w", err) + } + var selectedMachine *pb.MachineInfo + + if opts.machine == "" { + if len(machines) == 1 { + selectedMachine = machines[0].Machine + } else { + if selectedMachine, err = promptSelectMachine(ctx, machines); err != nil { + return fmt.Errorf("select machine: %w", err) + } + } + } else { + m := machines.FindByNameOrID(opts.machine) + if m == nil { + return fmt.Errorf("machine '%s' not found", opts.machine) + } + selectedMachine = m.Machine + } + + createOpts := volume.CreateOptions{ + Name: name, + Driver: opts.driver, + DriverOpts: driverOpts, + Labels: labels, + } + vol, err := client.CreateVolume(ctx, selectedMachine.Id, createOpts) + if err != nil { + return fmt.Errorf("create volume '%s' on machine '%s': %w", name, selectedMachine.Name, err) + } + + fmt.Printf("Volume '%s' created on machine '%s'.\n", vol.Volume.Name, vol.MachineName) + return nil +} + +func promptSelectMachine(ctx context.Context, machines api.MachineMembersList) (*pb.MachineInfo, error) { + options := make([]huh.Option[*pb.MachineInfo], len(machines)) + for i, m := range machines { + options[i] = huh.NewOption(m.Machine.Name, m.Machine) + } + slices.SortFunc(options, func(a, b huh.Option[*pb.MachineInfo]) int { + return strings.Compare(a.Key, b.Key) + }) + + var selected *pb.MachineInfo + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[*pb.MachineInfo](). + Title("Select a machine to create the volume on (or specify with --machine flag)"). + Options(options...). + Value(&selected), + ), + ) + if err := form.RunWithContext(ctx); err != nil { + return nil, err + } + + return selected, nil +} diff --git a/cmd/uncloud/volume/inspect.go b/cmd/uncloud/volume/inspect.go new file mode 100644 index 00000000..f5239a6c --- /dev/null +++ b/cmd/uncloud/volume/inspect.go @@ -0,0 +1,81 @@ +package volume + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/psviderski/uncloud/internal/cli" + "github.com/psviderski/uncloud/pkg/api" + "github.com/spf13/cobra" +) + +type inspectOptions struct { + machine string + context string +} + +func NewInspectCommand() *cobra.Command { + opts := inspectOptions{} + + cmd := &cobra.Command{ + Use: "inspect [FLAGS] VOLUME_NAME", + Short: "Display detailed information on a volume.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + uncli := cmd.Context().Value("cli").(*cli.CLI) + return inspect(cmd.Context(), uncli, args[0], opts) + }, + } + + cmd.Flags().StringVarP(&opts.machine, "machine", "m", "", + "Name or ID of the machine where the volume is located. "+ + "If not specified, the volume will be searched across all machines.") + cmd.Flags().StringVarP(&opts.context, "context", "c", "", + "Name of the cluster context. (default is the current context)") + + return cmd +} + +func inspect(ctx context.Context, uncli *cli.CLI, name string, opts inspectOptions) error { + client, err := uncli.ConnectCluster(ctx, opts.context) + if err != nil { + return fmt.Errorf("connect to cluster: %w", err) + } + defer client.Close() + + filter := &api.VolumeFilter{ + Names: []string{name}, + } + if opts.machine != "" { + filter.Machines = []string{opts.machine} + } + + volumes, err := client.ListVolumes(ctx, filter) + if err != nil { + return fmt.Errorf("list volumes: %w", err) + } + + if len(volumes) == 0 { + if opts.machine != "" { + return fmt.Errorf("volume '%s' not found on machine '%s'", name, opts.machine) + } + return fmt.Errorf("volume '%s' not found on any machine", name) + } + if len(volumes) > 1 { + fmt.Printf("Volume '%s' found on multiple machines:\n", name) + for _, v := range volumes { + fmt.Printf(" • %s\n", v.MachineName) + } + return errors.New("specify --machine flag to choose which machine to use") + } + + data, err := json.MarshalIndent(volumes[0], "", " ") + if err != nil { + return fmt.Errorf("marshal volume: %w", err) + } + fmt.Println(string(data)) + + return nil +} diff --git a/cmd/uncloud/volume/ls.go b/cmd/uncloud/volume/ls.go new file mode 100644 index 00000000..71c4d5e6 --- /dev/null +++ b/cmd/uncloud/volume/ls.go @@ -0,0 +1,114 @@ +package volume + +import ( + "context" + "fmt" + "os" + "slices" + "strings" + "text/tabwriter" + + "github.com/psviderski/uncloud/internal/cli" + "github.com/psviderski/uncloud/pkg/api" + "github.com/spf13/cobra" +) + +type listOptions struct { + machines []string + quiet bool + context string +} + +func NewListCommand() *cobra.Command { + opts := listOptions{} + + cmd := &cobra.Command{ + Use: "ls [FLAGS]", + Aliases: []string{"list"}, + Short: "List volumes across all machines in the cluster.", + RunE: func(cmd *cobra.Command, args []string) error { + uncli := cmd.Context().Value("cli").(*cli.CLI) + return list(cmd.Context(), uncli, opts) + }, + } + + cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil, + "Filter volumes by machine name or ID. Can be specified multiple times or as a comma-separated list. "+ + "(default is include all machines)") + cmd.Flags().BoolVarP(&opts.quiet, "quiet", "q", false, + "Only display volume names.") + + cmd.Flags().StringVarP(&opts.context, "context", "c", "", + "Name of the cluster context. (default is the current context)") + + return cmd +} + +func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error { + client, err := uncli.ConnectCluster(ctx, opts.context) + if err != nil { + return fmt.Errorf("connect to cluster: %w", err) + } + defer client.Close() + + // Apply machine filter if specified. + var filter *api.VolumeFilter + if len(opts.machines) > 0 { + // Expand comma-separated machine names. + var machines []string + for _, m := range opts.machines { + for _, nameOrID := range strings.Split(m, ",") { + if nameOrID = strings.TrimSpace(nameOrID); nameOrID != "" { + machines = append(machines, nameOrID) + } + } + } + + filter = &api.VolumeFilter{ + Machines: machines, + } + } + + volumes, err := client.ListVolumes(ctx, filter) + if err != nil { + return fmt.Errorf("list volumes: %w", err) + } + + if len(volumes) == 0 { + if !opts.quiet { + fmt.Println("No volumes found.") + } + return nil + } + + // Sort the volumes by name first, then by machine name. + slices.SortFunc(volumes, func(a, b api.MachineVolume) int { + cmp := strings.Compare(a.Volume.Name, b.Volume.Name) + if cmp != 0 { + return cmp + } + return strings.Compare(a.MachineName, b.MachineName) + }) + + // If quiet mode, just print volume names. + if opts.quiet { + for _, v := range volumes { + fmt.Println(v.Volume.Name) + } + return nil + } + + // Print the volumes in a table format. + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + fmt.Fprintln(tw, "NAME\tDRIVER\tMACHINE") + + for _, v := range volumes { + fmt.Fprintf(tw, "%s\t%s\t%s\n", + v.Volume.Name, + v.Volume.Driver, + v.MachineName, + ) + } + + return tw.Flush() +} diff --git a/cmd/uncloud/volume/rm.go b/cmd/uncloud/volume/rm.go new file mode 100644 index 00000000..ed4fa6fe --- /dev/null +++ b/cmd/uncloud/volume/rm.go @@ -0,0 +1,121 @@ +package volume + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/psviderski/uncloud/internal/cli" + "github.com/psviderski/uncloud/pkg/api" + "github.com/spf13/cobra" +) + +type removeOptions struct { + force bool + machines []string + yes bool + context string +} + +func NewRemoveCommand() *cobra.Command { + opts := removeOptions{} + + cmd := &cobra.Command{ + Use: "rm [FLAGS] VOLUME_NAME [VOLUME_NAME...]", + Aliases: []string{"remove", "delete"}, + Short: "Remove one or more volumes.", + Long: "Remove one or more volumes. You cannot remove a volume that is in use by a container.", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + uncli := cmd.Context().Value("cli").(*cli.CLI) + return remove(cmd.Context(), uncli, args, opts) + }, + } + + cmd.Flags().BoolVarP(&opts.force, "force", "f", false, + "Force the removal of one or more volumes.") + cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil, + "Name or ID of the machine to remove one or more volumes from. "+ + "Can be specified multiple times or as a comma-separated list.\n"+ + "If not specified, the found volume(s) will be removed from all machines.") + cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, + "Do not prompt for confirmation before removing the volume(s).") + + cmd.Flags().StringVarP(&opts.context, "context", "c", "", + "Name of the cluster context. (default is the current context)") + + return cmd +} + +func remove(ctx context.Context, uncli *cli.CLI, names []string, opts removeOptions) error { + client, err := uncli.ConnectCluster(ctx, opts.context) + if err != nil { + return fmt.Errorf("connect to cluster: %w", err) + } + defer client.Close() + + filter := &api.VolumeFilter{ + Names: names, + } + + if len(opts.machines) > 0 { + // Expand comma-separated machine names. + var machines []string + for _, m := range opts.machines { + for _, nameOrID := range strings.Split(m, ",") { + if nameOrID = strings.TrimSpace(nameOrID); nameOrID != "" { + machines = append(machines, nameOrID) + } + } + } + + filter.Machines = machines + } + + volumes, err := client.ListVolumes(ctx, filter) + if err != nil { + return fmt.Errorf("list volumes: %w", err) + } + + if len(volumes) == 0 { + if len(names) == 1 { + return fmt.Errorf("volume '%s' not found", names[0]) + } + return fmt.Errorf("no volumes found matching the specified names") + } + + // Confirm removal if not using --yes flag. + if !opts.yes { + fmt.Println("The following volumes will be removed:") + for _, v := range volumes { + fmt.Printf(" • '%s' on machine '%s'\n", v.Volume.Name, v.MachineName) + } + + fmt.Println() + confirmed, err := cli.Confirm() + if err != nil { + return fmt.Errorf("confirm removal: %w", err) + } + if !confirmed { + fmt.Println("Cancelled. No volumes were removed.") + return nil + } + } + + // Remove the volumes one by one collecting errors. + var removeErr error + for _, v := range volumes { + if err = client.RemoveVolume(ctx, v.MachineID, v.Volume.Name, opts.force); err != nil { + if !errors.Is(err, api.ErrNotFound) { + removeErr = errors.Join(removeErr, fmt.Errorf("failed to remove volume '%s' on machine '%s': %w", + v.Volume.Name, v.MachineName, err)) + } + continue + } + + fmt.Printf("Volume '%s' removed from machine '%s'.\n", v.Volume.Name, v.MachineName) + } + + return removeErr +} diff --git a/cmd/uncloud/volume/root.go b/cmd/uncloud/volume/root.go new file mode 100644 index 00000000..1e8f6eea --- /dev/null +++ b/cmd/uncloud/volume/root.go @@ -0,0 +1,19 @@ +package volume + +import ( + "github.com/spf13/cobra" +) + +func NewRootCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "volume", + Short: "Manage volumes in an Uncloud cluster.", + } + cmd.AddCommand( + NewCreateCommand(), + NewInspectCommand(), + NewListCommand(), + NewRemoveCommand(), + ) + return cmd +}