Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions internal/verda-cli/cmd/vm/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,10 +149,22 @@ func NewCmdAction(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command

//nolint:gocyclo // Interactive CLI command with prompts, confirmation, and spinner — inherently complex.
func runAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *actionOptions) error {
// Validate: --status and --hostname are filters that require --all.
if !opts.All && (opts.Status != "" || opts.Hostname != "") {
flags := []string{}
if opts.Status != "" {
flags = append(flags, "--status")
}
if opts.Hostname != "" {
flags = append(flags, "--hostname")
}
return fmt.Errorf("%s can only be used with --all", strings.Join(flags, " and "))
}

// In agent mode, --id and --action are required.
if f.AgentMode() {
var missing []string
if opts.InstanceID == "" && !opts.All && opts.Hostname == "" {
if opts.InstanceID == "" && !opts.All {
missing = append(missing, "--id")
}
if opts.Action == "" {
Expand All @@ -163,8 +175,8 @@ func runAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream
}
}

// Batch mode: --all or --hostname routes to batch execution.
if opts.All || opts.Hostname != "" {
// Batch mode: --all routes to batch execution.
if opts.All {
return runBatchAction(cmd, f, ioStreams, opts)
}

Expand Down
15 changes: 5 additions & 10 deletions internal/verda-cli/cmd/vm/batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,12 @@ import (
)

// runBatchAction validates batch flags and executes the action against all
// matching instances. It is called from runAction when --all or --hostname is set.
//
//nolint:gocyclo // Batch CLI command with validation, filtering, confirmation, and execution — inherently complex.
// matching instances. It is called from runAction when --all is set.
// --status and --hostname act as filters (AND logic) to narrow the target set.
func runBatchAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *actionOptions) error {
// Validation: --all/--hostname cannot be combined with --id or a positional instance ID.
// Validation: --all cannot be combined with --id or a positional instance ID.
if opts.InstanceID != "" {
return errors.New("cannot combine --all/--hostname with --id or positional instance ID")
return errors.New("cannot combine --all with --id or positional instance ID")
}

// Validation: --with-volumes is only valid for delete.
Expand All @@ -31,11 +30,7 @@ func runBatchAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOS

// Agent mode requires --yes for batch operations (always destructive at scale).
if f.AgentMode() && !opts.Yes {
label := opts.Action + " --all"
if opts.Hostname != "" {
label = opts.Action + " --hostname"
}
return cmdutil.NewConfirmationRequiredError(label)
return cmdutil.NewConfirmationRequiredError(opts.Action + " --all")
}

client, err := f.VerdaClient()
Expand Down
51 changes: 49 additions & 2 deletions internal/verda-cli/cmd/vm/batch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func TestBatchRejectsAllWithID(t *testing.T) {
if err == nil {
t.Fatal("expected error when combining --all with --id")
}
if !strings.Contains(err.Error(), "cannot combine --all/--hostname with --id") {
if !strings.Contains(err.Error(), "cannot combine --all with --id") {
t.Fatalf("unexpected error: %v", err)
}
}
Expand All @@ -50,11 +50,58 @@ func TestBatchRejectsAllWithPositionalArg(t *testing.T) {
if err == nil {
t.Fatal("expected error when combining --all with positional arg")
}
if !strings.Contains(err.Error(), "cannot combine --all/--hostname with --id") {
if !strings.Contains(err.Error(), "cannot combine --all with --id") {
t.Fatalf("unexpected error: %v", err)
}
}

func TestFiltersRequireAll(t *testing.T) {
t.Parallel()

var buf bytes.Buffer
ioStreams := cmdutil.IOStreams{Out: &buf, ErrOut: &buf}
f := &cmdutil.TestFactory{AgentModeOverride: true}

tests := []struct {
name string
args []string
want string
}{
{
name: "hostname without all",
args: []string{"vm", "shutdown", "--hostname", "test-*"},
want: "--hostname can only be used with --all",
},
{
name: "status without all",
args: []string{"vm", "shutdown", "--status", "running"},
want: "--status can only be used with --all",
},
{
name: "both filters without all",
args: []string{"vm", "shutdown", "--status", "running", "--hostname", "test-*"},
want: "--status and --hostname can only be used with --all",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
root := &cobra.Command{Use: "verda", SilenceUsage: true, SilenceErrors: true}
root.AddCommand(NewCmdVM(f, ioStreams))
root.SetArgs(tt.args)

err := root.Execute()
if err == nil {
t.Fatal("expected error for filter without --all")
}
if !strings.Contains(err.Error(), tt.want) {
t.Fatalf("expected %q, got: %v", tt.want, err)
}
})
}
}

func TestBatchRejectsWithVolumesOnNonDelete(t *testing.T) {
t.Parallel()

Expand Down
11 changes: 7 additions & 4 deletions internal/verda-cli/cmd/vm/shortcuts.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,9 @@ func newShortcutCmd(f cmdutil.Factory, ioStreams cmdutil.IOStreams, def shortcut

cmd.Flags().StringVar(&opts.InstanceID, "id", "", "Instance ID (alternative to positional argument)")
cmd.Flags().BoolVar(&opts.Yes, "yes", false, "Skip confirmation for destructive actions")
cmd.Flags().BoolVar(&opts.All, "all", false, "Target all instances matching filters")
cmd.Flags().StringVar(&opts.Status, "status", "", "Filter instances by status (e.g., running, offline)")
cmd.Flags().StringVar(&opts.Hostname, "hostname", "", "Filter instances by hostname glob pattern (e.g., \"test-*\")")
cmd.Flags().BoolVar(&opts.All, "all", false, "Target all instances (use with --status/--hostname to filter)")
cmd.Flags().StringVar(&opts.Status, "status", "", "Filter by status, requires --all (e.g., running, offline)")
cmd.Flags().StringVar(&opts.Hostname, "hostname", "", "Filter by hostname glob pattern, requires --all (e.g., \"test-*\")")
cmd.Flags().BoolVar(&opts.WithVolumes, "with-volumes", false, "Also delete all attached volumes (delete only)")
opts.Wait.AddFlags(cmd.Flags(), true)

Expand All @@ -91,7 +91,10 @@ verda vm %s
verda vm %s --all --status %s

# Batch: %s instances matching hostname pattern
verda vm %s --hostname "test-*"`, def.Short, name, name, strings.ToLower(strings.SplitN(def.Short, " ", 2)[0]), name, exampleStatus(def.Action), strings.ToLower(strings.SplitN(def.Short, " ", 2)[0]), name)
verda vm %s --all --hostname "test-*"

# Batch: combine filters (AND logic)
verda vm %s --all --status %s --hostname "test-*"`, def.Short, name, name, strings.ToLower(strings.SplitN(def.Short, " ", 2)[0]), name, exampleStatus(def.Action), strings.ToLower(strings.SplitN(def.Short, " ", 2)[0]), name, name, exampleStatus(def.Action))

if def.Action == verda.ActionDelete {
examples += fmt.Sprintf(`
Expand Down
19 changes: 9 additions & 10 deletions internal/verda-cli/cmd/volume/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,19 @@ func NewCmdDelete(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command
}

cmd.Flags().StringVar(&opts.VolumeID, "id", "", "Volume ID (alternative to positional argument)")
cmd.Flags().BoolVar(&opts.All, "all", false, "Target all volumes matching filters")
cmd.Flags().StringVar(&opts.Status, "status", "", "Filter volumes by status (e.g., detached, attached)")
cmd.Flags().BoolVar(&opts.All, "all", false, "Target all volumes (use with --status to filter)")
cmd.Flags().StringVar(&opts.Status, "status", "", "Filter by status, requires --all (e.g., detached, attached)")
cmd.Flags().BoolVar(&opts.Yes, "yes", false, "Skip confirmation for destructive actions")

return cmd
}

func runDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *deleteOptions) error {
// Validate: --status is a filter that requires --all.
if !opts.All && opts.Status != "" {
return errors.New("--status can only be used with --all")
}

// Validate: --all cannot combine with --id or positional arg.
if opts.All && opts.VolumeID != "" {
return errors.New("cannot combine --all with --id or positional volume ID")
Expand Down Expand Up @@ -138,19 +143,13 @@ func runSingleVolumeDelete(ctx context.Context, f cmdutil.Factory, ioStreams cmd
}

func runInteractiveVolumeDelete(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client *verda.Client, opts *deleteOptions) error {
// Load volumes with optional status filter.
// Load all volumes (--status filter is only valid with --all, validated earlier).
var sp interface{ Stop(string) }
if status := f.Status(); status != nil {
sp, _ = status.Spinner(ctx, "Loading volumes...")
}

var volumes []verda.Volume
var err error
if opts.Status != "" {
volumes, err = client.Volumes.ListVolumesByStatus(ctx, opts.Status)
} else {
volumes, err = client.Volumes.ListVolumes(ctx)
}
volumes, err := client.Volumes.ListVolumes(ctx)
if sp != nil {
sp.Stop("")
}
Expand Down
21 changes: 21 additions & 0 deletions internal/verda-cli/cmd/volume/delete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package volume

import (
"bytes"
"strings"
"testing"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -60,6 +61,26 @@ func TestDeleteAgentModeRequiresYes(t *testing.T) {
}
}

func TestDeleteStatusRequiresAll(t *testing.T) {
t.Parallel()

var buf bytes.Buffer
ioStreams := cmdutil.IOStreams{Out: &buf, ErrOut: &buf}
f := cmdutil.NewTestFactory(nil)

root := &cobra.Command{Use: "verda", SilenceUsage: true, SilenceErrors: true}
root.AddCommand(NewCmdVolume(f, ioStreams))
root.SetArgs([]string{"volume", "delete", "--status", "detached"})

err := root.Execute()
if err == nil {
t.Fatal("expected error for --status without --all")
}
if !strings.Contains(err.Error(), "--status can only be used with --all") {
t.Fatalf("unexpected error: %v", err)
}
}

func TestDeleteHasExpectedFlags(t *testing.T) {
t.Parallel()

Expand Down
Loading