diff --git a/cli/cmd/bulk.go b/cli/cmd/bulk.go index 86026bd..5de6da0 100644 --- a/cli/cmd/bulk.go +++ b/cli/cmd/bulk.go @@ -2,15 +2,15 @@ package cmd import ( "fmt" - "strconv" "strings" + "github.com/omattsson/stackctl/cli/pkg/client" "github.com/omattsson/stackctl/cli/pkg/output" "github.com/omattsson/stackctl/cli/pkg/types" "github.com/spf13/cobra" ) -const flagDescIDs = "Comma-separated list of instance IDs" +const flagDescIDs = "Comma-separated list of stack names or IDs" var bulkCmd = &cobra.Command{ Use: "bulk", @@ -19,25 +19,25 @@ var bulkCmd = &cobra.Command{ } var bulkDeployCmd = &cobra.Command{ - Use: "deploy [IDs...]", + Use: "deploy [name|ID...]", Short: "Deploy multiple stack instances", Long: `Deploy multiple stack instances at once. -IDs can be provided via --ids flag, positional arguments, or both. +Stacks can be specified by name or ID via --ids flag, positional arguments, or both. Examples: stackctl bulk deploy --ids 1,2,3 - stackctl bulk deploy 1 2 3 - stackctl bulk deploy --ids 1,2 3 + stackctl bulk deploy my-stack other-stack + stackctl bulk deploy --ids my-stack,2 3 stackctl bulk deploy --ids 1,2,3 -o json`, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - ids, err := parseBulkIDs(cmd, args) + c, err := newClient() if err != nil { return err } - c, err := newClient() + ids, err := resolveBulkIDs(c, cmd, args) if err != nil { return err } @@ -52,25 +52,25 @@ Examples: } var bulkStopCmd = &cobra.Command{ - Use: "stop [IDs...]", + Use: "stop [name|ID...]", Short: "Stop multiple stack instances", Long: `Stop multiple stack instances at once. -IDs can be provided via --ids flag, positional arguments, or both. +Stacks can be specified by name or ID via --ids flag, positional arguments, or both. Examples: stackctl bulk stop --ids 1,2,3 - stackctl bulk stop 1 2 3 - stackctl bulk stop --ids 1,2 3 + stackctl bulk stop my-stack other-stack + stackctl bulk stop --ids my-stack,2 3 stackctl bulk stop --ids 1,2,3 -o json`, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - ids, err := parseBulkIDs(cmd, args) + c, err := newClient() if err != nil { return err } - c, err := newClient() + ids, err := resolveBulkIDs(c, cmd, args) if err != nil { return err } @@ -85,22 +85,27 @@ Examples: } var bulkCleanCmd = &cobra.Command{ - Use: "clean [IDs...]", + Use: "clean [name|ID...]", Short: "Clean multiple stack instances", Long: `Undeploy and remove namespaces for multiple stack instances. This is a destructive operation. You will be prompted for confirmation unless --yes is specified. -IDs can be provided via --ids flag, positional arguments, or both. +Stacks can be specified by name or ID via --ids flag, positional arguments, or both. Examples: stackctl bulk clean --ids 1,2,3 - stackctl bulk clean 1 2 3 + stackctl bulk clean my-stack other-stack stackctl bulk clean --ids 1,2,3 --yes`, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - ids, err := parseBulkIDs(cmd, args) + c, err := newClient() + if err != nil { + return err + } + + ids, err := resolveBulkIDs(c, cmd, args) if err != nil { return err } @@ -114,11 +119,6 @@ Examples: return nil } - c, err := newClient() - if err != nil { - return err - } - resp, err := c.BulkClean(ids) if err != nil { return err @@ -129,22 +129,27 @@ Examples: } var bulkDeleteCmd = &cobra.Command{ - Use: "delete [IDs...]", + Use: "delete [name|ID...]", Short: "Delete multiple stack instances", Long: `Permanently delete multiple stack instances. This is a destructive operation. You will be prompted for confirmation unless --yes is specified. -IDs can be provided via --ids flag, positional arguments, or both. +Stacks can be specified by name or ID via --ids flag, positional arguments, or both. Examples: stackctl bulk delete --ids 1,2,3 - stackctl bulk delete 1 2 3 + stackctl bulk delete my-stack other-stack stackctl bulk delete --ids 1,2,3 --yes`, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - ids, err := parseBulkIDs(cmd, args) + c, err := newClient() + if err != nil { + return err + } + + ids, err := resolveBulkIDs(c, cmd, args) if err != nil { return err } @@ -158,11 +163,6 @@ Examples: return nil } - c, err := newClient() - if err != nil { - return err - } - resp, err := c.BulkDelete(ids) if err != nil { return err @@ -190,7 +190,7 @@ func init() { rootCmd.AddCommand(bulkCmd) } -func parseBulkIDs(cmd *cobra.Command, args []string) ([]string, error) { +func resolveBulkIDs(c *client.Client, cmd *cobra.Command, args []string) ([]string, error) { var rawParts []string idsStr, _ := cmd.Flags().GetString("ids") @@ -199,6 +199,10 @@ func parseBulkIDs(cmd *cobra.Command, args []string) ([]string, error) { } rawParts = append(rawParts, args...) + if len(rawParts) > 50 { + return nil, fmt.Errorf("maximum 50 stacks allowed, got %d", len(rawParts)) + } + seen := make(map[string]bool) ids := make([]string, 0, len(rawParts)) for _, p := range rawParts { @@ -206,23 +210,23 @@ func parseBulkIDs(cmd *cobra.Command, args []string) ([]string, error) { if p == "" { continue } - id, err := strconv.ParseUint(p, 10, 64) - if err != nil || id == 0 { - return nil, fmt.Errorf("invalid ID %q: must be a positive integer", p) + resolved, err := resolveStackID(c, p) + if err != nil { + return nil, err } - if seen[p] { + if seen[resolved] { continue } - seen[p] = true - ids = append(ids, p) + seen[resolved] = true + ids = append(ids, resolved) } if len(ids) == 0 { - return nil, fmt.Errorf("at least one instance ID is required (use --ids or positional arguments)") + return nil, fmt.Errorf("at least one stack name or ID is required (use --ids or positional arguments)") } if len(ids) > 50 { - return nil, fmt.Errorf("maximum 50 IDs allowed, got %d", len(ids)) + return nil, fmt.Errorf("maximum 50 stacks allowed, got %d", len(ids)) } return ids, nil diff --git a/cli/cmd/bulk_test.go b/cli/cmd/bulk_test.go index 8db87ba..cdb376a 100644 --- a/cli/cmd/bulk_test.go +++ b/cli/cmd/bulk_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/omattsson/stackctl/cli/pkg/client" "github.com/omattsson/stackctl/cli/pkg/output" "github.com/omattsson/stackctl/cli/pkg/types" "github.com/spf13/cobra" @@ -309,60 +310,131 @@ func TestBulkDeleteCmd_WithYesFlag(t *testing.T) { assert.Contains(t, buf.String(), "ID") } -// ---------- parseBulkIDs ---------- +// ---------- resolveBulkIDs ---------- -func TestParseBulkIDs_Valid(t *testing.T) { +func TestResolveBulkIDs_NumericIDs(t *testing.T) { + c := client.New("http://unused") cmd := &cobra.Command{} cmd.Flags().String("ids", "", "") cmd.Flags().Set("ids", "1,2,3") - ids, err := parseBulkIDs(cmd, nil) + ids, err := resolveBulkIDs(c, cmd, nil) require.NoError(t, err) assert.Equal(t, []string{"1", "2", "3"}, ids) } -func TestParseBulkIDs_InvalidID(t *testing.T) { +func TestResolveBulkIDs_UUIDs(t *testing.T) { + c := client.New("http://unused") cmd := &cobra.Command{} cmd.Flags().String("ids", "", "") - cmd.Flags().Set("ids", "1,abc,3") + cmd.Flags().Set("ids", "550e8400-e29b-41d4-a716-446655440000,660e8400-e29b-41d4-a716-446655440001") - _, err := parseBulkIDs(cmd, nil) + ids, err := resolveBulkIDs(c, cmd, nil) + require.NoError(t, err) + assert.Equal(t, []string{"550e8400-e29b-41d4-a716-446655440000", "660e8400-e29b-41d4-a716-446655440001"}, ids) +} + +func TestResolveBulkIDs_StackNames(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + id := "resolved-" + name + json.NewEncoder(w).Encode(types.ListResponse[types.StackInstance]{ + Data: []types.StackInstance{{Base: types.Base{ID: id}, Name: name}}, + Total: 1, Page: 1, PageSize: 1, + }) + })) + defer server.Close() + + c := client.New(server.URL) + cmd := &cobra.Command{} + cmd.Flags().String("ids", "", "") + cmd.Flags().Set("ids", "my-stack,other-stack") + + ids, err := resolveBulkIDs(c, cmd, nil) + require.NoError(t, err) + assert.Equal(t, []string{"resolved-my-stack", "resolved-other-stack"}, ids) +} + +func TestResolveBulkIDs_MixedNamesAndIDs(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + json.NewEncoder(w).Encode(types.ListResponse[types.StackInstance]{ + Data: []types.StackInstance{{Base: types.Base{ID: "99"}, Name: name}}, + Total: 1, Page: 1, PageSize: 1, + }) + })) + defer server.Close() + + c := client.New(server.URL) + cmd := &cobra.Command{} + cmd.Flags().String("ids", "", "") + cmd.Flags().Set("ids", "1,my-stack") + + ids, err := resolveBulkIDs(c, cmd, nil) + require.NoError(t, err) + assert.Equal(t, []string{"1", "99"}, ids) +} + +func TestResolveBulkIDs_NameDedup(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(types.ListResponse[types.StackInstance]{ + Data: []types.StackInstance{{Base: types.Base{ID: "42"}, Name: r.URL.Query().Get("name")}}, + Total: 1, Page: 1, PageSize: 1, + }) + })) + defer server.Close() + + c := client.New(server.URL) + cmd := &cobra.Command{} + cmd.Flags().String("ids", "", "") + cmd.Flags().Set("ids", "my-stack,42") + + ids, err := resolveBulkIDs(c, cmd, nil) + require.NoError(t, err) + assert.Equal(t, []string{"42"}, ids) +} + +func TestResolveBulkIDs_UnknownName(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(types.ListResponse[types.StackInstance]{ + Data: []types.StackInstance{}, Total: 0, Page: 1, PageSize: 0, + }) + })) + defer server.Close() + + c := client.New(server.URL) + cmd := &cobra.Command{} + cmd.Flags().String("ids", "", "") + cmd.Flags().Set("ids", "nonexistent") + + _, err := resolveBulkIDs(c, cmd, nil) require.Error(t, err) - assert.Contains(t, err.Error(), "invalid ID") + assert.Contains(t, err.Error(), "no stack found") } -func TestParseBulkIDs_TooMany(t *testing.T) { +func TestResolveBulkIDs_TooMany(t *testing.T) { + c := client.New("http://unused") cmd := &cobra.Command{} cmd.Flags().String("ids", "", "") - // Build 51 unique IDs parts := make([]string, 51) for i := range parts { parts[i] = strconv.Itoa(i + 1) } cmd.Flags().Set("ids", strings.Join(parts, ",")) - _, err := parseBulkIDs(cmd, nil) + _, err := resolveBulkIDs(c, cmd, nil) require.Error(t, err) assert.Contains(t, err.Error(), "maximum 50") } -func TestParseBulkIDs_Empty(t *testing.T) { - cmd := &cobra.Command{} - cmd.Flags().String("ids", "", "") - cmd.Flags().Set("ids", "") - - _, err := parseBulkIDs(cmd, nil) - require.Error(t, err) -} - -func TestParseBulkIDs_ZeroID(t *testing.T) { +func TestResolveBulkIDs_Empty(t *testing.T) { + c := client.New("http://unused") cmd := &cobra.Command{} cmd.Flags().String("ids", "", "") - cmd.Flags().Set("ids", "0") - _, err := parseBulkIDs(cmd, nil) + _, err := resolveBulkIDs(c, cmd, nil) require.Error(t, err) - assert.Contains(t, err.Error(), "invalid ID") + assert.Contains(t, err.Error(), "at least one stack name or ID") } func TestBulkDeployCmd_APIError(t *testing.T) { @@ -554,65 +626,60 @@ func TestBulkDeleteCmd_YAMLOutput(t *testing.T) { assert.Contains(t, out, "success: true") } -// ---------- parseBulkIDs edge cases ---------- +// ---------- resolveBulkIDs edge cases ---------- -func TestParseBulkIDs_OnlyCommas(t *testing.T) { +func TestResolveBulkIDs_OnlyCommas(t *testing.T) { + c := client.New("http://unused") cmd := &cobra.Command{} cmd.Flags().String("ids", "", "") cmd.Flags().Set("ids", ",,,") - _, err := parseBulkIDs(cmd, nil) + _, err := resolveBulkIDs(c, cmd, nil) require.Error(t, err) - assert.Contains(t, err.Error(), "at least one instance ID is required") + assert.Contains(t, err.Error(), "at least one stack name or ID") } -func TestParseBulkIDs_WhitespaceHandling(t *testing.T) { +func TestResolveBulkIDs_WhitespaceHandling(t *testing.T) { + c := client.New("http://unused") cmd := &cobra.Command{} cmd.Flags().String("ids", "", "") cmd.Flags().Set("ids", " 1 , 2 , 3 ") - ids, err := parseBulkIDs(cmd, nil) + ids, err := resolveBulkIDs(c, cmd, nil) require.NoError(t, err) assert.Equal(t, []string{"1", "2", "3"}, ids) } -func TestParseBulkIDs_NegativeID(t *testing.T) { - cmd := &cobra.Command{} - cmd.Flags().String("ids", "", "") - cmd.Flags().Set("ids", "-1") - - _, err := parseBulkIDs(cmd, nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid ID") -} - // ---------- positional and mixed args ---------- -func TestParseBulkIDs_PositionalArgs(t *testing.T) { +func TestResolveBulkIDs_PositionalArgs(t *testing.T) { + c := client.New("http://unused") cmd := &cobra.Command{} cmd.Flags().String("ids", "", "") - ids, err := parseBulkIDs(cmd, []string{"1", "2", "3"}) + ids, err := resolveBulkIDs(c, cmd, []string{"1", "2", "3"}) require.NoError(t, err) assert.Equal(t, []string{"1", "2", "3"}, ids) } -func TestParseBulkIDs_MixedFlagAndPositional(t *testing.T) { +func TestResolveBulkIDs_MixedFlagAndPositional(t *testing.T) { + c := client.New("http://unused") cmd := &cobra.Command{} cmd.Flags().String("ids", "", "") cmd.Flags().Set("ids", "1,2") - ids, err := parseBulkIDs(cmd, []string{"3"}) + ids, err := resolveBulkIDs(c, cmd, []string{"3"}) require.NoError(t, err) assert.Equal(t, []string{"1", "2", "3"}, ids) } -func TestParseBulkIDs_MixedDedup(t *testing.T) { +func TestResolveBulkIDs_MixedDedup(t *testing.T) { + c := client.New("http://unused") cmd := &cobra.Command{} cmd.Flags().String("ids", "", "") cmd.Flags().Set("ids", "1,2") - ids, err := parseBulkIDs(cmd, []string{"2", "3"}) + ids, err := resolveBulkIDs(c, cmd, []string{"2", "3"}) require.NoError(t, err) assert.Equal(t, []string{"1", "2", "3"}, ids) }