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
2 changes: 1 addition & 1 deletion src/pkg/stacks/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -783,7 +783,7 @@ func TestGetStack(t *testing.T) {
},
},
interactiveResponses: map[string]string{
"stack": "existingstack",
"stack": "existingstack (gcp)",
},
expectedStack: &Parameters{
Name: "existingstack",
Expand Down
94 changes: 83 additions & 11 deletions src/pkg/stacks/selector.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"slices"
"strings"

"github.com/DefangLabs/defang/src/pkg/elicitations"
"github.com/DefangLabs/defang/src/pkg/term"
Expand Down Expand Up @@ -52,21 +53,19 @@ func (ss *stackSelector) SelectStack(ctx context.Context, opts SelectStackOption
return nil, errors.New("no stacks available to select")
}
}

labelMap := MakeStackSelectorLabels(stackList)
stackLabels := make([]string, 0, len(stackList)+1)
stackNames := make([]string, 0, len(stackList))
labelMap := make(map[string]string)
for _, s := range stackList {
var label string
if s.DeployedAt.IsZero() {
label = s.Name
} else {
label = fmt.Sprintf("%s (deployed %s)", s.Name, s.DeployedAt.Format("Jan 2 2006"))
for _, stack := range stackList {
for label, name := range labelMap {
if name == stack.Name {
stackLabels = append(stackLabels, label)
stackNames = append(stackNames, name)
break
}
}
stackLabels = append(stackLabels, label)
stackNames = append(stackNames, s.Name)
labelMap[label] = s.Name
}

if opts.AllowStackCreation {
stackLabels = append(stackLabels, CreateNewStack)
}
Expand Down Expand Up @@ -125,3 +124,76 @@ func printStacksInfoMessage(stacks []string) {
}
term.Printf("To skip this prompt, run this command with --stack=%s\n", "<stack_name>")
}

func MakeStackSelectorLabels(stacks []ListItem) map[string]string {
partsList := stackLabelParts(stacks)
partsList = reduceStackLabelParts(partsList)

labelMap := make(map[string]string)
for i, parts := range partsList {
label := formatStackLabelParts(parts)
labelMap[label] = stacks[i].Name
}
return labelMap
}

func stackLabelParts(stacks []ListItem) [][]string {
partsList := make([][]string, 0, len(stacks))
for _, s := range stacks {
var deployedAt string
if !s.DeployedAt.IsZero() {
deployedAt = "last deployed " + s.DeployedAt.Format("Jan 2 2006")
}
parts := []string{
s.Name,
s.Provider.String(),
s.Region,
deployedAt,
}
partsList = append(partsList, parts)
}
return partsList
}

func reduceStackLabelParts(partsList [][]string) [][]string {
if len(partsList) <= 1 {
return partsList
}
// iterate over the partsList,
// if all stacks have the same value for a given part index, remove that part from all labels
for i := 0; i < len(partsList[0]); i++ {
same := true
value := partsList[0][i]
for _, part := range partsList {
if part[i] != value {
same = false
break
}
}
if same {
for j := 0; j < len(partsList); j++ {
partsList[j] = append(partsList[j][:i], partsList[j][i+1:]...)
}
i-- // adjust index since we removed a part
}
}

return partsList
}

func formatStackLabelParts(parts []string) string {
// remove any empty parts
nonEmptyParts := make([]string, 0, len(parts))
for _, part := range parts {
if part != "" {
nonEmptyParts = append(nonEmptyParts, part)
}
}
if len(nonEmptyParts) == 0 {
return ""
}
if len(nonEmptyParts) == 1 {
return nonEmptyParts[0]
}
return fmt.Sprintf("%s (%s)", nonEmptyParts[0], strings.Join(nonEmptyParts[1:], ", "))
}
85 changes: 77 additions & 8 deletions src/pkg/stacks/selector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@ func TestStackSelector_SelectStack_ExistingStack(t *testing.T) {
mockSM.On("List", ctx).Return(existingStacks, nil)

// Mock user selecting existing stack
expectedOptions := []string{"production", "development"}
mockEC.On("RequestEnum", ctx, "Select a stack", "stack", expectedOptions).Return("production", nil)
expectedOptions := []string{"production (us-west-2)", "development (us-east-1)"}
mockEC.On("RequestEnum", ctx, "Select a stack", "stack", expectedOptions).Return("production (us-west-2)", nil)

// Expected params based on ToParameters() conversion
expectedParams := &Parameters{
Expand Down Expand Up @@ -143,8 +143,8 @@ func TestStackSelector_SelectOrCreateStack_ExistingStack(t *testing.T) {
mockSM.On("List", ctx).Return(existingStacks, nil)

// Mock user selecting existing stack
expectedOptions := []string{"production", "development", CreateNewStack}
mockEC.On("RequestEnum", ctx, "Select a stack", "stack", expectedOptions).Return("production", nil)
expectedOptions := []string{"production (us-west-2)", "development (us-east-1)", CreateNewStack}
mockEC.On("RequestEnum", ctx, "Select a stack", "stack", expectedOptions).Return("production (us-west-2)", nil)

// Expected params based on ToParameters() conversion
expectedParams := &Parameters{
Expand Down Expand Up @@ -183,7 +183,7 @@ func TestStackSelector_SelectStack_CreateNewStack(t *testing.T) {
mockSM.On("List", ctx).Return(existingStacks, nil)

// Mock user selecting to create new stack
expectedOptions := []string{"production", CreateNewStack}
expectedOptions := []string{"production (aws, us-west-2)", CreateNewStack}
mockEC.On("RequestEnum", ctx, "Select a stack", "stack", expectedOptions).Return(CreateNewStack, nil)

// Mock wizard parameter collection - provider selection
Expand Down Expand Up @@ -351,7 +351,7 @@ func TestStackSelector_SelectStack_ElicitationError(t *testing.T) {
mockSM.On("List", ctx).Return(existingStacks, nil)

// Mock error during elicitation
expectedOptions := []string{"production"}
expectedOptions := []string{"production (aws, us-west-2)"}
mockEC.On("RequestEnum", ctx, "Select a stack", "stack", expectedOptions).Return("", errors.New("user cancelled selection"))

selector := NewSelector(mockEC, mockSM)
Expand Down Expand Up @@ -382,7 +382,7 @@ func TestStackSelector_SelectStack_WizardError(t *testing.T) {
mockSM.On("List", ctx).Return(existingStacks, nil)

// Mock user selecting to create new stack
expectedOptions := []string{"production", CreateNewStack}
expectedOptions := []string{"production (aws, us-west-2)", CreateNewStack}
mockEC.On("RequestEnum", ctx, "Select a stack", "stack", expectedOptions).Return(CreateNewStack, nil)

// Mock wizard parameter collection - provider selection fails
Expand Down Expand Up @@ -419,7 +419,7 @@ func TestStackSelector_SelectStack_CreateStackError(t *testing.T) {
mockSM.On("List", ctx).Return(existingStacks, nil)

// Mock user selecting to create new stack
expectedOptions := []string{"production", CreateNewStack}
expectedOptions := []string{"production (aws, us-west-2)", CreateNewStack}
mockEC.On("RequestEnum", ctx, "Select a stack", "stack", expectedOptions).Return(CreateNewStack, nil)

// Mock wizard parameter collection - provider selection
Expand Down Expand Up @@ -468,3 +468,72 @@ func TestStackSelector_SelectStack_CreateStackError(t *testing.T) {
mockSM.AssertExpectations(t)
mockProfileLister.AssertExpectations(t)
}

func TestMakeStackSelectorLabels(t *testing.T) {
tests := []struct {
name string
stacks []ListItem
wantLabels []string
}{
{
name: "no stacks",
stacks: []ListItem{},
wantLabels: []string{},
},
{
name: "one stack - present all fields",
stacks: []ListItem{
{Parameters: Parameters{Name: "production", Provider: "aws", Region: "us-west-2"}},
},
wantLabels: []string{"production (aws, us-west-2)"},
},
{
name: "hide redundant provider",
stacks: []ListItem{
{Parameters: Parameters{Name: "production", Provider: "aws", Region: "us-west-2"}},
{Parameters: Parameters{Name: "development", Provider: "aws", Region: "us-east-1"}},
},
wantLabels: []string{
"production (us-west-2)",
"development (us-east-1)",
},
},
{
name: "hide redundant provider and region",
stacks: []ListItem{
{Parameters: Parameters{Name: "prod-us-west-2", Provider: "aws", Region: "us-west-2"}},
{Parameters: Parameters{Name: "dev-us-west-2", Provider: "aws", Region: "us-west-2"}},
},
wantLabels: []string{
"prod-us-west-2",
"dev-us-west-2",
},
},
{
name: "mixed redundancy",
stacks: []ListItem{
{Parameters: Parameters{Name: "prod-us-west-2", Provider: "aws", Region: "us-west-2"}},
{Parameters: Parameters{Name: "dev-us-east-1", Provider: "aws", Region: "us-east-1"}},
{Parameters: Parameters{Name: "gcp-stack", Provider: "gcp", Region: "us-central1"}},
},
wantLabels: []string{
"prod-us-west-2 (aws, us-west-2)",
"dev-us-east-1 (aws, us-east-1)",
"gcp-stack (gcp, us-central1)",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
labels := MakeStackSelectorLabels(tt.stacks)

// Extract labels into a slice for easier comparison
var gotLabels []string
for label := range labels {
gotLabels = append(gotLabels, label)
}

assert.ElementsMatch(t, tt.wantLabels, gotLabels)
})
}
}
Loading