diff --git a/cli/cmd/resolve.go b/cli/cmd/resolve.go index 0af5a89..9c407a6 100644 --- a/cli/cmd/resolve.go +++ b/cli/cmd/resolve.go @@ -52,3 +52,35 @@ func resolveStackID(c *client.Client, nameOrID string) (string, error) { return "", fmt.Errorf("%s", msg) } } + +func resolveDefinitionID(c *client.Client, nameOrID string) (string, error) { + nameOrID = strings.TrimSpace(nameOrID) + if nameOrID == "" { + return "", fmt.Errorf("definition name or ID must not be empty") + } + + if looksLikeID(nameOrID) { + return nameOrID, nil + } + + resp, err := c.ListDefinitions(map[string]string{"name": nameOrID}) + if err != nil { + return "", fmt.Errorf("resolving definition name %q: %w", nameOrID, err) + } + + switch len(resp.Data) { + case 0: + return "", fmt.Errorf("no definition found with name %q", nameOrID) + case 1: + if !strings.EqualFold(resp.Data[0].Name, nameOrID) { + return "", fmt.Errorf("no definition found with name %q", nameOrID) + } + return resp.Data[0].ID, nil + default: + msg := fmt.Sprintf("multiple definitions match name %q — use the ID instead:\n", nameOrID) + for _, d := range resp.Data { + msg += fmt.Sprintf(" %s (owner: %s)\n", d.ID, d.Owner) + } + return "", fmt.Errorf("%s", msg) + } +} diff --git a/cli/cmd/resolve_test.go b/cli/cmd/resolve_test.go index 5882eda..61fba12 100644 --- a/cli/cmd/resolve_test.go +++ b/cli/cmd/resolve_test.go @@ -175,3 +175,142 @@ func TestPassthroughID(t *testing.T) { _, err = passthroughID(nil, "") assert.Error(t, err) } + +func TestResolveDefinitionID_UUID(t *testing.T) { + t.Parallel() + c := client.New("http://unused") + id, err := resolveDefinitionID(c, "550e8400-e29b-41d4-a716-446655440000") + require.NoError(t, err) + assert.Equal(t, "550e8400-e29b-41d4-a716-446655440000", id) +} + +func TestResolveDefinitionID_NumericID(t *testing.T) { + t.Parallel() + c := client.New("http://unused") + id, err := resolveDefinitionID(c, "42") + require.NoError(t, err) + assert.Equal(t, "42", id) +} + +func TestResolveDefinitionID_Empty(t *testing.T) { + t.Parallel() + c := client.New("http://unused") + _, err := resolveDefinitionID(c, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "must not be empty") +} + +func TestResolveDefinitionID_Whitespace(t *testing.T) { + t.Parallel() + c := client.New("http://unused") + _, err := resolveDefinitionID(c, " ") + assert.Error(t, err) + assert.Contains(t, err.Error(), "must not be empty") +} + +func TestResolveDefinitionID_NameWithWhitespace(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "klaravik-dev", r.URL.Query().Get("name")) + json.NewEncoder(w).Encode(types.ListResponse[types.StackDefinition]{ + Data: []types.StackDefinition{ + {Base: types.Base{ID: "def-123"}, Name: "klaravik-dev"}, + }, + Total: 1, Page: 1, PageSize: 1, + }) + })) + defer server.Close() + + c := client.New(server.URL) + id, err := resolveDefinitionID(c, " klaravik-dev ") + require.NoError(t, err) + assert.Equal(t, "def-123", id) +} + +func TestResolveDefinitionID_NameSingleMatch(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/stack-definitions", r.URL.Path) + assert.Equal(t, "klaravik-dev", r.URL.Query().Get("name")) + json.NewEncoder(w).Encode(types.ListResponse[types.StackDefinition]{ + Data: []types.StackDefinition{ + {Base: types.Base{ID: "def-123"}, Name: "klaravik-dev", Owner: "alice"}, + }, + Total: 1, Page: 1, PageSize: 1, + }) + })) + defer server.Close() + + c := client.New(server.URL) + id, err := resolveDefinitionID(c, "klaravik-dev") + require.NoError(t, err) + assert.Equal(t, "def-123", id) +} + +func TestResolveDefinitionID_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.StackDefinition]{ + Data: []types.StackDefinition{}, Total: 0, Page: 1, PageSize: 0, + }) + })) + defer server.Close() + + c := client.New(server.URL) + _, err := resolveDefinitionID(c, "nonexistent") + assert.Error(t, err) + assert.Contains(t, err.Error(), `no definition found with name "nonexistent"`) +} + +func TestResolveDefinitionID_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.StackDefinition]{ + Data: []types.StackDefinition{ + {Base: types.Base{ID: "def-1"}, Name: "klaravik-dev", Owner: "alice"}, + {Base: types.Base{ID: "def-2"}, Name: "klaravik-dev", Owner: "bob"}, + }, + Total: 2, Page: 1, PageSize: 2, + }) + })) + defer server.Close() + + c := client.New(server.URL) + _, err := resolveDefinitionID(c, "klaravik-dev") + assert.Error(t, err) + assert.Contains(t, err.Error(), "multiple definitions match") + assert.Contains(t, err.Error(), "def-1") + assert.Contains(t, err.Error(), "def-2") +} + +func TestResolveDefinitionID_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.StackDefinition]{ + Data: []types.StackDefinition{ + {Base: types.Base{ID: "def-123"}, Name: "other-def", Owner: "alice"}, + }, + Total: 1, Page: 1, PageSize: 1, + }) + })) + defer server.Close() + + c := client.New(server.URL) + _, err := resolveDefinitionID(c, "klaravik-dev") + assert.Error(t, err) + assert.Contains(t, err.Error(), `no definition found with name "klaravik-dev"`) +} + +func TestResolveDefinitionID_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 := resolveDefinitionID(c, "klaravik-dev") + assert.Error(t, err) + assert.Contains(t, err.Error(), "resolving definition name") +} diff --git a/cli/cmd/stack.go b/cli/cmd/stack.go index c8d8682..07e979b 100644 --- a/cli/cmd/stack.go +++ b/cli/cmd/stack.go @@ -29,10 +29,13 @@ var stackListCmd = &cobra.Command{ Short: "List stack instances", Long: `List stack instances with optional filtering. +The --definition flag accepts either a definition name or ID. + Examples: stackctl stack list stackctl stack list --mine stackctl stack list --status running --cluster 1 + stackctl stack list --definition klaravik-dev stackctl stack list -o json stackctl stack list -q | xargs -I{} stackctl stack deploy {}`, SilenceUsage: true, @@ -58,7 +61,11 @@ Examples: params["cluster_id"] = cluster } if def, _ := cmd.Flags().GetString("definition"); def != "" { - params["definition_id"] = def + defID, err := resolveDefinitionID(c, def) + if err != nil { + return err + } + params["definition_id"] = defID } if cmd.Flags().Changed("page") { page, _ := cmd.Flags().GetInt("page") @@ -151,13 +158,16 @@ var stackCreateCmd = &cobra.Command{ Short: "Create a new stack instance", Long: `Create a new stack instance from a definition. +The --definition flag accepts either a definition name or ID. + Examples: - stackctl stack create --name my-stack --definition 1 - stackctl stack create --name my-stack --definition 1 --branch feature/xyz --cluster 2 --ttl 120`, + stackctl stack create --name my-stack --definition klaravik-dev + stackctl stack create --name my-stack --definition e9af3b10-4633-436b-a131-975a3b598e3e + stackctl stack create --name my-stack --definition klaravik-dev --branch feature/xyz --cluster 2 --ttl 120`, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { name, _ := cmd.Flags().GetString("name") - defID, _ := cmd.Flags().GetString("definition") + defNameOrID, _ := cmd.Flags().GetString("definition") branch, _ := cmd.Flags().GetString("branch") clusterID, _ := cmd.Flags().GetString("cluster") ttl, _ := cmd.Flags().GetInt("ttl") @@ -165,6 +175,16 @@ Examples: return fmt.Errorf("--ttl must be a non-negative integer (0 means no TTL)") } + c, err := newClient() + if err != nil { + return err + } + + defID, err := resolveDefinitionID(c, defNameOrID) + if err != nil { + return err + } + req := &types.CreateStackRequest{ Name: name, StackDefinitionID: defID, @@ -173,11 +193,6 @@ Examples: TTLMinutes: ttl, } - c, err := newClient() - if err != nil { - return err - } - created, err := c.CreateStack(req) if err != nil { return err @@ -810,14 +825,14 @@ func init() { stackListCmd.Flags().String("owner", "", "Filter by owner") stackListCmd.Flags().String("status", "", "Filter by status") stackListCmd.Flags().String("cluster", "", "Filter by cluster ID") - stackListCmd.Flags().String("definition", "", "Filter by definition ID") + stackListCmd.Flags().String("definition", "", "Filter by definition name or ID") stackListCmd.Flags().Int("page", 0, "Page number") stackListCmd.Flags().Int(flagPageSize, 0, "Page size") stackListCmd.MarkFlagsMutuallyExclusive("mine", "owner") // stack create flags stackCreateCmd.Flags().String("name", "", "Stack instance name (required)") - stackCreateCmd.Flags().String("definition", "", "Stack definition ID (required)") + stackCreateCmd.Flags().String("definition", "", "Stack definition name or ID (required)") stackCreateCmd.Flags().String("branch", "", "Git branch") stackCreateCmd.Flags().String("cluster", "", "Target cluster ID") stackCreateCmd.Flags().Int("ttl", 0, "Time to live in minutes")