Skip to content
Open
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
3 changes: 2 additions & 1 deletion src/cmd/cli/command/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ type MockFabricControllerClient struct {
defangv1connect.FabricControllerClient
canIUseResponse defangv1.CanIUseResponse
savedProvider map[string]defangv1.Provider
stacksToList []*defangv1.Stack
}

func (m *MockFabricControllerClient) CanIUse(context.Context, *connect.Request[defangv1.CanIUseRequest]) (*connect.Response[defangv1.CanIUseResponse], error) {
Expand Down Expand Up @@ -240,7 +241,7 @@ func (m *MockFabricControllerClient) ListDeployments(ctx context.Context, req *c

func (m *MockFabricControllerClient) ListStacks(ctx context.Context, req *connect.Request[defangv1.ListStacksRequest]) (*connect.Response[defangv1.ListStacksResponse], error) {
return connect.NewResponse(&defangv1.ListStacksResponse{
Stacks: []*defangv1.Stack{},
Stacks: m.stacksToList,
}), nil
}

Expand Down
25 changes: 22 additions & 3 deletions src/cmd/cli/command/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,21 +129,40 @@ func makeStackListCmd() *cobra.Command {
return err
}

stacks, err := sm.List(ctx)
stackList, err := sm.List(ctx)
if err != nil {
return err
}

if len(stacks) == 0 {
if len(stackList) == 0 {
_, err = term.Infof("No Defang stacks found in the current directory.\n")
return err
}

all, _ := cmd.Flags().GetBool("all")
if !all {
filteredStacks := make([]stacks.ListItem, 0, len(stackList))
for _, stack := range stackList {
if stack.Status == defangv1.StackStatus_STACK_STATUS_DOWN {
continue
}
filteredStacks = append(filteredStacks, stack)
}

if len(filteredStacks) == 0 {
_, err = term.Infof("All stacks in the current directory are down.\n")
return err
}

stackList = filteredStacks
}

columns := []string{"Name", "Default", "Provider", "Region", "Account", "Mode", "DeployedAt"}
return term.Table(stacks, columns...)
return term.Table(stackList, columns...)
},
}
stackListCmd.Flags().Bool("json", false, "Output in JSON format")
stackListCmd.Flags().BoolP("all", "a", false, "Include stacks that are down")
Comment on lines 160 to +165
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

--json output path is currently bypassed.

The command always returns term.Table(...), so --json is effectively ignored despite being defined. This is a user-facing regression for scripted usage.

💡 Proposed fix
 import (
 	"context"
+	json_package "encoding/json"
 	"fmt"
+	"os"
@@
-			columns := []string{"Name", "Default", "Provider", "Region", "Account", "Mode", "DeployedAt"}
-			return term.Table(stackList, columns...)
+			if jsonOut, _ := cmd.Flags().GetBool("json"); jsonOut {
+				encoder := json_package.NewEncoder(os.Stdout)
+				return encoder.Encode(stackList)
+			}
+
+			columns := []string{"Name", "Default", "Provider", "Region", "Account", "Mode", "DeployedAt"}
+			return term.Table(stackList, columns...)
 		},
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
columns := []string{"Name", "Default", "Provider", "Region", "Account", "Mode", "DeployedAt"}
return term.Table(stacks, columns...)
return term.Table(stackList, columns...)
},
}
stackListCmd.Flags().Bool("json", false, "Output in JSON format")
stackListCmd.Flags().BoolP("all", "a", false, "Include stacks that are down")
if jsonOut, _ := cmd.Flags().GetBool("json"); jsonOut {
encoder := json_package.NewEncoder(os.Stdout)
return encoder.Encode(stackList)
}
columns := []string{"Name", "Default", "Provider", "Region", "Account", "Mode", "DeployedAt"}
return term.Table(stackList, columns...)
},
}
stackListCmd.Flags().Bool("json", false, "Output in JSON format")
stackListCmd.Flags().BoolP("all", "a", false, "Include stacks that are down")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cmd/cli/command/stack.go` around lines 158 - 163, The CLI currently
always returns term.Table(stackList, columns...) so the --json flag is ignored;
inside the stackListCmd RunE closure, read the "json" flag (e.g.
cmd.Flags().GetBool("json") or stackListCmd.Flags().GetBool("json")) and branch:
if true, marshal stackList to JSON and write it to stdout (or use an existing
JSON helper) and return, otherwise call term.Table as before; modify the code
around stackList, columns, and the term.Table call so the JSON path is taken
when "json" is true.

return stackListCmd
}

Expand Down
86 changes: 67 additions & 19 deletions src/cmd/cli/command/stack_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,40 +64,35 @@ func TestStackListCmd(t *testing.T) {
global.Client = origClient
})

// Set up a mock client
// Set up a mock client (shared, but stacksToList is updated per subtest)
mockClient := client.GrpcClient{}
mockCtrl := &MockFabricControllerClient{
canIUseResponse: defangv1.CanIUseResponse{},
}
mockClient.SetFabricClient(mockCtrl)
global.Client = &mockClient

// Set up a fake RootCmd with required flags
RootCmd = &cobra.Command{Use: "defang"}
RootCmd.PersistentFlags().StringVarP(&global.Stack.Name, "stack", "s", global.Stack.Name, "stack name")
RootCmd.PersistentFlags().VarP(&global.Stack.Provider, "provider", "P", "provider")
RootCmd.PersistentFlags().StringP("project-name", "p", "", "project name")
RootCmd.PersistentFlags().StringArrayP("file", "f", []string{}, "compose file path(s)")

// Create stackListCmd with manual RunE to avoid configureLoader call during test
var stackListCmd = makeStackListCmd()

// Add stackListCmd as a child of RootCmd
RootCmd.AddCommand(stackListCmd)
downStackFile := []byte("DEFANG_PROVIDER=aws\nAWS_REGION=us-test-1\nDEFANG_MODE=affordable")
upStackFile := []byte("DEFANG_PROVIDER=gcp\nGOOGLE_REGION=us-central1\nDEFANG_MODE=balanced")

tests := []struct {
name string
stacks []stacks.Parameters
localStacks []stacks.Parameters
remoteStacks []*defangv1.Stack
cmdArgs []string
expectOutput string
containsAll []string
containsNone []string
}{
{
name: "no stacks present",
stacks: []stacks.Parameters{},
localStacks: []stacks.Parameters{},
cmdArgs: []string{"list"},
expectOutput: " * No Defang stacks found in the current directory.\n",
},
{
name: "multiple stacks present",
stacks: []stacks.Parameters{
localStacks: []stacks.Parameters{
{
Name: "teststack1",
Provider: client.ProviderAWS,
Expand All @@ -111,13 +106,57 @@ func TestStackListCmd(t *testing.T) {
Mode: modes.ModeBalanced,
},
},
cmdArgs: []string{"list"},
expectOutput: "NAME DEFAULT PROVIDER REGION ACCOUNT MODE DEPLOYEDAT\n" +
"teststack1 aws us-test-2 AFFORDABLE \n" +
"teststack2 gcp us-central1 BALANCED \n",
},
{
name: "down stack hidden by default",
remoteStacks: []*defangv1.Stack{
{Name: "downstack", Status: defangv1.StackStatus_STACK_STATUS_DOWN, StackFile: downStackFile},
},
cmdArgs: []string{"list"},
expectOutput: " * All stacks in the current directory are down.\n",
},
{
name: "down stack shown with --all",
remoteStacks: []*defangv1.Stack{
{Name: "downstack", Status: defangv1.StackStatus_STACK_STATUS_DOWN, StackFile: downStackFile},
},
cmdArgs: []string{"list", "--all"},
containsAll: []string{"downstack"},
},
{
name: "mixed stacks, down hidden without --all",
remoteStacks: []*defangv1.Stack{
{Name: "upstack", Status: defangv1.StackStatus_STACK_STATUS_UP, StackFile: upStackFile},
{Name: "downstack", Status: defangv1.StackStatus_STACK_STATUS_DOWN, StackFile: downStackFile},
},
cmdArgs: []string{"list"},
containsAll: []string{"upstack"},
containsNone: []string{"downstack"},
},
{
name: "mixed stacks, all shown with --all",
remoteStacks: []*defangv1.Stack{
{Name: "upstack", Status: defangv1.StackStatus_STACK_STATUS_UP, StackFile: upStackFile},
{Name: "downstack", Status: defangv1.StackStatus_STACK_STATUS_DOWN, StackFile: downStackFile},
},
cmdArgs: []string{"list", "--all"},
containsAll: []string{"upstack", "downstack"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Recreate RootCmd and stackListCmd per subtest so flag state is fresh
RootCmd = &cobra.Command{Use: "defang"}
RootCmd.PersistentFlags().StringVarP(&global.Stack.Name, "stack", "s", global.Stack.Name, "stack name")
RootCmd.PersistentFlags().VarP(&global.Stack.Provider, "provider", "P", "provider")
RootCmd.PersistentFlags().StringP("project-name", "p", "", "project name")
RootCmd.PersistentFlags().StringArrayP("file", "f", []string{}, "compose file path(s)")
RootCmd.AddCommand(makeStackListCmd())

// Setup stacks
t.Chdir(t.TempDir())
// create a compose file so stackListCmd doesn't error out
Expand All @@ -128,18 +167,27 @@ func TestStackListCmd(t *testing.T) {
image: nginx`),
os.FileMode(0644),
)
for _, stack := range tt.stacks {
for _, stack := range tt.localStacks {
stacks.CreateInDirectory(".", stack)
}
mockCtrl.stacksToList = tt.remoteStacks

buffer := new(bytes.Buffer)
mockStdin := bytes.NewReader([]byte{})
MockTerm(t, buffer, mockStdin)

RootCmd.SetArgs([]string{"list"})
RootCmd.SetArgs(tt.cmdArgs)
err := RootCmd.Execute()
assert.NoError(t, err)
assert.Equal(t, tt.expectOutput, buffer.String())
if tt.expectOutput != "" {
assert.Equal(t, tt.expectOutput, buffer.String())
}
for _, s := range tt.containsAll {
assert.Contains(t, buffer.String(), s)
}
for _, s := range tt.containsNone {
assert.NotContains(t, buffer.String(), s)
}
})
}
}
Expand Down
1 change: 1 addition & 0 deletions src/pkg/stacks/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ func (sm *manager) ListRemote(ctx context.Context) ([]ListItem, error) {
Account: params.Account(),
DeployedAt: timeutils.AsTime(stack.GetLastDeployedAt(), time.Time{}).Local(),
Default: stack.GetIsDefault(),
Status: stack.Status,
})
}

Expand Down
4 changes: 4 additions & 0 deletions src/pkg/stacks/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ DEFANG_PROVIDER=aws
AWS_REGION=us-east-1
`),
LastDeployedAt: timestamppb.New(deployedAt),
Status: defangv1.StackStatus_STACK_STATUS_UP,
},
{
Name: "remotestack2",
Expand All @@ -302,6 +303,7 @@ DEFANG_PROVIDER=gcp
GOOGLE_REGION=us-central1
`),
LastDeployedAt: timestamppb.New(deployedAt),
Status: defangv1.StackStatus_STACK_STATUS_DOWN,
},
},
}
Expand All @@ -319,11 +321,13 @@ GOOGLE_REGION=us-central1
assert.Equal(t, client.ProviderAWS, remoteStacks[0].Provider, "Expected provider aws for remotestack1")
assert.Equal(t, "us-east-1", remoteStacks[0].Region, "Expected region us-east-1 for remotestack1")
assert.NotZero(t, remoteStacks[0].DeployedAt, "Expected DeployedAt to be set for remotestack1")
assert.Equal(t, defangv1.StackStatus_STACK_STATUS_UP, remoteStacks[0].Status, "Expected status UP for remotestack1")

assert.Equal(t, "remotestack2", remoteStacks[1].Name, "Expected stack name remotestack2")
assert.Equal(t, client.ProviderGCP, remoteStacks[1].Provider, "Expected provider gcp for remotestack2")
assert.Equal(t, "us-central1", remoteStacks[1].Region, "Expected region us-central1 for remotestack2")
assert.NotZero(t, remoteStacks[1].DeployedAt, "Expected DeployedAt to be set for remotestack2")
assert.Equal(t, defangv1.StackStatus_STACK_STATUS_DOWN, remoteStacks[1].Status, "Expected status DOWN for remotestack2")
}

func TestManager_ListRemoteError(t *testing.T) {
Expand Down
15 changes: 12 additions & 3 deletions src/pkg/stacks/selector.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/DefangLabs/defang/src/pkg/cli/client"
"github.com/DefangLabs/defang/src/pkg/elicitations"
"github.com/DefangLabs/defang/src/pkg/term"
defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1"
)

const CreateNewStack = "Create new stack"
Expand Down Expand Up @@ -54,10 +55,18 @@ func (ss *stackSelector) SelectStack(ctx context.Context, opts SelectStackOption
return nil, errors.New("no stacks available to select in this workspace")
}
}
labelMap := MakeStackSelectorLabels(stackList)
stackLabels := make([]string, 0, len(stackList)+1)
stackNames := make([]string, 0, len(stackList))

filteredStackList := make([]ListItem, 0, len(stackList))
for _, stack := range stackList {
if stack.Status == defangv1.StackStatus_STACK_STATUS_DOWN {
continue
}
filteredStackList = append(filteredStackList, stack)
}
labelMap := MakeStackSelectorLabels(filteredStackList)
stackLabels := make([]string, 0, len(filteredStackList)+1)
stackNames := make([]string, 0, len(filteredStackList))
for _, stack := range filteredStackList {
for label, name := range labelMap {
if name == stack.Name {
stackLabels = append(stackLabels, label)
Expand Down
58 changes: 58 additions & 0 deletions src/pkg/stacks/selector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/DefangLabs/defang/src/pkg/cli/client"
"github.com/DefangLabs/defang/src/pkg/elicitations"
"github.com/DefangLabs/defang/src/pkg/modes"
defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
Expand Down Expand Up @@ -122,6 +123,63 @@ func TestStackSelector_SelectStack_ExistingStack(t *testing.T) {
mockSM.AssertExpectations(t)
}

func TestStackSelector_SelectStack_FiltersDownStacks(t *testing.T) {
ctx := t.Context()

mockEC := &MockElicitationsController{}
mockSM := &MockStacksManager{}

mockEC.On("IsSupported").Return(true)

// Mix of up, down, and unspecified stacks
stackList := []ListItem{
{Parameters: Parameters{Name: "upstack", Provider: "aws", Region: "us-east-1"}, Status: defangv1.StackStatus_STACK_STATUS_UP},
{Parameters: Parameters{Name: "downstack", Provider: "aws", Region: "us-east-1"}, Status: defangv1.StackStatus_STACK_STATUS_DOWN},
{Parameters: Parameters{Name: "unspecifiedstack", Provider: "aws", Region: "us-west-2"}, Status: defangv1.StackStatus_STACK_STATUS_UNSPECIFIED},
}
mockSM.On("List", ctx).Return(stackList, nil)

// downstack should not appear in options
expectedOptions := []string{"upstack (us-east-1)", "unspecifiedstack (us-west-2)"}
mockEC.On("RequestEnum", ctx, "Select a stack", "stack", expectedOptions).Return("upstack (us-east-1)", nil)

selector := NewSelector(mockEC, mockSM)
result, err := selector.SelectStack(ctx, SelectStackOptions{})

assert.NoError(t, err)
assert.Equal(t, "upstack", result.Name)

mockEC.AssertExpectations(t)
mockSM.AssertExpectations(t)
}

func TestStackSelector_SelectStack_AllStacksDown(t *testing.T) {
ctx := t.Context()

mockEC := &MockElicitationsController{}
mockSM := &MockStacksManager{}

mockEC.On("IsSupported").Return(true)

stackList := []ListItem{
{Parameters: Parameters{Name: "downstack1", Provider: "aws", Region: "us-east-1"}, Status: defangv1.StackStatus_STACK_STATUS_DOWN},
{Parameters: Parameters{Name: "downstack2", Provider: "aws", Region: "us-west-2"}, Status: defangv1.StackStatus_STACK_STATUS_DOWN},
}
mockSM.On("List", ctx).Return(stackList, nil)

// All stacks filtered out — RequestEnum is called with no selectable options
mockEC.On("RequestEnum", ctx, "Select a stack", "stack", []string{}).Return("", errors.New("no options available"))

selector := NewSelector(mockEC, mockSM)
result, err := selector.SelectStack(ctx, SelectStackOptions{})

assert.Error(t, err)
assert.Nil(t, result)

mockEC.AssertExpectations(t)
mockSM.AssertExpectations(t)
}

func TestStackSelector_SelectOrCreateStack_ExistingStack(t *testing.T) {
ctx := t.Context()

Expand Down
3 changes: 3 additions & 0 deletions src/pkg/stacks/stacks.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/DefangLabs/defang/src/pkg/cli/client"
"github.com/DefangLabs/defang/src/pkg/modes"
"github.com/DefangLabs/defang/src/pkg/term"
defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1"
"github.com/joho/godotenv"
)

Expand Down Expand Up @@ -159,6 +160,7 @@ type ListItem struct {
Account string
Default bool
DeployedAt time.Time
Status defangv1.StackStatus
}

func List() ([]ListItem, error) {
Expand Down Expand Up @@ -192,6 +194,7 @@ func ListInDirectory(workingDirectory string) ([]ListItem, error) {
stacks = append(stacks, ListItem{
Parameters: *params,
Account: params.Account(),
Status: defangv1.StackStatus_STACK_STATUS_UNSPECIFIED, // we don't know the status until we call the API, so we'll set it to UNKNOWN for now
})
}

Expand Down
Loading
Loading