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
32 changes: 32 additions & 0 deletions cli/cmd/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
139 changes: 139 additions & 0 deletions cli/cmd/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
37 changes: 26 additions & 11 deletions cli/cmd/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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")
Expand Down Expand Up @@ -151,20 +158,33 @@ 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")
if ttl < 0 {
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,
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down
Loading