diff --git a/cli/cmd/definition.go b/cli/cmd/definition.go index a10a153..da36fc6 100644 --- a/cli/cmd/definition.go +++ b/cli/cmd/definition.go @@ -260,6 +260,7 @@ Examples: RunE: func(cmd *cobra.Command, args []string) error { return deleteByID(cmd, args, "This will permanently delete definition %s. Continue? (y/n): ", + passthroughID, func(c *client.Client, id string) error { return c.DeleteDefinition(id) }, "Deleted definition %s", ) diff --git a/cli/cmd/override.go b/cli/cmd/override.go index dc5bee5..6d39e9c 100644 --- a/cli/cmd/override.go +++ b/cli/cmd/override.go @@ -29,23 +29,23 @@ var overrideCmd = &cobra.Command{ // --- Value Overrides --- var overrideListCmd = &cobra.Command{ - Use: "list ", + Use: "list ", Short: "List value overrides for a stack instance", Long: `List all value overrides for a stack instance. Examples: - stackctl override list 42 - stackctl override list 42 -o json - stackctl override list 42 -q`, + stackctl override list my-stack + stackctl override list my-stack -o json + stackctl override list my-stack -q`, Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - instanceID, err := parseID(args[0]) + c, err := newClient() if err != nil { return err } - c, err := newClient() + instanceID, err := resolveStackID(c, args[0]) if err != nil { return err } @@ -88,7 +88,7 @@ Examples: } var overrideSetCmd = &cobra.Command{ - Use: "set ", + Use: "set ", Short: "Set value overrides for a chart", Long: `Set value overrides for a specific chart in a stack instance. @@ -96,17 +96,13 @@ Provide values via --file (JSON or YAML file) or --set key=value (repeatable). At least one of --file or --set is required. Examples: - stackctl override set 42 1 --file values.json - stackctl override set 42 1 --file values.yaml - stackctl override set 42 1 --set replicas=3 --set image.tag=v2 - stackctl override set 42 1 --file values.json --set replicas=5`, + stackctl override set my-stack 1 --file values.json + stackctl override set my-stack 1 --file values.yaml + stackctl override set my-stack 1 --set replicas=3 --set image.tag=v2 + stackctl override set my-stack 1 --file values.json --set replicas=5`, Args: cobra.ExactArgs(2), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - instanceID, err := parseID(args[0]) - if err != nil { - return err - } chartID, err := parseID(args[1]) if err != nil { return err @@ -132,9 +128,7 @@ Examples: if err != nil { return fmt.Errorf("reading file %s: %w", file, err) } - // Try JSON first, then YAML if err := json.Unmarshal(data, &values); err != nil { - // Try YAML if yamlErr := yaml.Unmarshal(data, &values); yamlErr != nil { return fmt.Errorf("invalid JSON/YAML in file %s (json: %v): %w", file, err, yamlErr) } @@ -154,6 +148,11 @@ Examples: return err } + instanceID, err := resolveStackID(c, args[0]) + if err != nil { + return err + } + override, err := c.SetValueOverride(instanceID, chartID, &types.SetValueOverrideRequest{ Values: values, }) @@ -179,7 +178,7 @@ Examples: } var overrideDeleteCmd = &cobra.Command{ - Use: "delete ", + Use: "delete ", Short: "Delete a value override", Long: `Delete a value override for a specific chart in a stack instance. @@ -187,8 +186,8 @@ This is a destructive operation. You will be prompted for confirmation unless --yes is specified. Examples: - stackctl override delete 42 1 - stackctl override delete 42 1 --yes`, + stackctl override delete my-stack 1 + stackctl override delete my-stack 1 --yes`, Args: cobra.ExactArgs(2), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -207,22 +206,22 @@ var overrideBranchCmd = &cobra.Command{ } var overrideBranchListCmd = &cobra.Command{ - Use: "list ", + Use: "list ", Short: "List branch overrides for a stack instance", Long: `List all branch overrides for a stack instance. Examples: - stackctl override branch list 42 - stackctl override branch list 42 -o json`, + stackctl override branch list my-stack + stackctl override branch list my-stack -o json`, Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - instanceID, err := parseID(args[0]) + c, err := newClient() if err != nil { return err } - c, err := newClient() + instanceID, err := resolveStackID(c, args[0]) if err != nil { return err } @@ -261,20 +260,16 @@ Examples: } var overrideBranchSetCmd = &cobra.Command{ - Use: "set ", + Use: "set ", Short: "Set a branch override for a chart", Long: `Set a branch override for a specific chart in a stack instance. Examples: - stackctl override branch set 42 1 feature/my-branch - stackctl override branch set 42 1 main -o json`, + stackctl override branch set my-stack 1 feature/my-branch + stackctl override branch set my-stack 1 main -o json`, Args: cobra.ExactArgs(3), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - instanceID, err := parseID(args[0]) - if err != nil { - return err - } chartID, err := parseID(args[1]) if err != nil { return err @@ -286,6 +281,11 @@ Examples: return err } + instanceID, err := resolveStackID(c, args[0]) + if err != nil { + return err + } + override, err := c.SetBranchOverride(instanceID, chartID, &types.SetBranchOverrideRequest{ Branch: branch, }) @@ -311,7 +311,7 @@ Examples: } var overrideBranchDeleteCmd = &cobra.Command{ - Use: "delete ", + Use: "delete ", Short: "Delete a branch override", Long: `Delete a branch override for a specific chart in a stack instance. @@ -319,8 +319,8 @@ This is a destructive operation. You will be prompted for confirmation unless --yes is specified. Examples: - stackctl override branch delete 42 1 - stackctl override branch delete 42 1 --yes`, + stackctl override branch delete my-stack 1 + stackctl override branch delete my-stack 1 --yes`, Args: cobra.ExactArgs(2), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -339,22 +339,22 @@ var overrideQuotaCmd = &cobra.Command{ } var overrideQuotaGetCmd = &cobra.Command{ - Use: "get ", + Use: "get ", Short: "Get quota override for a stack instance", Long: `Get the resource quota override for a stack instance. Examples: - stackctl override quota get 42 - stackctl override quota get 42 -o json`, + stackctl override quota get my-stack + stackctl override quota get my-stack -o json`, Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - instanceID, err := parseID(args[0]) + c, err := newClient() if err != nil { return err } - c, err := newClient() + instanceID, err := resolveStackID(c, args[0]) if err != nil { return err } @@ -388,24 +388,19 @@ Examples: } var overrideQuotaSetCmd = &cobra.Command{ - Use: "set ", + Use: "set ", Short: "Set quota override for a stack instance", Long: `Set resource quota overrides for a stack instance. At least one of the quota flags must be specified. Examples: - stackctl override quota set 42 --cpu-request 100m --cpu-limit 500m - stackctl override quota set 42 --memory-request 128Mi --memory-limit 512Mi - stackctl override quota set 42 --cpu-request 200m --memory-limit 1Gi`, + stackctl override quota set my-stack --cpu-request 100m --cpu-limit 500m + stackctl override quota set my-stack --memory-request 128Mi --memory-limit 512Mi + stackctl override quota set my-stack --cpu-request 200m --memory-limit 1Gi`, Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - instanceID, err := parseID(args[0]) - if err != nil { - return err - } - cpuReq, _ := cmd.Flags().GetString("cpu-request") cpuLim, _ := cmd.Flags().GetString("cpu-limit") memReq, _ := cmd.Flags().GetString("memory-request") @@ -420,6 +415,11 @@ Examples: return err } + instanceID, err := resolveStackID(c, args[0]) + if err != nil { + return err + } + quota, err := c.SetQuotaOverride(instanceID, &types.SetQuotaOverrideRequest{ CPURequest: cpuReq, CPULimit: cpuLim, @@ -448,7 +448,7 @@ Examples: } var overrideQuotaDeleteCmd = &cobra.Command{ - Use: "delete ", + Use: "delete ", Short: "Delete quota override for a stack instance", Long: `Delete the resource quota override for a stack instance. @@ -456,12 +456,17 @@ This is a destructive operation. You will be prompted for confirmation unless --yes is specified. Examples: - stackctl override quota delete 42 - stackctl override quota delete 42 --yes`, + stackctl override quota delete my-stack + stackctl override quota delete my-stack --yes`, Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - instanceID, err := parseID(args[0]) + c, err := newClient() + if err != nil { + return err + } + + instanceID, err := resolveStackID(c, args[0]) if err != nil { return err } @@ -475,11 +480,6 @@ Examples: return nil } - c, err := newClient() - if err != nil { - return err - } - if err := c.DeleteQuotaOverride(instanceID); err != nil { return err } @@ -495,11 +495,17 @@ Examples: } func deleteChartOverride(cmd *cobra.Command, args []string, kind string, deleteFn func(*client.Client, string, string) error) error { - instanceID, err := parseID(args[0]) + chartID, err := parseID(args[1]) if err != nil { return err } - chartID, err := parseID(args[1]) + + c, err := newClient() + if err != nil { + return err + } + + instanceID, err := resolveStackID(c, args[0]) if err != nil { return err } @@ -513,11 +519,6 @@ func deleteChartOverride(cmd *cobra.Command, args []string, kind string, deleteF return nil } - c, err := newClient() - if err != nil { - return err - } - if err := deleteFn(c, instanceID, chartID); err != nil { return err } diff --git a/cli/cmd/resolve.go b/cli/cmd/resolve.go new file mode 100644 index 0000000..0af5a89 --- /dev/null +++ b/cli/cmd/resolve.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/omattsson/stackctl/cli/pkg/client" +) + +var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + +func looksLikeID(s string) bool { + if uuidRegex.MatchString(s) { + return true + } + if _, err := strconv.Atoi(s); err == nil { + return true + } + return false +} + +func resolveStackID(c *client.Client, nameOrID string) (string, error) { + nameOrID = strings.TrimSpace(nameOrID) + if nameOrID == "" { + return "", fmt.Errorf("stack name or ID must not be empty") + } + + if looksLikeID(nameOrID) { + return nameOrID, nil + } + + resp, err := c.ListStacks(map[string]string{"name": nameOrID}) + if err != nil { + return "", fmt.Errorf("resolving stack name %q: %w", nameOrID, err) + } + + switch len(resp.Data) { + case 0: + return "", fmt.Errorf("no stack found with name %q", nameOrID) + case 1: + if !strings.EqualFold(resp.Data[0].Name, nameOrID) { + return "", fmt.Errorf("no stack found with name %q", nameOrID) + } + return resp.Data[0].ID, nil + default: + msg := fmt.Sprintf("multiple stacks match name %q — use the ID instead:\n", nameOrID) + for _, s := range resp.Data { + msg += fmt.Sprintf(" %s (owner: %s, status: %s)\n", s.ID, s.Owner, s.Status) + } + return "", fmt.Errorf("%s", msg) + } +} diff --git a/cli/cmd/resolve_test.go b/cli/cmd/resolve_test.go new file mode 100644 index 0000000..5882eda --- /dev/null +++ b/cli/cmd/resolve_test.go @@ -0,0 +1,177 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/omattsson/stackctl/cli/pkg/client" + "github.com/omattsson/stackctl/cli/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveStackID_UUID(t *testing.T) { + t.Parallel() + c := client.New("http://should-not-be-called") + id, err := resolveStackID(c, "550e8400-e29b-41d4-a716-446655440000") + require.NoError(t, err) + assert.Equal(t, "550e8400-e29b-41d4-a716-446655440000", id) +} + +func TestResolveStackID_NumericID(t *testing.T) { + t.Parallel() + c := client.New("http://should-not-be-called") + id, err := resolveStackID(c, "42") + require.NoError(t, err) + assert.Equal(t, "42", id) +} + +func TestResolveStackID_UUID_UpperCase(t *testing.T) { + t.Parallel() + c := client.New("http://should-not-be-called") + id, err := resolveStackID(c, "550E8400-E29B-41D4-A716-446655440000") + require.NoError(t, err) + assert.Equal(t, "550E8400-E29B-41D4-A716-446655440000", id) +} + +func TestResolveStackID_Empty(t *testing.T) { + t.Parallel() + c := client.New("http://unused") + _, err := resolveStackID(c, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "must not be empty") +} + +func TestResolveStackID_Whitespace(t *testing.T) { + t.Parallel() + c := client.New("http://unused") + _, err := resolveStackID(c, " ") + assert.Error(t, err) + assert.Contains(t, err.Error(), "must not be empty") +} + +func TestResolveStackID_NameSingleMatch(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/stack-instances", r.URL.Path) + assert.Equal(t, "my-stack", r.URL.Query().Get("name")) + json.NewEncoder(w).Encode(types.ListResponse[types.StackInstance]{ + Data: []types.StackInstance{ + {Base: types.Base{ID: "abc-123"}, Name: "my-stack", Owner: "alice", Status: "running"}, + }, + Total: 1, + Page: 1, + PageSize: 1, + }) + })) + defer server.Close() + + c := client.New(server.URL) + id, err := resolveStackID(c, "my-stack") + require.NoError(t, err) + assert.Equal(t, "abc-123", id) +} + +func TestResolveStackID_NameNoMatch(t *testing.T) { + t.Parallel() + 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) + _, err := resolveStackID(c, "nonexistent") + assert.Error(t, err) + assert.Contains(t, err.Error(), `no stack found with name "nonexistent"`) +} + +func TestResolveStackID_NameMultipleMatches(t *testing.T) { + t.Parallel() + 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: "id-1"}, Name: "my-stack", Owner: "alice", Status: "running"}, + {Base: types.Base{ID: "id-2"}, Name: "my-stack", Owner: "bob", Status: "stopped"}, + }, + Total: 2, + Page: 1, + PageSize: 2, + }) + })) + defer server.Close() + + c := client.New(server.URL) + _, err := resolveStackID(c, "my-stack") + assert.Error(t, err) + assert.Contains(t, err.Error(), "multiple stacks match") + assert.Contains(t, err.Error(), "id-1") + assert.Contains(t, err.Error(), "id-2") +} + +func TestResolveStackID_NameWithWhitespace(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "my-stack", r.URL.Query().Get("name")) + json.NewEncoder(w).Encode(types.ListResponse[types.StackInstance]{ + Data: []types.StackInstance{ + {Base: types.Base{ID: "abc-123"}, Name: "my-stack"}, + }, + Total: 1, Page: 1, PageSize: 1, + }) + })) + defer server.Close() + + c := client.New(server.URL) + id, err := resolveStackID(c, " my-stack ") + require.NoError(t, err) + assert.Equal(t, "abc-123", id) +} + +func TestResolveStackID_APIError(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":"internal server error"}`)) + })) + defer server.Close() + + c := client.New(server.URL) + _, err := resolveStackID(c, "my-stack") + assert.Error(t, err) + assert.Contains(t, err.Error(), "resolving stack name") +} + +func TestResolveStackID_NameMismatch(t *testing.T) { + t.Parallel() + 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: "abc-123"}, Name: "different-stack", Owner: "alice", Status: "running"}, + }, + Total: 1, Page: 1, PageSize: 1, + }) + })) + defer server.Close() + + c := client.New(server.URL) + _, err := resolveStackID(c, "my-stack") + assert.Error(t, err) + assert.Contains(t, err.Error(), `no stack found with name "my-stack"`) +} + +func TestPassthroughID(t *testing.T) { + t.Parallel() + id, err := passthroughID(nil, "some-id") + require.NoError(t, err) + assert.Equal(t, "some-id", id) + + _, err = passthroughID(nil, "") + assert.Error(t, err) +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 17b6d2b..e7c0ab6 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -204,8 +204,13 @@ func confirmAction(cmd *cobra.Command, message string) (bool, error) { return true, nil } -func deleteByID(cmd *cobra.Command, args []string, promptFmt string, deleteFn func(*client.Client, string) error, successFmt string) error { - id, err := parseID(args[0]) +func deleteByID(cmd *cobra.Command, args []string, promptFmt string, resolveFn func(*client.Client, string) (string, error), deleteFn func(*client.Client, string) error, successFmt string) error { + c, err := newClient() + if err != nil { + return err + } + + id, err := resolveFn(c, args[0]) if err != nil { return err } @@ -219,11 +224,6 @@ func deleteByID(cmd *cobra.Command, args []string, promptFmt string, deleteFn fu return nil } - c, err := newClient() - if err != nil { - return err - } - if err := deleteFn(c, id); err != nil { return err } @@ -236,3 +236,7 @@ func deleteByID(cmd *cobra.Command, args []string, promptFmt string, deleteFn fu printer.PrintMessage(successFmt, id) return nil } + +func passthroughID(_ *client.Client, id string) (string, error) { + return parseID(id) +} diff --git a/cli/cmd/stack.go b/cli/cmd/stack.go index ca7091c..c8d8682 100644 --- a/cli/cmd/stack.go +++ b/cli/cmd/stack.go @@ -18,7 +18,10 @@ const flagPageSize = "page-size" var stackCmd = &cobra.Command{ Use: "stack", Short: "Manage stack instances", - Long: "Create, deploy, monitor, and manage stack instances.", + Long: `Create, deploy, monitor, and manage stack instances. + +Most commands accept a stack name or UUID as the argument. Purely numeric +values (e.g. "42") are always treated as IDs, not names.`, } var stackListCmd = &cobra.Command{ @@ -113,22 +116,23 @@ Examples: } var stackGetCmd = &cobra.Command{ - Use: "get ", + Use: "get ", Short: "Show stack instance details", Long: `Show detailed information about a stack instance. Examples: - stackctl stack get 42 - stackctl stack get 42 -o json`, + stackctl stack get my-stack + stackctl stack get 550e8400-e29b-41d4-a716-446655440000 + stackctl stack get my-stack -o json`, Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - id, err := parseID(args[0]) + c, err := newClient() if err != nil { return err } - c, err := newClient() + id, err := resolveStackID(c, args[0]) if err != nil { return err } @@ -184,21 +188,22 @@ Examples: } var stackDeployCmd = &cobra.Command{ - Use: "deploy ", + Use: "deploy ", Short: "Deploy a stack instance", Long: `Trigger a deployment for a stack instance. Examples: - stackctl stack deploy 42`, + stackctl stack deploy my-stack + stackctl stack deploy 550e8400-e29b-41d4-a716-446655440000`, Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - id, err := parseID(args[0]) + c, err := newClient() if err != nil { return err } - c, err := newClient() + id, err := resolveStackID(c, args[0]) if err != nil { return err } @@ -219,21 +224,22 @@ Examples: } var stackStopCmd = &cobra.Command{ - Use: "stop ", + Use: "stop ", Short: "Stop a stack instance", Long: `Stop a running stack instance. Examples: - stackctl stack stop 42`, + stackctl stack stop my-stack + stackctl stack stop 550e8400-e29b-41d4-a716-446655440000`, Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - id, err := parseID(args[0]) + c, err := newClient() if err != nil { return err } - c, err := newClient() + id, err := resolveStackID(c, args[0]) if err != nil { return err } @@ -254,7 +260,7 @@ Examples: } var stackCleanCmd = &cobra.Command{ - Use: "clean ", + Use: "clean ", Short: "Undeploy and remove namespace for a stack instance", Long: `Undeploy a stack instance and remove its namespace. @@ -262,12 +268,17 @@ This is a destructive operation. You will be prompted for confirmation unless --yes is specified. Examples: - stackctl stack clean 42 - stackctl stack clean 42 --yes`, + stackctl stack clean my-stack + stackctl stack clean my-stack --yes`, Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - id, err := parseID(args[0]) + c, err := newClient() + if err != nil { + return err + } + + id, err := resolveStackID(c, args[0]) if err != nil { return err } @@ -281,11 +292,6 @@ Examples: return nil } - c, err := newClient() - if err != nil { - return err - } - resp, err := c.CleanStack(id) if err != nil { return err @@ -302,7 +308,7 @@ Examples: } var stackDeleteCmd = &cobra.Command{ - Use: "delete ", + Use: "delete ", Short: "Delete a stack instance", Long: `Permanently delete a stack instance. @@ -310,13 +316,14 @@ This is a destructive operation. You will be prompted for confirmation unless --yes is specified. Examples: - stackctl stack delete 42 - stackctl stack delete 42 --yes`, + stackctl stack delete my-stack + stackctl stack delete my-stack --yes`, Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { return deleteByID(cmd, args, "This will permanently delete stack %s. Continue? (y/n): ", + resolveStackID, func(c *client.Client, id string) error { return c.DeleteStack(id) }, "Deleted stack %s", ) @@ -324,22 +331,22 @@ Examples: } var stackStatusCmd = &cobra.Command{ - Use: "status ", + Use: "status ", Short: "Show pod status for a stack instance", Long: `Show the current status and pod states for a stack instance. Examples: - stackctl stack status 42 - stackctl stack status 42 -o json`, + stackctl stack status my-stack + stackctl stack status my-stack -o json`, Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - id, err := parseID(args[0]) + c, err := newClient() if err != nil { return err } - c, err := newClient() + id, err := resolveStackID(c, args[0]) if err != nil { return err } @@ -386,22 +393,22 @@ Examples: } var stackLogsCmd = &cobra.Command{ - Use: "logs ", + Use: "logs ", Short: "Show latest deployment log for a stack instance", Long: `Show the latest deployment log for a stack instance. Examples: - stackctl stack logs 42 - stackctl stack logs 42 -o json`, + stackctl stack logs my-stack + stackctl stack logs my-stack -o json`, Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - id, err := parseID(args[0]) + c, err := newClient() if err != nil { return err } - c, err := newClient() + id, err := resolveStackID(c, args[0]) if err != nil { return err } @@ -434,22 +441,22 @@ Examples: } var stackCloneCmd = &cobra.Command{ - Use: "clone ", + Use: "clone ", Short: "Clone a stack instance", Long: `Clone a stack instance, creating a new instance with the same configuration. Examples: - stackctl stack clone 42 - stackctl stack clone 42 -q`, + stackctl stack clone my-stack + stackctl stack clone my-stack -q`, Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - id, err := parseID(args[0]) + c, err := newClient() if err != nil { return err } - c, err := newClient() + id, err := resolveStackID(c, args[0]) if err != nil { return err } @@ -470,21 +477,16 @@ Examples: } var stackExtendCmd = &cobra.Command{ - Use: "extend ", + Use: "extend ", Short: "Extend the TTL of a stack instance", Long: `Extend the time-to-live of a stack instance by the specified number of minutes. Examples: - stackctl stack extend 42 --minutes 60 - stackctl stack extend 42 --minutes 120`, + stackctl stack extend my-stack --minutes 60 + stackctl stack extend my-stack --minutes 120`, Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - id, err := parseID(args[0]) - if err != nil { - return err - } - minutes, _ := cmd.Flags().GetInt("minutes") if minutes <= 0 { return fmt.Errorf("--minutes must be a positive integer") @@ -495,6 +497,11 @@ Examples: return err } + id, err := resolveStackID(c, args[0]) + if err != nil { + return err + } + _, err = c.ExtendStack(id, minutes) if err != nil { return err @@ -511,27 +518,27 @@ Examples: } var stackValuesCmd = &cobra.Command{ - Use: "values ", + Use: "values ", Short: "Show merged Helm values for a stack instance", Long: `Show the fully merged Helm values for a stack instance. Nested values are displayed as JSON by default. Use -o yaml for YAML format. Examples: - stackctl stack values 1 - stackctl stack values 1 --chart my-chart - stackctl stack values 1 -o json`, + stackctl stack values my-stack + stackctl stack values my-stack --chart my-chart + stackctl stack values my-stack -o json`, Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - id, err := parseID(args[0]) + chart, _ := cmd.Flags().GetString("chart") + + c, err := newClient() if err != nil { return err } - chart, _ := cmd.Flags().GetString("chart") - - c, err := newClient() + id, err := resolveStackID(c, args[0]) if err != nil { return err } @@ -558,21 +565,26 @@ Examples: } var stackCompareCmd = &cobra.Command{ - Use: "compare ", + Use: "compare ", Short: "Compare two stack instances", Long: `Compare two stack instances and show their differences. Examples: - stackctl stack compare 42 43 - stackctl stack compare 42 43 -o json`, + stackctl stack compare my-stack other-stack + stackctl stack compare my-stack other-stack -o json`, Args: cobra.ExactArgs(2), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - leftID, err := parseID(args[0]) + c, err := newClient() if err != nil { return err } - rightID, err := parseID(args[1]) + + leftID, err := resolveStackID(c, args[0]) + if err != nil { + return err + } + rightID, err := resolveStackID(c, args[1]) if err != nil { return err } @@ -581,11 +593,6 @@ Examples: return fmt.Errorf("cannot compare an instance with itself (both IDs are %s)", leftID) } - c, err := newClient() - if err != nil { - return err - } - result, err := c.CompareInstances(leftID, rightID) if err != nil { return err @@ -628,25 +635,25 @@ Examples: } var stackHistoryCmd = &cobra.Command{ - Use: "history ", + Use: "history ", Short: "Show deployment history for a stack instance", Long: `Show the deployment history for a stack instance. Examples: - stackctl stack history 42 - stackctl stack history 42 --limit 20 - stackctl stack history 42 -o json`, + stackctl stack history my-stack + stackctl stack history my-stack --limit 20 + stackctl stack history my-stack -o json`, Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - id, err := parseID(args[0]) + limit, _ := cmd.Flags().GetInt("limit") + + c, err := newClient() if err != nil { return err } - limit, _ := cmd.Flags().GetInt("limit") - - c, err := newClient() + id, err := resolveStackID(c, args[0]) if err != nil { return err } @@ -697,7 +704,7 @@ Examples: } var stackRollbackCmd = &cobra.Command{ - Use: "rollback ", + Use: "rollback ", Short: "Rollback a stack instance to the previous deployment", Long: `Rollback all Helm releases in a stack instance to their previous revision. @@ -707,13 +714,18 @@ confirmation unless --yes is specified. Optionally specify --target-log to rollback to a specific past deployment. Examples: - stackctl stack rollback 42 - stackctl stack rollback 42 --yes - stackctl stack rollback 42 --target-log abc-123`, + stackctl stack rollback my-stack + stackctl stack rollback my-stack --yes + stackctl stack rollback my-stack --target-log abc-123`, Args: cobra.ExactArgs(1), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - id, err := parseID(args[0]) + c, err := newClient() + if err != nil { + return err + } + + id, err := resolveStackID(c, args[0]) if err != nil { return err } @@ -727,11 +739,6 @@ Examples: return nil } - c, err := newClient() - if err != nil { - return err - } - targetLog, _ := cmd.Flags().GetString("target-log") req := &types.RollbackRequest{TargetLogID: targetLog} @@ -751,26 +758,27 @@ Examples: } var stackHistoryValuesCmd = &cobra.Command{ - Use: "history-values ", + Use: "history-values ", Short: "Show values used in a past deployment", Long: `Show the merged Helm values that were used in a specific deployment. Examples: - stackctl stack history-values 42 abc-123 - stackctl stack history-values 42 abc-123 -o yaml`, + stackctl stack history-values my-stack abc-123 + stackctl stack history-values my-stack abc-123 -o yaml`, Args: cobra.ExactArgs(2), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - instanceID, err := parseID(args[0]) + logID, err := parseID(args[1]) if err != nil { return err } - logID, err := parseID(args[1]) + + c, err := newClient() if err != nil { return err } - c, err := newClient() + instanceID, err := resolveStackID(c, args[0]) if err != nil { return err } diff --git a/cli/test/integration/stack_integration_test.go b/cli/test/integration/stack_integration_test.go index b22303b..879736e 100644 --- a/cli/test/integration/stack_integration_test.go +++ b/cli/test/integration/stack_integration_test.go @@ -60,6 +60,7 @@ func startStackMockServer(t *testing.T, state *stackMockState) *httptest.Server var data []types.StackInstance status := r.URL.Query().Get("status") owner := r.URL.Query().Get("owner") + name := r.URL.Query().Get("name") for _, inst := range state.instances { if status != "" && inst.Status != status { continue @@ -67,6 +68,9 @@ func startStackMockServer(t *testing.T, state *stackMockState) *httptest.Server if owner != "" && owner != "me" && inst.Owner != owner { continue } + if name != "" && inst.Name != name { + continue + } data = append(data, *inst) } state.mu.Unlock() @@ -405,6 +409,64 @@ func TestStackWorkflow_CloneAndExtend(t *testing.T) { assert.Equal(t, 2, resp.Total) } +func TestStackWorkflow_GetByName(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + state := newStackMockState() + server := startStackMockServer(t, state) + defer server.Close() + + c := client.New(server.URL) + + created, err := c.CreateStack(&types.CreateStackRequest{Name: "named-stack"}) + require.NoError(t, err) + + resp, err := c.ListStacks(map[string]string{"name": "named-stack"}) + require.NoError(t, err) + require.Len(t, resp.Data, 1) + assert.Equal(t, created.ID, resp.Data[0].ID) + assert.Equal(t, "named-stack", resp.Data[0].Name) +} + +func TestStackWorkflow_GetByNameAmbiguous(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + state := newStackMockState() + server := startStackMockServer(t, state) + defer server.Close() + + c := client.New(server.URL) + + _, err := c.CreateStack(&types.CreateStackRequest{Name: "dup-stack"}) + require.NoError(t, err) + _, err = c.CreateStack(&types.CreateStackRequest{Name: "dup-stack"}) + require.NoError(t, err) + + resp, err := c.ListStacks(map[string]string{"name": "dup-stack"}) + require.NoError(t, err) + assert.Len(t, resp.Data, 2) +} + +func TestStackWorkflow_GetByNameNotFound(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + state := newStackMockState() + server := startStackMockServer(t, state) + defer server.Close() + + c := client.New(server.URL) + + resp, err := c.ListStacks(map[string]string{"name": "nonexistent"}) + require.NoError(t, err) + assert.Empty(t, resp.Data) +} + func TestStackWorkflow_DestructiveOpsOnMissingInstance(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode")