From da86673ec733a1492a96151ac742708518befae7 Mon Sep 17 00:00:00 2001 From: Lionello Lunesu Date: Fri, 29 May 2026 13:55:20 -0700 Subject: [PATCH 1/6] feat: add deployment mode selection to parameter collection --- src/pkg/stacks/wizard.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/pkg/stacks/wizard.go b/src/pkg/stacks/wizard.go index b82c34c40..3be103e1d 100644 --- a/src/pkg/stacks/wizard.go +++ b/src/pkg/stacks/wizard.go @@ -13,6 +13,7 @@ import ( "github.com/DefangLabs/defang/src/pkg" "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/elicitations" + "github.com/DefangLabs/defang/src/pkg/modes" ) type Wizard struct { @@ -81,6 +82,16 @@ func (w *Wizard) CollectRemainingParameters(ctx context.Context, params *Paramet params.Region = region } + if params.Mode == modes.ModeUnspecified && params.Provider != client.ProviderDefang { + modeName, err := w.ec.RequestEnum(ctx, "Which recipe (deployment mode) do you want to deploy with?", "mode", + modes.AllDeploymentModes(), + ) + if err != nil { + return nil, fmt.Errorf("failed to elicit deployment mode: %w", err) + } + params.Mode = modes.Parse(modeName) + } + if params.Name == "" { defaultName := MakeDefaultName(params.Provider, params.Region) name, err := w.ec.RequestString(ctx, "What do you want to call this stack?:", "stack_name", From 9e50ba46d69145d2db4df3c24996a69a42123f7f Mon Sep 17 00:00:00 2001 From: Lionello Lunesu Date: Fri, 29 May 2026 15:02:19 -0700 Subject: [PATCH 2/6] feat: implement recipe management commands and integrate with deployment modes --- src/cmd/cli/command/commands.go | 2 + src/cmd/cli/command/estimate.go | 3 + src/cmd/cli/command/recipe.go | 93 +++++++++++++++++++++++ src/cmd/cli/command/stack.go | 1 - src/pkg/agent/tools/create_aws_stack.go | 7 +- src/pkg/agent/tools/create_azure_stack.go | 7 +- src/pkg/agent/tools/create_gcp_stack.go | 7 +- src/pkg/cli/client/byoc/aws/byoc.go | 1 + src/pkg/cli/client/byoc/azure/byoc.go | 1 + src/pkg/cli/client/byoc/do/byoc.go | 1 + src/pkg/cli/client/byoc/gcp/byoc.go | 1 + src/pkg/cli/client/client.go | 5 +- src/pkg/cli/client/grpc.go | 13 ++++ src/pkg/cli/client/provider.go | 1 + src/pkg/cli/common.go | 1 + src/pkg/cli/composeUp.go | 7 ++ src/pkg/cli/estimate.go | 5 +- src/pkg/cli/recipeActivate.go | 33 ++++++++ src/pkg/cli/recipeList.go | 23 ++++++ src/pkg/cli/recipeShow.go | 20 +++++ src/pkg/cli/tail_test.go | 3 + src/pkg/modes/modes.go | 91 +++++++++------------- src/pkg/stacks/stacks.go | 2 +- 23 files changed, 248 insertions(+), 80 deletions(-) create mode 100644 src/cmd/cli/command/recipe.go create mode 100644 src/pkg/cli/recipeActivate.go create mode 100644 src/pkg/cli/recipeList.go create mode 100644 src/pkg/cli/recipeShow.go diff --git a/src/cmd/cli/command/commands.go b/src/cmd/cli/command/commands.go index 532ebc1f8..c2999445d 100644 --- a/src/cmd/cli/command/commands.go +++ b/src/cmd/cli/command/commands.go @@ -319,6 +319,8 @@ func SetupCommands(version string) { // TODO: Add list, renew etc. certCmd.AddCommand(certGenerateCmd) RootCmd.AddCommand(certCmd) + recipeCmd := makeRecipeCmd() + RootCmd.AddCommand(recipeCmd) stackCmd := makeStackCmd() RootCmd.AddCommand(stackCmd) diff --git a/src/cmd/cli/command/estimate.go b/src/cmd/cli/command/estimate.go index 2f50ec44b..b274d4e52 100644 --- a/src/cmd/cli/command/estimate.go +++ b/src/cmd/cli/command/estimate.go @@ -69,6 +69,7 @@ func makeEstimateCmd() *cobra.Command { var providerDescription = map[client.ProviderID]string{ client.ProviderDefang: "The Defang Playground is a free platform intended for testing purposes only.", + client.ProviderAzure: "Deploy to Azure using the AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_CLIENT_SECRET environment variables or the Azure CLI configuration.", client.ProviderAWS: "Deploy to AWS using the AWS_* environment variables or the AWS CLI configuration.", client.ProviderDO: "Deploy to DigitalOcean using the DIGITALOCEAN_TOKEN, SPACES_ACCESS_KEY_ID, and SPACES_SECRET_ACCESS_KEY environment variables.", client.ProviderGCP: "Deploy to Google Cloud Platform using gcloud Application Default Credentials.", @@ -94,6 +95,8 @@ func interactiveSelectProvider(providers []client.ProviderID) (client.ProviderID defaultOption = client.ProviderDO.String() case pkg.GcpInEnv() != "": defaultOption = client.ProviderGCP.String() + case pkg.AzureInEnv() != "": + defaultOption = client.ProviderAzure.String() } var optionValue string diff --git a/src/cmd/cli/command/recipe.go b/src/cmd/cli/command/recipe.go new file mode 100644 index 000000000..e6e3aca79 --- /dev/null +++ b/src/cmd/cli/command/recipe.go @@ -0,0 +1,93 @@ +package command + +import ( + "errors" + + "github.com/DefangLabs/defang/src/pkg/cli" + "github.com/spf13/cobra" +) + +func makeRecipeCmd() *cobra.Command { + var stackCmd = &cobra.Command{ + Use: "recipe", + Aliases: []string{"recipes", "modes", "mode"}, + Short: "Manage workspace recipes (deployment modes)", + } + recipeListCmd := makeRecipeListCmd() + stackCmd.AddCommand(recipeListCmd) + recipeShowCmd := makeRecipeShowCmd() + stackCmd.AddCommand(recipeShowCmd) + recipeDeactivateCmd := makeRecipeDeactivateCmd() + stackCmd.AddCommand(recipeDeactivateCmd) + recipeActivateCmd := makeRecipeActivateCmd() + stackCmd.AddCommand(recipeActivateCmd) + return stackCmd +} + +func makeRecipeShowCmd() *cobra.Command { + var recipeShowCmd = &cobra.Command{ + Use: "show [RECIPE_NAME]", + Aliases: []string{"get", "describe", "desc"}, + Annotations: authNeededAlways, + Args: cobra.ExactArgs(1), + Short: "Show details of a recipe in the current workspace", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + return cli.RecipeShow(ctx, global.Client, args[0]) + }, + } + return recipeShowCmd +} + +func makeRecipeListCmd() *cobra.Command { + var recipeListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Annotations: authNeededAlways, + Args: cobra.NoArgs, + Short: "List recipes in the current workspace", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + return cli.RecipeList(ctx, global.Client) + }, + } + return recipeListCmd +} + +func makeRecipeDeactivateCmd() *cobra.Command { + var recipeArchiveCmd = &cobra.Command{ + Use: "deactivate [RECIPE_NAME...]", + Aliases: []string{"remove", "rm", "delete", "del", "disable", "archive"}, + Annotations: authNeededAlways, + Args: cobra.MinimumNArgs(1), + Short: "Deactivates a recipe in the current workspace", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + var errs []error + for _, name := range args { + errs = append(errs, cli.RecipeActivate(ctx, global.Client, name, false)) + } + return errors.Join(errs...) + }, + } + return recipeArchiveCmd +} + +func makeRecipeActivateCmd() *cobra.Command { + var recipeUnarchiveCmd = &cobra.Command{ + Use: "activate [RECIPE_NAME...]", + Aliases: []string{"restore", "enable", "undelete", "unarchive"}, + Annotations: authNeededAlways, + Args: cobra.MinimumNArgs(1), + Short: "Activates a recipe in the current workspace", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + var errs []error + for _, name := range args { + errs = append(errs, cli.RecipeActivate(ctx, global.Client, name, true)) + } + return errors.Join(errs...) + }, + } + return recipeUnarchiveCmd +} diff --git a/src/cmd/cli/command/stack.go b/src/cmd/cli/command/stack.go index 5f6b5b40e..c5c8f24b6 100644 --- a/src/cmd/cli/command/stack.go +++ b/src/cmd/cli/command/stack.go @@ -259,7 +259,6 @@ func promptForStackParameters(ctx context.Context, params *stacks.Parameters) er } *params = *newParams - return nil } diff --git a/src/pkg/agent/tools/create_aws_stack.go b/src/pkg/agent/tools/create_aws_stack.go index 9807ef8d7..6cfd6e30a 100644 --- a/src/pkg/agent/tools/create_aws_stack.go +++ b/src/pkg/agent/tools/create_aws_stack.go @@ -18,16 +18,11 @@ type CreateAWSStackParams struct { } func HandleCreateAWSStackTool(ctx context.Context, params CreateAWSStackParams, sc StackConfig) (string, error) { - mode, err := modes.Parse(params.Mode) - if err != nil { - return "Invalid mode provided", err - } - newStack := stacks.Parameters{ Name: params.Name, Region: params.Region, Provider: client.ProviderAWS, - Mode: mode, + Mode: modes.Parse(params.Mode), Variables: map[string]string{ "AWS_PROFILE": params.AWS_Profile, }, diff --git a/src/pkg/agent/tools/create_azure_stack.go b/src/pkg/agent/tools/create_azure_stack.go index c8a886de6..237617cc7 100644 --- a/src/pkg/agent/tools/create_azure_stack.go +++ b/src/pkg/agent/tools/create_azure_stack.go @@ -18,16 +18,11 @@ type CreateAzureStackParams struct { } func HandleCreateAzureStackTool(ctx context.Context, params CreateAzureStackParams, sc StackConfig) (string, error) { - mode, err := modes.Parse(params.Mode) - if err != nil { - return "Invalid mode provided", err - } - newStack := stacks.Parameters{ Name: params.Name, Region: params.Location, Provider: client.ProviderAzure, - Mode: mode, + Mode: modes.Parse(params.Mode), Variables: map[string]string{ "AZURE_SUBSCRIPTION_ID": params.AzureSubscriptionID, }, diff --git a/src/pkg/agent/tools/create_gcp_stack.go b/src/pkg/agent/tools/create_gcp_stack.go index 6c83f8c2c..3cd5ce40e 100644 --- a/src/pkg/agent/tools/create_gcp_stack.go +++ b/src/pkg/agent/tools/create_gcp_stack.go @@ -18,16 +18,11 @@ type CreateGCPStackParams struct { } func HandleCreateGCPStackTool(ctx context.Context, params CreateGCPStackParams, sc StackConfig) (string, error) { - mode, err := modes.Parse(params.Mode) - if err != nil { - return "Invalid mode provided", err - } - newStack := stacks.Parameters{ Name: params.Name, Region: params.Region, Provider: client.ProviderGCP, - Mode: mode, + Mode: modes.Parse(params.Mode), Variables: map[string]string{ "GCP_PROJECT_ID": params.GCPProjectID, }, diff --git a/src/pkg/cli/client/byoc/aws/byoc.go b/src/pkg/cli/client/byoc/aws/byoc.go index 30825cd3a..ce5367609 100644 --- a/src/pkg/cli/client/byoc/aws/byoc.go +++ b/src/pkg/cli/client/byoc/aws/byoc.go @@ -228,6 +228,7 @@ func (b *ByocAws) deploy(ctx context.Context, req *client.DeployRequest, cmd str Mode: req.Mode, PulumiVersion: b.PulumiVersion, Services: serviceInfos, + Recipe: req.Recipe, }) if err != nil { return nil, err diff --git a/src/pkg/cli/client/byoc/azure/byoc.go b/src/pkg/cli/client/byoc/azure/byoc.go index 8e4e80d25..adcfb9e8c 100644 --- a/src/pkg/cli/client/byoc/azure/byoc.go +++ b/src/pkg/cli/client/byoc/azure/byoc.go @@ -510,6 +510,7 @@ func (b *ByocAzure) deploy(ctx context.Context, req *client.DeployRequest, verb Mode: req.Mode, PulumiVersion: b.PulumiVersion, Services: serviceInfos, + Recipe: req.Recipe, }) if err != nil { return nil, err diff --git a/src/pkg/cli/client/byoc/do/byoc.go b/src/pkg/cli/client/byoc/do/byoc.go index bf14f9881..48d03bcc8 100644 --- a/src/pkg/cli/client/byoc/do/byoc.go +++ b/src/pkg/cli/client/byoc/do/byoc.go @@ -165,6 +165,7 @@ func (b *ByocDo) deploy(ctx context.Context, req *client.DeployRequest, cmd stri Mode: req.Mode, PulumiVersion: b.PulumiVersion, Services: serviceInfos, + Recipe: req.Recipe, }) if err != nil { return nil, err diff --git a/src/pkg/cli/client/byoc/gcp/byoc.go b/src/pkg/cli/client/byoc/gcp/byoc.go index 0758dd8cc..6207c4002 100644 --- a/src/pkg/cli/client/byoc/gcp/byoc.go +++ b/src/pkg/cli/client/byoc/gcp/byoc.go @@ -557,6 +557,7 @@ func (b *ByocGcp) deploy(ctx context.Context, req *client.DeployRequest, command Mode: req.Mode, PulumiVersion: b.PulumiVersion, Services: serviceInfos, + Recipe: req.Recipe, }) if err != nil { return nil, err diff --git a/src/pkg/cli/client/client.go b/src/pkg/cli/client/client.go index 4d951816e..433cb443b 100644 --- a/src/pkg/cli/client/client.go +++ b/src/pkg/cli/client/client.go @@ -25,14 +25,17 @@ type FabricClient interface { GetDelegateSubdomainZone(context.Context, *defangv1.GetDelegateSubdomainZoneRequest) (*defangv1.DelegateSubdomainZoneResponse, error) GetFabricClient() defangv1connect.FabricControllerClient GetPlaygroundProjectDomain(context.Context) (*defangv1.GetPlaygroundProjectDomainResponse, error) + GetRecipe(context.Context, *defangv1.GetRecipeRequest) (*defangv1.GetRecipeResponse, error) GetRequestedTenant() types.TenantNameOrID + GetStack(context.Context, *defangv1.GetStackRequest) (*defangv1.GetStackResponse, error) GetTenantName() types.TenantLabel GetVersions(context.Context) (*defangv1.Version, error) ListDeployments(context.Context, *defangv1.ListDeploymentsRequest) (*defangv1.ListDeploymentsResponse, error) + ListRecipes(context.Context) (*defangv1.ListRecipesResponse, error) ListStacks(context.Context, *defangv1.ListStacksRequest) (*defangv1.ListStacksResponse, error) - GetStack(context.Context, *defangv1.GetStackRequest) (*defangv1.GetStackResponse, error) Preview(context.Context, *defangv1.PreviewRequest) (*defangv1.PreviewResponse, error) PutDeployment(context.Context, *defangv1.PutDeploymentRequest) error + PutRecipe(context.Context, *defangv1.PutRecipeRequest) error PutStack(context.Context, *defangv1.PutStackRequest) error ResolveCNAME(context.Context, *defangv1.ResolveCNAMERequest) (*defangv1.ResolveCNAMEResponse, error) ResolveIPAddr(context.Context, *defangv1.ResolveIPAddrRequest) (*defangv1.ResolveIPAddrResponse, error) diff --git a/src/pkg/cli/client/grpc.go b/src/pkg/cli/client/grpc.go index 3f40b8126..2241228a3 100644 --- a/src/pkg/cli/client/grpc.go +++ b/src/pkg/cli/client/grpc.go @@ -222,3 +222,16 @@ func (g GrpcClient) ResolveNS(ctx context.Context, req *defangv1.ResolveNSReques func (g GrpcClient) ResolveTXT(ctx context.Context, req *defangv1.ResolveTXTRequest) (*defangv1.ResolveTXTResponse, error) { return getMsg(g.client.ResolveTXT(ctx, connect.NewRequest(req))) } + +func (g GrpcClient) PutRecipe(ctx context.Context, req *defangv1.PutRecipeRequest) error { + _, err := g.client.PutRecipe(ctx, connect.NewRequest(req)) + return err +} + +func (g GrpcClient) GetRecipe(ctx context.Context, req *defangv1.GetRecipeRequest) (*defangv1.GetRecipeResponse, error) { + return getMsg(g.client.GetRecipe(ctx, connect.NewRequest(req))) +} + +func (g GrpcClient) ListRecipes(ctx context.Context) (*defangv1.ListRecipesResponse, error) { + return getMsg(g.client.ListRecipes(ctx, connect.NewRequest(&defangv1.ListRecipesRequest{}))) +} diff --git a/src/pkg/cli/client/provider.go b/src/pkg/cli/client/provider.go index f87834c35..b8916a57c 100644 --- a/src/pkg/cli/client/provider.go +++ b/src/pkg/cli/client/provider.go @@ -48,6 +48,7 @@ type DeployRequest struct { defangv1.DeployRequest EventsUrl string StatesUrl string + Recipe *defangv1.Recipe } type DeployResponse struct { diff --git a/src/pkg/cli/common.go b/src/pkg/cli/common.go index fc837f8fe..b3fa6991b 100644 --- a/src/pkg/cli/common.go +++ b/src/pkg/cli/common.go @@ -53,6 +53,7 @@ type putDeploymentParams struct { CdType defangv1.CdType CdId string Compose []byte + Recipe *defangv1.Recipe } func putDeploymentAndStack(ctx context.Context, provider client.Provider, fabric client.FabricClient, stack *stacks.Parameters, req putDeploymentParams) error { diff --git a/src/pkg/cli/composeUp.go b/src/pkg/cli/composeUp.go index 145dc47ee..568e1ffcc 100644 --- a/src/pkg/cli/composeUp.go +++ b/src/pkg/cli/composeUp.go @@ -138,6 +138,11 @@ func ComposeUp(ctx context.Context, fabric client.FabricClient, provider client. } } + rresp, err := fabric.GetRecipe(ctx, &defangv1.GetRecipeRequest{Name: mode.String()}) + if err != nil { + return nil, project, fmt.Errorf("failed to get recipe for deployment mode %q: %w", mode, err) + } + deployRequest := &client.DeployRequest{ DeployRequest: defangv1.DeployRequest{ Mode: mode.Value(), @@ -145,6 +150,7 @@ func ComposeUp(ctx context.Context, fabric client.FabricClient, provider client. Compose: composeYaml, DelegateDomain: delegateDomain.Zone, }, + Recipe: rresp.Recipe, } delegation, err := provider.PrepareDomainDelegation(ctx, client.PrepareDomainDelegationRequest{ @@ -207,6 +213,7 @@ func ComposeUp(ctx context.Context, fabric client.FabricClient, provider client. CdType: resp.CdType, CdId: resp.CdId, Compose: composeYaml, + Recipe: deployRequest.Recipe, }) if err != nil { term.Debug("Failed to record deployment:", err) diff --git a/src/pkg/cli/estimate.go b/src/pkg/cli/estimate.go index 4ea2ffd8f..4b691a291 100644 --- a/src/pkg/cli/estimate.go +++ b/src/pkg/cli/estimate.go @@ -119,18 +119,15 @@ func PrintEstimate(mode modes.Mode, estimate *defangv1.EstimateResponse, term *t subtotal := (*money.Money)(estimate.Subtotal) tableItems := prepareEstimateLineItemTableItems(estimate.LineItems) term.Println("") + term.Println("Estimate for Deployment Mode:", mode) switch mode { case modes.ModeAffordable: - term.Println("Estimate for Deployment Mode: AFFORDABLE") term.Println(affordableModeEstimateSummary) case modes.ModeBalanced: - term.Println("Estimate for Deployment Mode: BALANCED") term.Println(balancedModeEstimateSummary) case modes.ModeHighAvailability: - term.Println("Estimate for Deployment Mode: HIGH_AVAILABILITY") term.Println(highAvailabilityModeEstimateSummary) default: - panic("unexpected mode") } term.Table(tableItems, "Cost", "Quantity", "Service", "Description") diff --git a/src/pkg/cli/recipeActivate.go b/src/pkg/cli/recipeActivate.go new file mode 100644 index 000000000..b15c26be3 --- /dev/null +++ b/src/pkg/cli/recipeActivate.go @@ -0,0 +1,33 @@ +package cli + +import ( + "context" + "fmt" + + "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/term" + defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" +) + +func RecipeActivate(ctx context.Context, fabric client.FabricClient, name string, active bool) error { + resp, err := fabric.GetRecipe(ctx, &defangv1.GetRecipeRequest{Name: name}) + if err != nil { + return fmt.Errorf("failed to get recipe: %w", err) + } + + err = fabric.PutRecipe(ctx, &defangv1.PutRecipeRequest{ + Recipe: &defangv1.Recipe{ + Name: resp.Recipe.Name, + PulumiConfig: resp.Recipe.PulumiConfig, + Active: active, + }, + }) + if err == nil { + state := "active" + if !active { + state = "inactive" + } + term.Info(fmt.Sprintf("Recipe %q is now %s.", name, state)) + } + return err +} diff --git a/src/pkg/cli/recipeList.go b/src/pkg/cli/recipeList.go new file mode 100644 index 000000000..22b317fe6 --- /dev/null +++ b/src/pkg/cli/recipeList.go @@ -0,0 +1,23 @@ +package cli + +import ( + "context" + "fmt" + + "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/term" +) + +func RecipeList(ctx context.Context, fabric client.FabricClient) error { + resp, err := fabric.ListRecipes(ctx) + if err != nil { + return fmt.Errorf("failed to list recipes: %w", err) + } + + if len(resp.Recipes) == 0 { + term.Warn("No recipes found in this workspace.") + return nil + } + + return term.Table(resp.Recipes, "Name", "Active") +} diff --git a/src/pkg/cli/recipeShow.go b/src/pkg/cli/recipeShow.go new file mode 100644 index 000000000..bbfee7c94 --- /dev/null +++ b/src/pkg/cli/recipeShow.go @@ -0,0 +1,20 @@ +package cli + +import ( + "context" + "fmt" + + "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/term" + defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" +) + +func RecipeShow(ctx context.Context, fabric client.FabricClient, recipeName string) error { + resp, err := fabric.GetRecipe(ctx, &defangv1.GetRecipeRequest{Name: recipeName}) + if err != nil { + return fmt.Errorf("failed to get recipe: %w", err) + } + + _, err = term.Println(resp.Recipe.PulumiConfig) + return err +} diff --git a/src/pkg/cli/tail_test.go b/src/pkg/cli/tail_test.go index f97c3c54f..854e59dc5 100644 --- a/src/pkg/cli/tail_test.go +++ b/src/pkg/cli/tail_test.go @@ -477,6 +477,9 @@ func fileToStringArray(t *testing.T, fileName string) []string { for scanner.Scan() { expectedLines = append(expectedLines, scanner.Text()) } + if err := scanner.Err(); err != nil { + t.Fatalf("Error scanning file: %v", err) + } return expectedLines } diff --git a/src/pkg/modes/modes.go b/src/pkg/modes/modes.go index 5ccd354ce..9e14b86ad 100644 --- a/src/pkg/modes/modes.go +++ b/src/pkg/modes/modes.go @@ -1,84 +1,65 @@ package modes import ( - "fmt" - "maps" - "slices" "strings" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" ) -type Mode defangv1.DeploymentMode +type Mode string const ( - ModeUnspecified Mode = Mode(defangv1.DeploymentMode_MODE_UNSPECIFIED) - ModeAffordable Mode = Mode(defangv1.DeploymentMode_DEVELOPMENT) - ModeBalanced Mode = Mode(defangv1.DeploymentMode_STAGING) - ModeHighAvailability Mode = Mode(defangv1.DeploymentMode_PRODUCTION) + ModeUnspecified Mode = Mode("") + ModeAffordable Mode = Mode("AFFORDABLE") + ModeBalanced Mode = Mode("BALANCED") + ModeHighAvailability Mode = Mode("HIGH_AVAILABILITY") ) -func (b Mode) String() string { - if b == 0 { - return "" - } - - switch b { - case ModeAffordable: - return "AFFORDABLE" - case ModeBalanced: - return "BALANCED" - case ModeHighAvailability: - return "HIGH_AVAILABILITY" - default: - return fmt.Sprintf("UNKNOWN(%d)", b) - } +func (m Mode) String() string { + return string(m) } -func (b *Mode) Set(s string) error { - mode, err := Parse(s) - if err != nil { - return err - } - *b = mode +func (m *Mode) Set(s string) error { + *m = Parse(s) return nil } -func Parse(str string) (Mode, error) { +func Parse(str string) Mode { upper := strings.ToUpper(str) - mode, ok := defangv1.DeploymentMode_value[upper] - if !ok { - switch upper { - case "": - mode = int32(defangv1.DeploymentMode_MODE_UNSPECIFIED) - case "AFFORDABLE", "CHEAP": - mode = int32(defangv1.DeploymentMode_DEVELOPMENT) - case "BALANCED": - mode = int32(defangv1.DeploymentMode_STAGING) - case "HA", "HIGH_AVAILABILITY", "HIGH-AVAILABILITY": - mode = int32(defangv1.DeploymentMode_PRODUCTION) - default: - return 0, fmt.Errorf("invalid mode: %q, not one of %v", str, AllDeploymentModes()) - } + // Handle legacy aliases + switch upper { + case "CHEAP", "DEVELOPMENT": + return ModeAffordable + case "STAGING": + return ModeBalanced + case "HA", "HIGH-AVAILABILITY", "PRODUCTION": + return ModeHighAvailability } - return Mode(mode), nil + return Mode(upper) } -func (b Mode) Type() string { +func (Mode) Type() string { return "mode" } -func (b Mode) Value() defangv1.DeploymentMode { - return defangv1.DeploymentMode(b) +func (m Mode) Value() defangv1.DeploymentMode { + switch m { + case ModeAffordable: + return defangv1.DeploymentMode_DEVELOPMENT + case ModeBalanced: + return defangv1.DeploymentMode_STAGING + case ModeHighAvailability: + return defangv1.DeploymentMode_PRODUCTION + default: + return defangv1.DeploymentMode_MODE_UNSPECIFIED + } } +// Deprecated: replaced by free-form recipe names, ListRecipes gRPC method func AllDeploymentModes() []string { - var modes []string - for _, i := range slices.Sorted(maps.Keys(defangv1.DeploymentMode_name)) { - if i == 0 { - continue - } - modes = append(modes, Mode(i).String()) + return []string{ + ModeAffordable.String(), + ModeBalanced.String(), + ModeHighAvailability.String(), } - return modes } diff --git a/src/pkg/stacks/stacks.go b/src/pkg/stacks/stacks.go index 28860b374..ff42c59d7 100644 --- a/src/pkg/stacks/stacks.go +++ b/src/pkg/stacks/stacks.go @@ -18,7 +18,7 @@ import ( type Parameters struct { Name string Provider client.ProviderID - Mode modes.Mode + Mode modes.Mode // aka recipe name Region string Variables map[string]string } From 7c041c95f78a6fe8bc721c572ed793aeb8e3a454 Mon Sep 17 00:00:00 2001 From: Lionello Lunesu Date: Mon, 1 Jun 2026 08:48:45 -0700 Subject: [PATCH 3/6] seperate Recipe from Mode --- src/cmd/cli/command/compose.go | 2 +- src/cmd/cli/command/estimate.go | 4 +- src/cmd/cli/command/estimate_test.go | 2 +- src/cmd/cli/command/globals.go | 4 +- src/cmd/cli/command/globals_test.go | 2 +- src/cmd/cli/command/stack_test.go | 18 ++-- src/pkg/agent/tools/create_aws_stack.go | 2 +- src/pkg/agent/tools/create_azure_stack.go | 2 +- src/pkg/agent/tools/create_gcp_stack.go | 2 +- src/pkg/agent/tools/current_stack_test.go | 2 +- src/pkg/agent/tools/default_tool_cli.go | 4 +- src/pkg/agent/tools/deploy.go | 2 +- src/pkg/agent/tools/estimate.go | 10 +-- src/pkg/agent/tools/estimate_test.go | 10 ++- src/pkg/agent/tools/interfaces.go | 4 +- src/pkg/agent/tools/select_stack_test.go | 4 +- src/pkg/agent/tools/services_test.go | 4 +- src/pkg/cli/client/mock.go | 4 + src/pkg/cli/compose/validation.go | 6 +- src/pkg/cli/compose/validation_test.go | 4 +- src/pkg/cli/composeUp.go | 20 +++-- src/pkg/cli/composeUp_dockerfile_test.go | 6 +- src/pkg/cli/composeUp_test.go | 8 +- src/pkg/cli/estimate.go | 19 +++-- src/pkg/cli/preview_test.go | 4 +- src/pkg/cli/stacks.go | 13 +-- src/pkg/cli/stacks_test.go | 16 ++-- src/pkg/modes/mode.go | 85 +++++++++++++++++++ src/pkg/modes/{modes_test.go => mode_test.go} | 26 +++--- src/pkg/modes/modes.go | 65 -------------- src/pkg/modes/recipe.go | 70 +++++++++++++++ src/pkg/session/session.go | 2 +- src/pkg/session/session_test.go | 6 +- src/pkg/stacks/manager.go | 4 +- src/pkg/stacks/manager_test.go | 28 +++--- src/pkg/stacks/selector_test.go | 16 +++- src/pkg/stacks/stacks.go | 15 ++-- src/pkg/stacks/stacks_test.go | 36 ++++---- src/pkg/stacks/wizard.go | 4 +- src/pkg/stacks/wizard_test.go | 14 +++ 40 files changed, 332 insertions(+), 217 deletions(-) create mode 100644 src/pkg/modes/mode.go rename src/pkg/modes/{modes_test.go => mode_test.go} (72%) delete mode 100644 src/pkg/modes/modes.go create mode 100644 src/pkg/modes/recipe.go diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index 53d7167de..db6c9f51a 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -543,7 +543,7 @@ func makeComposeConfigCmd() *cobra.Command { _, _, err = cli.ComposeUp(ctx, global.Client, sessionx.Provider, sessionx.Stack, cli.ComposeUpParams{ Project: project, UploadMode: compose.UploadModeIgnore, - Mode: modes.ModeUnspecified, + Mode: modes.RecipeUnspecified, }) if !errors.Is(err, dryrun.ErrDryRun) { return err diff --git a/src/cmd/cli/command/estimate.go b/src/cmd/cli/command/estimate.go index b274d4e52..7cdf589d4 100644 --- a/src/cmd/cli/command/estimate.go +++ b/src/cmd/cli/command/estimate.go @@ -43,8 +43,8 @@ func makeEstimateCmd() *cobra.Command { var previewProvider client.Provider = &client.PlaygroundProvider{FabricClient: global.Client} // default to development mode if not specified; TODO: when mode is not specified, show an interactive prompt - if global.Stack.Mode == modes.ModeUnspecified { - global.Stack.Mode = modes.ModeAffordable + if global.Stack.Mode == modes.RecipeUnspecified { + global.Stack.Mode = modes.RecipeAffordable } if region == "" { region = client.GetRegion(global.Stack.Provider) // This sets the default region based on the provider diff --git a/src/cmd/cli/command/estimate_test.go b/src/cmd/cli/command/estimate_test.go index a0161bb33..8ce66dcd4 100644 --- a/src/cmd/cli/command/estimate_test.go +++ b/src/cmd/cli/command/estimate_test.go @@ -124,7 +124,7 @@ func TestPrintEstimate(t *testing.T) { } stdout, _ := term.SetupTestTerm(t) - cli.PrintEstimate(modes.ModeAffordable, estimate, term.DefaultTerm) + cli.PrintEstimate(modes.RecipeAffordable, estimate, term.DefaultTerm) expectedOutput := ` Estimate for Deployment Mode: AFFORDABLE diff --git a/src/cmd/cli/command/globals.go b/src/cmd/cli/command/globals.go index cf1b88ed6..141ffa1ff 100644 --- a/src/cmd/cli/command/globals.go +++ b/src/cmd/cli/command/globals.go @@ -107,7 +107,7 @@ func NewGlobalConfig() *GlobalConfig { } } - mode := modes.ModeUnspecified + mode := modes.RecipeUnspecified if fromEnv, ok := os.LookupEnv("DEFANG_MODE"); ok { err := mode.Set(fromEnv) if err != nil { @@ -158,7 +158,7 @@ func (global *GlobalConfig) ToMap() map[string]string { if regionVarName != "" && global.Stack.Region != "" { m[regionVarName] = global.Stack.Region } - if global.Stack.Mode != modes.ModeUnspecified { + if global.Stack.Mode != modes.RecipeUnspecified { m["DEFANG_MODE"] = global.Stack.Mode.String() } m["DEFANG_VERBOSE"] = strconv.FormatBool(global.Verbose) diff --git a/src/cmd/cli/command/globals_test.go b/src/cmd/cli/command/globals_test.go index 2d61501cd..2be04f4fa 100644 --- a/src/cmd/cli/command/globals_test.go +++ b/src/cmd/cli/command/globals_test.go @@ -23,7 +23,7 @@ func Test_configurationPrecedence(t *testing.T) { HideUpdate: false, NonInteractive: false, // set to false just for test instead of !term.IsTerminal() for consistency Verbose: false, - Stack: stacks.Parameters{Provider: client.ProviderAuto, Mode: modes.ModeUnspecified}, + Stack: stacks.Parameters{Provider: client.ProviderAuto, Mode: modes.RecipeUnspecified}, FabricAddr: "", Tenant: "", } diff --git a/src/cmd/cli/command/stack_test.go b/src/cmd/cli/command/stack_test.go index ab6ad26c7..e393eed3f 100644 --- a/src/cmd/cli/command/stack_test.go +++ b/src/cmd/cli/command/stack_test.go @@ -102,13 +102,13 @@ func TestStackListCmd(t *testing.T) { Name: "teststack1", Provider: client.ProviderAWS, Region: "us-test-2", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, }, { Name: "teststack2", Provider: client.ProviderGCP, Region: "us-central1", - Mode: modes.ModeBalanced, + Mode: modes.RecipeBalanced, }, }, expectOutput: "NAME DEFAULT PROVIDER REGION ACCOUNT MODE DEPLOYEDAT\n" + @@ -165,7 +165,7 @@ func TestStackNewCmd(t *testing.T) { Name: "teststack", Provider: client.ProviderAWS, Region: "us-test-2", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, }, existingStacks: []*defangv1.Stack{}, }, @@ -175,7 +175,7 @@ func TestStackNewCmd(t *testing.T) { Name: "", Provider: client.ProviderAWS, Region: "us-test-2", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, }, existingStacks: []*defangv1.Stack{}, expectErr: "invalid stack name", @@ -186,7 +186,7 @@ func TestStackNewCmd(t *testing.T) { Name: "existingstack", Provider: client.ProviderAWS, Region: "us-test-2", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, }, existingStacks: []*defangv1.Stack{{Name: "existingstack", Project: ""}}, }, @@ -197,7 +197,7 @@ func TestStackNewCmd(t *testing.T) { Name: "existingstack", Provider: client.ProviderAWS, Region: "us-test-2", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, }, existingStacks: []*defangv1.Stack{{Name: "existingstack", Project: ""}}, expectErr: "already exists", @@ -238,7 +238,7 @@ func TestLoadStackEnv(t *testing.T) { parameters: stacks.Parameters{ Provider: client.ProviderAWS, Region: "us-west-2", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, Variables: map[string]string{ "AWS_PROFILE": "default", }, @@ -255,7 +255,7 @@ func TestLoadStackEnv(t *testing.T) { parameters: stacks.Parameters{ Provider: client.ProviderGCP, Region: "us-central1", - Mode: modes.ModeBalanced, + Mode: modes.RecipeBalanced, Variables: map[string]string{ "GCP_PROJECT_ID": "my-gcp-project", "DEFANG_PREFIX": "test", @@ -276,7 +276,7 @@ func TestLoadStackEnv(t *testing.T) { parameters: stacks.Parameters{ Provider: client.ProviderAWS, Region: "us-west-2", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, Variables: map[string]string{ "AWS_PROFILE": "default", "DEFANG_PREFIX": "test", diff --git a/src/pkg/agent/tools/create_aws_stack.go b/src/pkg/agent/tools/create_aws_stack.go index 6cfd6e30a..235d01d1f 100644 --- a/src/pkg/agent/tools/create_aws_stack.go +++ b/src/pkg/agent/tools/create_aws_stack.go @@ -22,7 +22,7 @@ func HandleCreateAWSStackTool(ctx context.Context, params CreateAWSStackParams, Name: params.Name, Region: params.Region, Provider: client.ProviderAWS, - Mode: modes.Parse(params.Mode), + Mode: modes.Recipe(params.Mode), Variables: map[string]string{ "AWS_PROFILE": params.AWS_Profile, }, diff --git a/src/pkg/agent/tools/create_azure_stack.go b/src/pkg/agent/tools/create_azure_stack.go index 237617cc7..519bd02d3 100644 --- a/src/pkg/agent/tools/create_azure_stack.go +++ b/src/pkg/agent/tools/create_azure_stack.go @@ -22,7 +22,7 @@ func HandleCreateAzureStackTool(ctx context.Context, params CreateAzureStackPara Name: params.Name, Region: params.Location, Provider: client.ProviderAzure, - Mode: modes.Parse(params.Mode), + Mode: modes.Recipe(params.Mode), Variables: map[string]string{ "AZURE_SUBSCRIPTION_ID": params.AzureSubscriptionID, }, diff --git a/src/pkg/agent/tools/create_gcp_stack.go b/src/pkg/agent/tools/create_gcp_stack.go index 3cd5ce40e..0ca810d73 100644 --- a/src/pkg/agent/tools/create_gcp_stack.go +++ b/src/pkg/agent/tools/create_gcp_stack.go @@ -22,7 +22,7 @@ func HandleCreateGCPStackTool(ctx context.Context, params CreateGCPStackParams, Name: params.Name, Region: params.Region, Provider: client.ProviderGCP, - Mode: modes.Parse(params.Mode), + Mode: modes.Recipe(params.Mode), Variables: map[string]string{ "GCP_PROJECT_ID": params.GCPProjectID, }, diff --git a/src/pkg/agent/tools/current_stack_test.go b/src/pkg/agent/tools/current_stack_test.go index fa2bdacbe..3187bae5a 100644 --- a/src/pkg/agent/tools/current_stack_test.go +++ b/src/pkg/agent/tools/current_stack_test.go @@ -23,7 +23,7 @@ func TestHandleCurrentStackTool(t *testing.T) { Name: "test-stack", Provider: client.ProviderAWS, Region: "us-test-2", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, Variables: map[string]string{ "AWS_PROFILE": "default", }, diff --git a/src/pkg/agent/tools/default_tool_cli.go b/src/pkg/agent/tools/default_tool_cli.go index 8619eed17..412f223a6 100644 --- a/src/pkg/agent/tools/default_tool_cli.go +++ b/src/pkg/agent/tools/default_tool_cli.go @@ -38,7 +38,7 @@ func (DefaultToolCLI) ConfigSet(ctx context.Context, projectName string, provide return err } -func (DefaultToolCLI) RunEstimate(ctx context.Context, project *compose.Project, fabric *client.GrpcClient, provider client.Provider, providerId client.ProviderID, region string, mode modes.Mode) (*defangv1.EstimateResponse, error) { +func (DefaultToolCLI) RunEstimate(ctx context.Context, project *compose.Project, fabric *client.GrpcClient, provider client.Provider, providerId client.ProviderID, region string, mode modes.Recipe) (*defangv1.EstimateResponse, error) { return cli.RunEstimate(ctx, project, fabric, provider, providerId, region, mode) } @@ -76,7 +76,7 @@ func (DefaultToolCLI) GetServices(ctx context.Context, projectName string, provi return cli.GetServices(ctx, projectName, provider) } -func (DefaultToolCLI) PrintEstimate(mode modes.Mode, estimate *defangv1.EstimateResponse) string { +func (DefaultToolCLI) PrintEstimate(mode modes.Recipe, estimate *defangv1.EstimateResponse) string { stdout := new(bytes.Buffer) captureTerm := term.NewTerm( os.Stdin, diff --git a/src/pkg/agent/tools/deploy.go b/src/pkg/agent/tools/deploy.go index fdbf0e3a9..8e856e896 100644 --- a/src/pkg/agent/tools/deploy.go +++ b/src/pkg/agent/tools/deploy.go @@ -64,7 +64,7 @@ func HandleDeployTool(ctx context.Context, loader client.Loader, params DeployPa deployResp, project, err := cli.ComposeUp(ctx, client, provider, sc.Stack, cliTypes.ComposeUpParams{ Project: project, UploadMode: compose.UploadModeDigest, - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, }) if err != nil { err = fmt.Errorf("failed to compose up services: %w", err) diff --git a/src/pkg/agent/tools/estimate.go b/src/pkg/agent/tools/estimate.go index 9981479b3..1234a52e8 100644 --- a/src/pkg/agent/tools/estimate.go +++ b/src/pkg/agent/tools/estimate.go @@ -45,20 +45,16 @@ func HandleEstimateTool(ctx context.Context, loader client.Loader, params Estima return "", err } - var deploymentMode modes.Mode - err = deploymentMode.Set(params.DeploymentMode) - if err != nil { - return "", err - } + recipe := modes.Recipe(params.DeploymentMode) term.Debug("Function invoked: cli.RunEstimate") - estimate, err := cli.RunEstimate(ctx, project, fabric, defangProvider, providerID, params.Region, deploymentMode) + estimate, err := cli.RunEstimate(ctx, project, fabric, defangProvider, providerID, params.Region, recipe) if err != nil { return "", fmt.Errorf("failed to run estimate: %w", err) } term.Debugf("Estimate: %+v", estimate) - estimateText := cli.PrintEstimate(deploymentMode, estimate) + estimateText := cli.PrintEstimate(recipe, estimate) return "Successfully estimated the cost of the project to " + providerID.Name() + ":\n" + estimateText, nil } diff --git a/src/pkg/agent/tools/estimate_test.go b/src/pkg/agent/tools/estimate_test.go index 65807058e..8d7446acd 100644 --- a/src/pkg/agent/tools/estimate_test.go +++ b/src/pkg/agent/tools/estimate_test.go @@ -46,7 +46,7 @@ func (m *MockEstimateCLI) LoadProject(ctx context.Context, loader client.Loader) return m.Project, nil } -func (m *MockEstimateCLI) RunEstimate(ctx context.Context, project *compose.Project, grpcClient *client.GrpcClient, provider client.Provider, providerId client.ProviderID, region string, mode modes.Mode) (*defangv1.EstimateResponse, error) { +func (m *MockEstimateCLI) RunEstimate(ctx context.Context, project *compose.Project, grpcClient *client.GrpcClient, provider client.Provider, providerId client.ProviderID, region string, mode modes.Recipe) (*defangv1.EstimateResponse, error) { projectName := "" if project != nil { projectName = project.Name @@ -63,7 +63,7 @@ func (m *MockEstimateCLI) CreatePlaygroundProvider(grpcClient *client.GrpcClient return nil } -func (m *MockEstimateCLI) PrintEstimate(mode modes.Mode, estimate *defangv1.EstimateResponse) string { +func (m *MockEstimateCLI) PrintEstimate(mode modes.Recipe, estimate *defangv1.EstimateResponse) string { m.CallLog = append(m.CallLog, fmt.Sprintf("PrintEstimate(%s)", mode.String())) return m.CapturedOutput } @@ -85,7 +85,9 @@ func TestHandleEstimateTool(t *testing.T) { expectedError string }{ { - name: "unknown_deployment_mode_fails", + // Deployment modes are now free-form recipe names, so an unrecognized + // value is accepted (uppercased) rather than rejected. + name: "free_form_deployment_mode_accepted", arguments: map[string]interface{}{ "provider": "aws", "deployment_mode": "unknown-mode", @@ -103,7 +105,7 @@ func TestHandleEstimateTool(t *testing.T) { } m.CapturedOutput = "Estimated cost: $15.00/month" }, - expectedError: "invalid mode: \"unknown-mode\", not one of [AFFORDABLE BALANCED HIGH_AVAILABILITY]", + expectedTextContains: "Successfully estimated the cost of the project to AWS", }, { name: "load_project_error", diff --git a/src/pkg/agent/tools/interfaces.go b/src/pkg/agent/tools/interfaces.go index d6ad43636..8b389574f 100644 --- a/src/pkg/agent/tools/interfaces.go +++ b/src/pkg/agent/tools/interfaces.go @@ -27,7 +27,7 @@ type CLIInterface interface { LoadProject(ctx context.Context, loader client.Loader) (*compose.Project, error) LoadProjectNameWithFallback(ctx context.Context, loader client.Loader, provider client.Provider) (string, error) NewProvider(ctx context.Context, providerId client.ProviderID, client client.FabricClient, stack string) client.Provider - PrintEstimate(mode modes.Mode, estimate *defangv1.EstimateResponse) string - RunEstimate(ctx context.Context, project *compose.Project, fabric *client.GrpcClient, provider client.Provider, providerId client.ProviderID, region string, mode modes.Mode) (*defangv1.EstimateResponse, error) + PrintEstimate(mode modes.Recipe, estimate *defangv1.EstimateResponse) string + RunEstimate(ctx context.Context, project *compose.Project, fabric *client.GrpcClient, provider client.Provider, providerId client.ProviderID, region string, mode modes.Recipe) (*defangv1.EstimateResponse, error) Tail(ctx context.Context, provider client.Provider, projectName string, options cli.TailOptions) error } diff --git a/src/pkg/agent/tools/select_stack_test.go b/src/pkg/agent/tools/select_stack_test.go index 4782557d0..7f7825544 100644 --- a/src/pkg/agent/tools/select_stack_test.go +++ b/src/pkg/agent/tools/select_stack_test.go @@ -32,7 +32,7 @@ func TestHandleSelectStackTool(t *testing.T) { Name: "placeholder", Provider: client.ProviderGCP, Region: "placeholder", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, }, expectedResult: "Stack \"test-stack\" selected.", expectedError: false, @@ -49,7 +49,7 @@ func TestHandleSelectStackTool(t *testing.T) { Name: "old-stack", Provider: client.ProviderGCP, Region: "us-central1", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, Variables: map[string]string{ "GCP_PROJECT_ID": "old-project", }, diff --git a/src/pkg/agent/tools/services_test.go b/src/pkg/agent/tools/services_test.go index c1c2beb04..a86873fe4 100644 --- a/src/pkg/agent/tools/services_test.go +++ b/src/pkg/agent/tools/services_test.go @@ -102,11 +102,11 @@ func (m *MockCLI) LoadProject(ctx context.Context, loader client.Loader) (*compo return nil, nil } -func (m *MockCLI) PrintEstimate(mode modes.Mode, estimate *defangv1.EstimateResponse) string { +func (m *MockCLI) PrintEstimate(mode modes.Recipe, estimate *defangv1.EstimateResponse) string { return "" } -func (m *MockCLI) RunEstimate(ctx context.Context, project *compose.Project, fabric *client.GrpcClient, provider client.Provider, providerId client.ProviderID, region string, mode modes.Mode) (*defangv1.EstimateResponse, error) { +func (m *MockCLI) RunEstimate(ctx context.Context, project *compose.Project, fabric *client.GrpcClient, provider client.Provider, providerId client.ProviderID, region string, mode modes.Recipe) (*defangv1.EstimateResponse, error) { return nil, nil } diff --git a/src/pkg/cli/client/mock.go b/src/pkg/cli/client/mock.go index dab0cf0ca..487473b02 100644 --- a/src/pkg/cli/client/mock.go +++ b/src/pkg/cli/client/mock.go @@ -237,6 +237,10 @@ func (m MockFabricClient) PutDeployment(ctx context.Context, req *defangv1.PutDe return nil } +func (m MockFabricClient) GetRecipe(ctx context.Context, req *defangv1.GetRecipeRequest) (*defangv1.GetRecipeResponse, error) { + return &defangv1.GetRecipeResponse{Recipe: &defangv1.Recipe{Name: req.GetName()}}, nil +} + func (m MockFabricClient) ListDeployments(ctx context.Context, req *defangv1.ListDeploymentsRequest) (*defangv1.ListDeploymentsResponse, error) { return &defangv1.ListDeploymentsResponse{ Deployments: []*defangv1.Deployment{ diff --git a/src/pkg/cli/compose/validation.go b/src/pkg/cli/compose/validation.go index b8f4e35ce..91f015857 100644 --- a/src/pkg/cli/compose/validation.go +++ b/src/pkg/cli/compose/validation.go @@ -30,7 +30,7 @@ func (e ErrMissingConfig) Error() string { var ErrDockerfileNotFound = errors.New("dockerfile not found") -func ValidateProject(project *composeTypes.Project, mode modes.Mode) error { +func ValidateProject(project *composeTypes.Project, mode modes.Recipe) error { if project == nil { return errors.New("no project found") } @@ -57,7 +57,7 @@ func ValidateProject(project *composeTypes.Project, mode modes.Mode) error { return errors.Join(errs...) } -func validateService(svccfg *composeTypes.ServiceConfig, project *composeTypes.Project, mode modes.Mode) error { +func validateService(svccfg *composeTypes.ServiceConfig, project *composeTypes.Project, mode modes.Recipe) error { if svccfg.ReadOnly { term.Debugf("service %q: unsupported compose directive: read_only", svccfg.Name) } @@ -291,7 +291,7 @@ func validateService(svccfg *composeTypes.ServiceConfig, project *composeTypes.P replicas = *svccfg.Deploy.Replicas } } - if mode == modes.ModeHighAvailability && replicas < 2 && svccfg.Extensions["x-defang-autoscaling"] == nil { + if mode == modes.RecipeHighAvailability && replicas < 2 && svccfg.Extensions["x-defang-autoscaling"] == nil { term.Warnf("service %q: high-availability mode requires at least 2 replicas or x-defang-autoscaling", svccfg.Name) } if reservations == nil || reservations.MemoryBytes == 0 { diff --git a/src/pkg/cli/compose/validation_test.go b/src/pkg/cli/compose/validation_test.go index cd58976f3..e52066605 100644 --- a/src/pkg/cli/compose/validation_test.go +++ b/src/pkg/cli/compose/validation_test.go @@ -65,9 +65,9 @@ func TestValidationAndConvert(t *testing.T) { logs.WriteString("Error: " + err.Error() + "\n") } - mode := modes.ModeAffordable + mode := modes.RecipeAffordable if strings.Contains(path, "replicas") { - mode = modes.ModeHighAvailability + mode = modes.RecipeHighAvailability } if err := ValidateProject(project, mode); err != nil { t.Logf("Project validation failed: %v", err) diff --git a/src/pkg/cli/composeUp.go b/src/pkg/cli/composeUp.go index 568e1ffcc..7c2aed942 100644 --- a/src/pkg/cli/composeUp.go +++ b/src/pkg/cli/composeUp.go @@ -25,7 +25,7 @@ func (e ComposeError) Unwrap() error { type ComposeUpParams struct { Project *compose.Project UploadMode compose.UploadMode - Mode modes.Mode + Mode modes.Recipe } func checkDeploymentMode(prevMode, newMode modes.Mode) (modes.Mode, error) { @@ -68,7 +68,7 @@ func checkDeploymentMode(prevMode, newMode modes.Mode) (modes.Mode, error) { func ComposeUp(ctx context.Context, fabric client.FabricClient, provider client.Provider, stack *stacks.Parameters, params ComposeUpParams) (*defangv1.DeployResponse, *compose.Project, error) { upload := params.UploadMode project := params.Project - mode := params.Mode + recipe := params.Mode if dryrun.DoDryRun { upload = compose.UploadModeIgnore @@ -100,7 +100,7 @@ func ComposeUp(ctx context.Context, fabric client.FabricClient, provider client. return nil, project, err } - if err := compose.ValidateProject(fixedProject, mode); err != nil { + if err := compose.ValidateProject(fixedProject, recipe); err != nil { return nil, project, &ComposeError{err} } @@ -130,22 +130,24 @@ func ComposeUp(ctx context.Context, fabric client.FabricClient, provider client. } // New project, no previous deployment mode to check } else { - prevMode := modes.Mode(prevUpdate.Mode) - mode, err = checkDeploymentMode(prevMode, mode) + newMode, err := checkDeploymentMode(modes.Mode(prevUpdate.Mode), recipe.Mode()) // Ignore mode compatibility errors in estimation mode if err != nil && upload != compose.UploadModeEstimate { return nil, project, err } + if newMode != modes.ModeUnspecified { + recipe = modes.FromMode(newMode.Value()) + } } - rresp, err := fabric.GetRecipe(ctx, &defangv1.GetRecipeRequest{Name: mode.String()}) + rresp, err := fabric.GetRecipe(ctx, &defangv1.GetRecipeRequest{Name: recipe.String()}) if err != nil { - return nil, project, fmt.Errorf("failed to get recipe for deployment mode %q: %w", mode, err) + return nil, project, fmt.Errorf("failed to get recipe for deployment mode %q: %w", recipe, err) } deployRequest := &client.DeployRequest{ DeployRequest: defangv1.DeployRequest{ - Mode: mode.Value(), + Mode: recipe.Mode().Value(), Project: project.Name, Compose: composeYaml, DelegateDomain: delegateDomain.Zone, @@ -205,7 +207,7 @@ func ComposeUp(ctx context.Context, fabric client.FabricClient, provider client. err = putDeploymentAndStack(ctx, provider, fabric, stack, putDeploymentParams{ Action: action, ETag: resp.Etag, - Mode: mode.Value(), + Mode: recipe.Mode().Value(), ProjectName: project.Name, StatesUrl: statesUrl, EventsUrl: eventsUrl, diff --git a/src/pkg/cli/composeUp_dockerfile_test.go b/src/pkg/cli/composeUp_dockerfile_test.go index c51e63484..c8864ebed 100644 --- a/src/pkg/cli/composeUp_dockerfile_test.go +++ b/src/pkg/cli/composeUp_dockerfile_test.go @@ -53,7 +53,7 @@ func TestComposeUp_DockerfileValidation(t *testing.T) { _, _, err = ComposeUp(ctx, mockFabric, mockProvider, stack, ComposeUpParams{ Project: project, UploadMode: compose.UploadModeDigest, // This should trigger validation - Mode: modes.ModeUnspecified, + Mode: modes.RecipeUnspecified, }) if tt.expectError { @@ -96,7 +96,7 @@ func TestComposeUp_DockerfileValidationSkipped(t *testing.T) { _, _, err = ComposeUp(ctx, mockFabric, mockProvider, stack, ComposeUpParams{ Project: project, UploadMode: compose.UploadModeIgnore, // Should skip validation - Mode: modes.ModeUnspecified, + Mode: modes.RecipeUnspecified, }) // Should not get Dockerfile validation error @@ -108,7 +108,7 @@ func TestComposeUp_DockerfileValidationSkipped(t *testing.T) { _, _, err = ComposeUp(ctx, mockFabric, mockProvider, stack, ComposeUpParams{ Project: project, UploadMode: compose.UploadModeEstimate, // Should skip validation - Mode: modes.ModeUnspecified, + Mode: modes.RecipeUnspecified, }) // Should not get Dockerfile validation error diff --git a/src/pkg/cli/composeUp_test.go b/src/pkg/cli/composeUp_test.go index eb5dd8273..ecac4a7c0 100644 --- a/src/pkg/cli/composeUp_test.go +++ b/src/pkg/cli/composeUp_test.go @@ -129,7 +129,7 @@ func TestComposeUp(t *testing.T) { t.Run("happy path", func(t *testing.T) { d, project, err := ComposeUp(t.Context(), mc, mp, stack, ComposeUpParams{ - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, Project: proj, UploadMode: compose.UploadModeDigest, }) @@ -157,7 +157,7 @@ func TestComposeUp(t *testing.T) { Mode: defangv1.DeploymentMode_PRODUCTION, } _, _, err = ComposeUp(t.Context(), mc, mp, stack, ComposeUpParams{ - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, Project: proj, UploadMode: compose.UploadModeDigest, }) @@ -334,7 +334,7 @@ func TestComposeUpStops(t *testing.T) { } resp, project, err := ComposeUp(ctx, fabric, provider, stack, ComposeUpParams{ - Mode: modes.ModeUnspecified, + Mode: modes.RecipeUnspecified, Project: project, UploadMode: compose.UploadModeDigest, }) @@ -369,7 +369,7 @@ func TestComposeConfigWithoutLogin(t *testing.T) { stack := &stacks.Parameters{} _, _, err := ComposeUp(t.Context(), fabric, provider, stack, ComposeUpParams{ - Mode: modes.ModeUnspecified, + Mode: modes.RecipeUnspecified, Project: project, UploadMode: compose.UploadModeIgnore, }) diff --git a/src/pkg/cli/estimate.go b/src/pkg/cli/estimate.go index 4b691a291..2db9daa52 100644 --- a/src/pkg/cli/estimate.go +++ b/src/pkg/cli/estimate.go @@ -20,9 +20,9 @@ import ( defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" ) -func RunEstimate(ctx context.Context, project *compose.Project, client client.FabricClient, previewProvider client.Provider, estimateProviderID client.ProviderID, region string, mode modes.Mode) (*defangv1.EstimateResponse, error) { - term.Debugf("Running estimate for project %s in region %s with mode %s", project.Name, region, mode) - preview, err := GeneratePreview(ctx, project, client, previewProvider, estimateProviderID, mode, region) +func RunEstimate(ctx context.Context, project *compose.Project, client client.FabricClient, previewProvider client.Provider, estimateProviderID client.ProviderID, region string, recipe modes.Recipe) (*defangv1.EstimateResponse, error) { + term.Debugf("Running estimate for project %s in region %s with mode %s", project.Name, region, recipe) + preview, err := GeneratePreview(ctx, project, client, previewProvider, estimateProviderID, recipe, region) if err != nil { return nil, err } @@ -40,7 +40,7 @@ func RunEstimate(ctx context.Context, project *compose.Project, client client.Fa return estimate, nil } -func GeneratePreview(ctx context.Context, project *compose.Project, client client.FabricClient, previewProvider client.Provider, estimateProviderID client.ProviderID, mode modes.Mode, region string) (string, error) { +func GeneratePreview(ctx context.Context, project *compose.Project, client client.FabricClient, previewProvider client.Provider, estimateProviderID client.ProviderID, recipe modes.Recipe, region string) (string, error) { os.Setenv("DEFANG_JSON", "1") // HACK: always show JSON output for estimate since := time.Now().Add(-1 * time.Minute) // fetch logs since one minute ago to account for clock drift @@ -56,9 +56,10 @@ func GeneratePreview(ctx context.Context, project *compose.Project, client clien term.Debugf("Fixedup project: %s", string(composeData)) + // TODO: this will need to read the recipe from Fabric first resp, err := client.Preview(ctx, &defangv1.PreviewRequest{ Provider: estimateProviderID.Value(), - Mode: mode.Value(), + Mode: recipe.Mode().Value(), Region: region, Compose: composeData, ProjectName: project.Name, @@ -115,17 +116,17 @@ Databases will be provisioned using resources optimized for production. Services in the "internal" network will be deployed to a private subnet with a NAT gateway for outbound internet access.` -func PrintEstimate(mode modes.Mode, estimate *defangv1.EstimateResponse, term *term.Term) { +func PrintEstimate(mode modes.Recipe, estimate *defangv1.EstimateResponse, term *term.Term) { subtotal := (*money.Money)(estimate.Subtotal) tableItems := prepareEstimateLineItemTableItems(estimate.LineItems) term.Println("") term.Println("Estimate for Deployment Mode:", mode) switch mode { - case modes.ModeAffordable: + case modes.RecipeAffordable: term.Println(affordableModeEstimateSummary) - case modes.ModeBalanced: + case modes.RecipeBalanced: term.Println(balancedModeEstimateSummary) - case modes.ModeHighAvailability: + case modes.RecipeHighAvailability: term.Println(highAvailabilityModeEstimateSummary) default: } diff --git a/src/pkg/cli/preview_test.go b/src/pkg/cli/preview_test.go index ef45449b9..bf4491d37 100644 --- a/src/pkg/cli/preview_test.go +++ b/src/pkg/cli/preview_test.go @@ -45,7 +45,7 @@ func TestPreviewStops(t *testing.T) { deploymentStatus: tt.err, } - err := Preview(t.Context(), project, fabric, provider, ComposeUpParams{Mode: modes.ModeUnspecified, Project: project}) + err := Preview(t.Context(), project, fabric, provider, ComposeUpParams{Mode: modes.RecipeUnspecified, Project: project}) if err != nil { if err.Error() != tt.wantError { t.Errorf("got error: %v, want: %v", err, tt.wantError) @@ -67,7 +67,7 @@ func TestPreviewStops(t *testing.T) { provider := &mockDeployProvider{} - err := Preview(ctx, project, fabric, provider, ComposeUpParams{Mode: modes.ModeUnspecified, Project: project}) + err := Preview(ctx, project, fabric, provider, ComposeUpParams{Mode: modes.RecipeUnspecified, Project: project}) if err != nil { t.Errorf("got error: %v, want nil", err) } diff --git a/src/pkg/cli/stacks.go b/src/pkg/cli/stacks.go index eb3e78c5b..2a7f79f99 100644 --- a/src/pkg/cli/stacks.go +++ b/src/pkg/cli/stacks.go @@ -40,11 +40,14 @@ func SetDefaultStack(ctx context.Context, stacksPutter StacksPutter, stacksLoade err = stacksPutter.PutStack(ctx, &defangv1.PutStackRequest{ Stack: &defangv1.Stack{ - Name: stack.Name, - Project: projectName, - Provider: stack.Provider.Value(), - Region: stack.Region, - Mode: stack.Mode.Value(), + Name: stack.Name, + Project: projectName, + Provider: stack.Provider.Value(), + Region: stack.Region, + Mode: stack.Mode.Mode().Value(), + Recipe: &defangv1.Recipe{ + Name: stack.Mode.String(), + }, IsDefault: true, StackFile: []byte(stackFile), }, diff --git a/src/pkg/cli/stacks_test.go b/src/pkg/cli/stacks_test.go index a4afbe513..26c7fdf70 100644 --- a/src/pkg/cli/stacks_test.go +++ b/src/pkg/cli/stacks_test.go @@ -116,7 +116,7 @@ func TestSetDefaultStack(t *testing.T) { Name: "test-stack", Provider: client.ProviderAWS, Region: "us-west-2", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, }, loadErr: nil, putErr: nil, @@ -139,7 +139,7 @@ func TestSetDefaultStack(t *testing.T) { Name: "test-stack", Provider: client.ProviderAWS, Region: "us-west-2", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, }, loadErr: nil, putErr: assert.AnError, @@ -162,7 +162,7 @@ func TestSetDefaultStack(t *testing.T) { Name: "test-stack", Provider: client.ProviderAWS, Region: "us-west-2", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, }, loadErr: nil, putErr: nil, @@ -214,7 +214,7 @@ func TestRemoveStack(t *testing.T) { t.Run("no deployments deletes without confirmation", func(t *testing.T) { t.Chdir(t.TempDir()) - _, err := stacks.CreateInDirectory(".", stacks.Parameters{Name: "mystack", Provider: client.ProviderAWS, Region: "us-east-1", Mode: modes.ModeAffordable}) + _, err := stacks.CreateInDirectory(".", stacks.Parameters{Name: "mystack", Provider: client.ProviderAWS, Region: "us-east-1", Mode: modes.RecipeAffordable}) assert.NoError(t, err) remover := &mockStacksRemover{} @@ -229,7 +229,7 @@ func TestRemoveStack(t *testing.T) { t.Run("last deployment is down, deletes without confirmation", func(t *testing.T) { t.Chdir(t.TempDir()) - _, err := stacks.CreateInDirectory(".", stacks.Parameters{Name: "mystack", Provider: client.ProviderAWS, Region: "us-east-1", Mode: modes.ModeAffordable}) + _, err := stacks.CreateInDirectory(".", stacks.Parameters{Name: "mystack", Provider: client.ProviderAWS, Region: "us-east-1", Mode: modes.RecipeAffordable}) assert.NoError(t, err) remover := &mockStacksRemover{} @@ -267,7 +267,7 @@ func TestRemoveStack(t *testing.T) { t.Run("last deployment is up, user confirms", func(t *testing.T) { t.Chdir(t.TempDir()) - _, err := stacks.CreateInDirectory(".", stacks.Parameters{Name: "mystack", Provider: client.ProviderAWS, Region: "us-east-1", Mode: modes.ModeAffordable}) + _, err := stacks.CreateInDirectory(".", stacks.Parameters{Name: "mystack", Provider: client.ProviderAWS, Region: "us-east-1", Mode: modes.RecipeAffordable}) assert.NoError(t, err) remover := &mockStacksRemover{} @@ -295,7 +295,7 @@ func TestRemoveStack(t *testing.T) { t.Run("force with active deployment skips confirmation and deletes", func(t *testing.T) { t.Chdir(t.TempDir()) - _, err := stacks.CreateInDirectory(".", stacks.Parameters{Name: "mystack", Provider: client.ProviderAWS, Region: "us-east-1", Mode: modes.ModeAffordable}) + _, err := stacks.CreateInDirectory(".", stacks.Parameters{Name: "mystack", Provider: client.ProviderAWS, Region: "us-east-1", Mode: modes.RecipeAffordable}) assert.NoError(t, err) remover := &mockStacksRemover{} @@ -321,7 +321,7 @@ func TestRemoveStack(t *testing.T) { t.Run("passes correct project and stack to DeleteStack", func(t *testing.T) { t.Chdir(t.TempDir()) - _, err := stacks.CreateInDirectory(".", stacks.Parameters{Name: "beta", Provider: client.ProviderAWS, Region: "us-east-1", Mode: modes.ModeAffordable}) + _, err := stacks.CreateInDirectory(".", stacks.Parameters{Name: "beta", Provider: client.ProviderAWS, Region: "us-east-1", Mode: modes.RecipeAffordable}) assert.NoError(t, err) remover := &mockStacksRemover{} diff --git a/src/pkg/modes/mode.go b/src/pkg/modes/mode.go new file mode 100644 index 000000000..f7023c6aa --- /dev/null +++ b/src/pkg/modes/mode.go @@ -0,0 +1,85 @@ +package modes + +import ( + "fmt" + "maps" + "slices" + "strings" + + defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" +) + +// Deprecated: use Recipe instead of deployment Mode +type Mode defangv1.DeploymentMode + +const ( + ModeUnspecified Mode = Mode(defangv1.DeploymentMode_MODE_UNSPECIFIED) + ModeAffordable Mode = Mode(defangv1.DeploymentMode_DEVELOPMENT) + ModeBalanced Mode = Mode(defangv1.DeploymentMode_STAGING) + ModeHighAvailability Mode = Mode(defangv1.DeploymentMode_PRODUCTION) +) + +func (b Mode) String() string { + if b == 0 { + return "" + } + + switch b { + case ModeAffordable: + return "AFFORDABLE" + case ModeBalanced: + return "BALANCED" + case ModeHighAvailability: + return "HIGH_AVAILABILITY" + default: + return fmt.Sprintf("UNKNOWN(%d)", b) + } +} + +func (b *Mode) Set(s string) error { + mode, err := Parse(s) + if err != nil { + return err + } + *b = mode + return nil +} + +func Parse(str string) (Mode, error) { + upper := strings.ToUpper(str) + mode, ok := defangv1.DeploymentMode_value[upper] + if !ok { + switch upper { + case "": + mode = int32(defangv1.DeploymentMode_MODE_UNSPECIFIED) + case "AFFORDABLE", "CHEAP": + mode = int32(defangv1.DeploymentMode_DEVELOPMENT) + case "BALANCED": + mode = int32(defangv1.DeploymentMode_STAGING) + case "HA", "HIGH_AVAILABILITY", "HIGH-AVAILABILITY": + mode = int32(defangv1.DeploymentMode_PRODUCTION) + default: + return 0, fmt.Errorf("invalid mode: %q, not one of %v", str, AllDeploymentModes()) + } + } + return Mode(mode), nil +} + +func (b Mode) Type() string { + return "mode" +} + +func (b Mode) Value() defangv1.DeploymentMode { + return defangv1.DeploymentMode(b) +} + +func AllDeploymentModes() []string { + var modes []string + for _, i := range slices.Sorted(maps.Keys(defangv1.DeploymentMode_name)) { + if i == 0 { + continue + } + modes = append(modes, Mode(i).String()) + } + return modes +} diff --git a/src/pkg/modes/modes_test.go b/src/pkg/modes/mode_test.go similarity index 72% rename from src/pkg/modes/modes_test.go rename to src/pkg/modes/mode_test.go index 065b1076d..347b7a5e7 100644 --- a/src/pkg/modes/modes_test.go +++ b/src/pkg/modes/mode_test.go @@ -3,7 +3,6 @@ package modes import ( "testing" - defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" "github.com/stretchr/testify/assert" ) @@ -17,45 +16,40 @@ func TestMode_Set(t *testing.T) { { name: "affordable", input: "AFFORDABLE", - want: Mode(defangv1.DeploymentMode_DEVELOPMENT), + want: ModeAffordable, }, { name: "balanced", input: "BALANCED", - want: Mode(defangv1.DeploymentMode_STAGING), + want: ModeBalanced, }, { name: "high_availability", input: "HIGH_AVAILABILITY", - want: Mode(defangv1.DeploymentMode_PRODUCTION), - }, - { - name: "invalid", - input: "INVALID", - wantErr: true, + want: ModeHighAvailability, }, { name: "development (deprecated)", input: "DEVELOPMENT", - want: Mode(defangv1.DeploymentMode_DEVELOPMENT), + want: ModeAffordable, wantErr: false, }, { name: "staging (deprecated)", input: "STAGING", - want: Mode(defangv1.DeploymentMode_STAGING), + want: ModeBalanced, wantErr: false, }, { name: "production (deprecated)", input: "PRODUCTION", - want: Mode(defangv1.DeploymentMode_PRODUCTION), + want: ModeHighAvailability, wantErr: false, }, { name: "unspecified", input: "", - want: Mode(defangv1.DeploymentMode_MODE_UNSPECIFIED), + want: ModeUnspecified, wantErr: false, }, } @@ -82,17 +76,17 @@ func TestMode_String(t *testing.T) { }{ { name: "affordable", - mode: Mode(defangv1.DeploymentMode_DEVELOPMENT), + mode: ModeAffordable, want: "AFFORDABLE", }, { name: "balanced", - mode: Mode(defangv1.DeploymentMode_STAGING), + mode: ModeBalanced, want: "BALANCED", }, { name: "high_availability", - mode: Mode(defangv1.DeploymentMode_PRODUCTION), + mode: ModeHighAvailability, want: "HIGH_AVAILABILITY", }, } diff --git a/src/pkg/modes/modes.go b/src/pkg/modes/modes.go deleted file mode 100644 index 9e14b86ad..000000000 --- a/src/pkg/modes/modes.go +++ /dev/null @@ -1,65 +0,0 @@ -package modes - -import ( - "strings" - - defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" -) - -type Mode string - -const ( - ModeUnspecified Mode = Mode("") - ModeAffordable Mode = Mode("AFFORDABLE") - ModeBalanced Mode = Mode("BALANCED") - ModeHighAvailability Mode = Mode("HIGH_AVAILABILITY") -) - -func (m Mode) String() string { - return string(m) -} - -func (m *Mode) Set(s string) error { - *m = Parse(s) - return nil -} - -func Parse(str string) Mode { - upper := strings.ToUpper(str) - // Handle legacy aliases - switch upper { - case "CHEAP", "DEVELOPMENT": - return ModeAffordable - case "STAGING": - return ModeBalanced - case "HA", "HIGH-AVAILABILITY", "PRODUCTION": - return ModeHighAvailability - } - return Mode(upper) -} - -func (Mode) Type() string { - return "mode" -} - -func (m Mode) Value() defangv1.DeploymentMode { - switch m { - case ModeAffordable: - return defangv1.DeploymentMode_DEVELOPMENT - case ModeBalanced: - return defangv1.DeploymentMode_STAGING - case ModeHighAvailability: - return defangv1.DeploymentMode_PRODUCTION - default: - return defangv1.DeploymentMode_MODE_UNSPECIFIED - } -} - -// Deprecated: replaced by free-form recipe names, ListRecipes gRPC method -func AllDeploymentModes() []string { - return []string{ - ModeAffordable.String(), - ModeBalanced.String(), - ModeHighAvailability.String(), - } -} diff --git a/src/pkg/modes/recipe.go b/src/pkg/modes/recipe.go new file mode 100644 index 000000000..2b8dc14b4 --- /dev/null +++ b/src/pkg/modes/recipe.go @@ -0,0 +1,70 @@ +package modes + +import ( + "strings" + + defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" +) + +type Recipe string + +const ( + RecipeUnspecified Recipe = Recipe("") + RecipeAffordable Recipe = Recipe("AFFORDABLE") + RecipeBalanced Recipe = Recipe("BALANCED") + RecipeHighAvailability Recipe = Recipe("HIGH_AVAILABILITY") +) + +func (r Recipe) String() string { + return string(r) +} + +func (r *Recipe) Set(s string) error { + *r = ParseRecipe(s) + return nil +} + +func ParseRecipe(str string) Recipe { + upper := strings.ToUpper(str) + // Handle legacy aliases + switch upper { + case "CHEAP", "DEVELOPMENT": + return RecipeAffordable + case "STAGING": + return RecipeBalanced + case "HA", "HIGH-AVAILABILITY", "PRODUCTION": + return RecipeHighAvailability + } + return Recipe(upper) +} + +func (Recipe) Type() string { + return "recipe" +} + +func (r Recipe) Mode() Mode { + switch r { + case RecipeAffordable: + return ModeAffordable + case RecipeBalanced: + return ModeBalanced + case RecipeHighAvailability: + return ModeHighAvailability + default: + return ModeUnspecified + } +} + +// FromMode converts a protobuf DeploymentMode to a Recipe; it is the inverse of Mode. +func FromMode(mode defangv1.DeploymentMode) Recipe { + switch mode { + case defangv1.DeploymentMode_DEVELOPMENT: + return RecipeAffordable + case defangv1.DeploymentMode_STAGING: + return RecipeBalanced + case defangv1.DeploymentMode_PRODUCTION: + return RecipeHighAvailability + default: + return RecipeUnspecified + } +} diff --git a/src/pkg/session/session.go b/src/pkg/session/session.go index de9d5344e..a2ffd7442 100644 --- a/src/pkg/session/session.go +++ b/src/pkg/session/session.go @@ -79,7 +79,7 @@ func (sl *SessionLoader) loadStack(ctx context.Context) (*stacks.Parameters, str } // The only stack property that can be overridden via env/flag is Mode - if newMode := sl.opts.Default.Mode; newMode != modes.ModeUnspecified { + if newMode := sl.opts.Default.Mode; newMode != modes.RecipeUnspecified { stack.Mode = newMode } diff --git a/src/pkg/session/session_test.go b/src/pkg/session/session_test.go index f9ca984bc..0b224319a 100644 --- a/src/pkg/session/session_test.go +++ b/src/pkg/session/session_test.go @@ -152,19 +152,19 @@ func TestLoadSession(t *testing.T) { GetStackOpts: stacks.GetStackOpts{ Default: stacks.Parameters{ Name: "existingstack", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, }, }, }, existingStack: &stacks.Parameters{ Name: "existingstack", Provider: client.ProviderAWS, - Mode: modes.ModeBalanced, + Mode: modes.RecipeBalanced, }, expectedStack: &stacks.Parameters{ Name: "existingstack", Provider: client.ProviderAWS, - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, Variables: map[string]string{ "DEFANG_PROVIDER": "aws", "DEFANG_MODE": "affordable", diff --git a/src/pkg/stacks/manager.go b/src/pkg/stacks/manager.go index 9d5b92eb4..de7bca055 100644 --- a/src/pkg/stacks/manager.go +++ b/src/pkg/stacks/manager.go @@ -144,8 +144,8 @@ func newParametersFromPB(stack *defangv1.Stack) (*Parameters, error) { return nil, fmt.Errorf("failed to parse remote stack content: %w", err) } // fill in missing fields from remote stack info - if params.Mode == modes.ModeUnspecified { - params.Mode = modes.Mode(stack.GetMode()) + if params.Mode == modes.RecipeUnspecified { + params.Mode = modes.FromMode(stack.GetMode()) } if params.Region == "" { params.Region = stack.GetRegion() diff --git a/src/pkg/stacks/manager_test.go b/src/pkg/stacks/manager_test.go index 3c52ce12e..60f8ac511 100644 --- a/src/pkg/stacks/manager_test.go +++ b/src/pkg/stacks/manager_test.go @@ -89,7 +89,7 @@ func TestManager_CreateListLoad(t *testing.T) { Variables: map[string]string{ "AWS_PROFILE": "default", }, - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, } filename, err := manager.Create(params) @@ -111,7 +111,7 @@ func TestManager_CreateListLoad(t *testing.T) { assert.Equal(t, "teststack", stacks[0].Name, "Expected stack name 'teststack'") assert.Equal(t, client.ProviderAWS, stacks[0].Provider, "Expected provider AWS") assert.Equal(t, "us-east-1", stacks[0].Region, "Expected region 'us-east-1'") - assert.Equal(t, modes.ModeAffordable, stacks[0].Mode, "Expected mode 'AFFORDABLE'") + assert.Equal(t, modes.RecipeAffordable, stacks[0].Mode, "Expected mode 'AFFORDABLE'") // Test loading a stack loadedParams, err := manager.Load(t.Context(), "teststack") @@ -120,7 +120,7 @@ func TestManager_CreateListLoad(t *testing.T) { assert.Equal(t, client.ProviderAWS, loadedParams.Provider, "Expected provider AWS") assert.Equal(t, "us-east-1", loadedParams.Region, "Expected region 'us-east-1'") assert.Equal(t, "default", loadedParams.Variables["AWS_PROFILE"], "Expected AWS profile 'default'") - assert.Equal(t, modes.ModeAffordable, loadedParams.Mode, "Expected mode affordable") + assert.Equal(t, modes.RecipeAffordable, loadedParams.Mode, "Expected mode affordable") } func TestManager_CreateGCPStack(t *testing.T) { @@ -143,7 +143,7 @@ func TestManager_CreateGCPStack(t *testing.T) { Variables: map[string]string{ "GCP_PROJECT_ID": "my-project", }, - Mode: modes.ModeBalanced, + Mode: modes.RecipeBalanced, } filename, err := manager.Create(params) @@ -157,7 +157,7 @@ func TestManager_CreateGCPStack(t *testing.T) { require.NoError(t, err, "Load() failed") assert.Equal(t, client.ProviderGCP, loadedParams.Provider, "Expected provider GCP") assert.Equal(t, "my-project", loadedParams.Variables["GCP_PROJECT_ID"], "Expected GCP project ID 'my-project'") - assert.Equal(t, modes.ModeBalanced, loadedParams.Mode, "Expected mode balanced") + assert.Equal(t, modes.RecipeBalanced, loadedParams.Mode, "Expected mode balanced") } func TestManager_CreateMultipleStacks(t *testing.T) { @@ -181,7 +181,7 @@ func TestManager_CreateMultipleStacks(t *testing.T) { Variables: map[string]string{ "AWS_PROFILE": "default", }, - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, }, { Name: "stack2", @@ -190,13 +190,13 @@ func TestManager_CreateMultipleStacks(t *testing.T) { Variables: map[string]string{ "GCP_PROJECT_ID": "project2", }, - Mode: modes.ModeHighAvailability, + Mode: modes.RecipeHighAvailability, }, { Name: "stack3", Provider: client.ProviderDO, Region: "nyc1", - Mode: modes.ModeBalanced, + Mode: modes.RecipeBalanced, }, } @@ -281,7 +281,7 @@ func TestManager_CreateDuplicateStack(t *testing.T) { Name: "duplicatestack", Provider: client.ProviderAWS, Region: "us-east-1", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, } // Create the first stack @@ -393,7 +393,7 @@ GOOGLE_REGION=us-central1 Variables: map[string]string{ "AWS_PROFILE": "default", }, - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, } _, err = manager.Create(localParams) require.NoError(t, err, "First Create() failed") @@ -406,7 +406,7 @@ GOOGLE_REGION=us-central1 Variables: map[string]string{ "AWS_PROFILE": "default", }, - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, } _, err = manager.Create(localOnlyParams) require.NoError(t, err, "Create() failed") @@ -430,7 +430,7 @@ GOOGLE_REGION=us-central1 } assert.Equal(t, "us-east-1", sharedStack.Region, "Expected shared stack to use remote region us-east-1") assert.Equal(t, client.ProviderAWS, sharedStack.Provider, "Expected shared stack to use provider aws") - assert.Equal(t, modes.ModeUnspecified, sharedStack.Mode, "Expected shared stack to use mode UNSPECIFIED") + assert.Equal(t, modes.RecipeUnspecified, sharedStack.Mode, "Expected shared stack to use mode UNSPECIFIED") assert.Equal(t, "", sharedStack.Account, "Expected shared stack to have empty AWS_PROFILE variable") assert.Equal(t, deployedAt.Local().Format(time.RFC3339), sharedStack.DeployedAt.Local().Format(time.RFC3339), "Expected shared stack to have deployment time from remote") @@ -544,7 +544,7 @@ func TestManager_WorkingDirectoryMatches(t *testing.T) { Variables: map[string]string{ "AWS_PROFILE": "default", }, - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, } // Create should work @@ -601,7 +601,7 @@ GOOGLE_REGION=us-central1 Variables: map[string]string{ "AWS_PROFILE": "default", }, - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, } // Create should fail diff --git a/src/pkg/stacks/selector_test.go b/src/pkg/stacks/selector_test.go index 09741eec0..c5b92d3ce 100644 --- a/src/pkg/stacks/selector_test.go +++ b/src/pkg/stacks/selector_test.go @@ -121,7 +121,7 @@ func TestStackSelector_SelectStack_ExistingStack(t *testing.T) { Name: "production", Provider: client.ProviderAWS, Region: "us-west-2", - Mode: modes.ModeUnspecified, + Mode: modes.RecipeUnspecified, } selector := NewSelector(mockEC, mockSM) @@ -165,7 +165,7 @@ func TestStackSelector_SelectOrCreateStack_ExistingStack(t *testing.T) { Name: "production", Provider: client.ProviderAWS, Region: "us-west-2", - Mode: modes.ModeUnspecified, + Mode: modes.RecipeUnspecified, } selector := NewSelector(mockEC, mockSM) @@ -235,11 +235,15 @@ func TestStackSelector_SelectStack_CreateNewStack(t *testing.T) { return o.DefaultValue == "default" })).Return("staging", nil).Maybe() + // Mock wizard parameter collection - deployment mode (recipe) selection + mockEC.On("RequestEnum", ctx, "Which recipe (deployment mode) do you want to deploy with?", "mode", modes.AllDeploymentModes()).Return("BALANCED", nil) + // Mock wizard parameter collection newStackParams := &Parameters{ Name: "staging", Provider: client.ProviderAWS, Region: "us-east-1", + Mode: modes.RecipeBalanced, Variables: map[string]string{ "AWS_PROFILE": "staging", }, @@ -311,11 +315,15 @@ func TestStackSelector_SelectStack_NoExistingStacks(t *testing.T) { return o.DefaultValue == "default" })).Return("default", nil).Maybe() + // Mock wizard parameter collection - deployment mode (recipe) selection + mockEC.On("RequestEnum", ctx, "Which recipe (deployment mode) do you want to deploy with?", "mode", modes.AllDeploymentModes()).Return("BALANCED", nil) + // Mock wizard parameter collection newStackParams := &Parameters{ Name: "firststack", Provider: client.ProviderAWS, Region: "us-west-2", + Mode: modes.RecipeBalanced, Variables: map[string]string{ "AWS_PROFILE": "default", }, @@ -507,11 +515,15 @@ func TestStackSelector_SelectStack_CreateStackError(t *testing.T) { return o.DefaultValue == "default" })).Return("staging", nil).Maybe() + // Mock wizard parameter collection - deployment mode (recipe) selection + mockEC.On("RequestEnum", ctx, "Which recipe (deployment mode) do you want to deploy with?", "mode", modes.AllDeploymentModes()).Return("BALANCED", nil) + // Mock wizard parameter collection newStackParams := &Parameters{ Name: "staging", Provider: client.ProviderAWS, Region: "us-east-1", + Mode: modes.RecipeBalanced, Variables: map[string]string{ "AWS_PROFILE": "staging", }, diff --git a/src/pkg/stacks/stacks.go b/src/pkg/stacks/stacks.go index ff42c59d7..15504364f 100644 --- a/src/pkg/stacks/stacks.go +++ b/src/pkg/stacks/stacks.go @@ -18,7 +18,7 @@ import ( type Parameters struct { Name string Provider client.ProviderID - Mode modes.Mode // aka recipe name + Mode modes.Recipe Region string Variables map[string]string } @@ -49,7 +49,7 @@ func (sp Parameters) ToMap() map[string]string { vars[regionVarName] = sp.Region } } - if sp.Mode != modes.ModeUnspecified { + if sp.Mode != modes.RecipeUnspecified { vars["DEFANG_MODE"] = strings.ToLower(sp.Mode.String()) } return vars @@ -69,18 +69,15 @@ func paramsFromMap(variables map[string]string) (*Parameters, error) { regionVarName := client.GetRegionVarName(provider) // FIXME: GCP supports 5 different region vars region = variables[regionVarName] } - var mode modes.Mode + var recipe modes.Recipe if val, ok := variables["DEFANG_MODE"]; ok { - err := mode.Set(val) - if err != nil { - return nil, fmt.Errorf("invalid DEFANG_MODE value: %w", err) - } + recipe = modes.ParseRecipe(val) } return &Parameters{ Variables: variables, Provider: provider, Region: region, - Mode: mode, + Mode: recipe, }, nil } @@ -159,7 +156,7 @@ func (p *Parameters) Account() string { type ListItem struct { Name string Provider client.ProviderID - Mode modes.Mode + Mode modes.Recipe Region string Account string Default bool diff --git a/src/pkg/stacks/stacks_test.go b/src/pkg/stacks/stacks_test.go index e66b32f6a..06dcde821 100644 --- a/src/pkg/stacks/stacks_test.go +++ b/src/pkg/stacks/stacks_test.go @@ -48,7 +48,7 @@ func TestCreate(t *testing.T) { Name: "teststack", Provider: client.ProviderAWS, Region: "us-west-2", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, }, expectErr: false, expectedFilename: ".defang/teststack", @@ -59,7 +59,7 @@ func TestCreate(t *testing.T) { Name: "", Provider: client.ProviderAWS, Region: "us-west-2", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, }, expectErr: true, }, @@ -69,7 +69,7 @@ func TestCreate(t *testing.T) { Name: "invalid stack", Provider: client.ProviderAWS, Region: "us-west-2", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, }, expectErr: true, }, @@ -79,7 +79,7 @@ func TestCreate(t *testing.T) { Name: "a", Provider: client.ProviderAWS, Region: "us-west-2", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, }, expectErr: false, expectedFilename: ".defang/a", @@ -90,7 +90,7 @@ func TestCreate(t *testing.T) { Name: "invalid-name", Provider: client.ProviderAWS, Region: "us-west-2", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, }, expectErr: true, }, @@ -124,7 +124,7 @@ func TestRepeatCreate(t *testing.T) { Name: "repeattest", Provider: client.ProviderGCP, Region: "us-central1", - Mode: modes.ModeBalanced, + Mode: modes.RecipeBalanced, } _, err := CreateInDirectory(".", params) @@ -182,7 +182,7 @@ func TestRemove(t *testing.T) { Name: stackName, Provider: client.ProviderAWS, Region: "us-west-2", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, } stackFile, err := CreateInDirectory(".", params) if err != nil { @@ -218,7 +218,7 @@ func TestMarshal(t *testing.T) { Name: "teststack", Provider: client.ProviderGCP, Region: "us-central1", - Mode: modes.ModeBalanced, + Mode: modes.RecipeBalanced, }, expectedContent: "DEFANG_MODE=\"balanced\"\nDEFANG_PROVIDER=\"gcp\"\nGOOGLE_REGION=\"us-central1\"", }, @@ -228,7 +228,7 @@ func TestMarshal(t *testing.T) { Name: "awsstack", Provider: client.ProviderAWS, Region: "us-east-1", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, }, expectedContent: "AWS_REGION=\"us-east-1\"\nDEFANG_MODE=\"affordable\"\nDEFANG_PROVIDER=\"aws\"", }, @@ -238,7 +238,7 @@ func TestMarshal(t *testing.T) { Name: "nomodestack", Provider: client.ProviderAWS, Region: "us-west-1", - Mode: modes.ModeUnspecified, + Mode: modes.RecipeUnspecified, }, expectedContent: "AWS_REGION=\"us-west-1\"\nDEFANG_PROVIDER=\"aws\"", }, @@ -248,7 +248,7 @@ func TestMarshal(t *testing.T) { Name: "noregionstack", Provider: client.ProviderGCP, Region: "", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, }, expectedContent: "DEFANG_MODE=\"affordable\"\nDEFANG_PROVIDER=\"gcp\"", }, @@ -280,7 +280,7 @@ DEFANG_MODE=BALANCED expectedParams: Parameters{ Provider: client.ProviderGCP, Region: "us-central1", - Mode: modes.ModeBalanced, + Mode: modes.RecipeBalanced, }, }, { @@ -292,7 +292,7 @@ DEFANG_MODE=AFFORDABLE expectedParams: Parameters{ Provider: client.ProviderAWS, Region: "us-east-1", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, }, }, } @@ -321,7 +321,7 @@ func TestReadInDirectory(t *testing.T) { Name: stackName, Provider: client.ProviderAWS, Region: "us-west-2", - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, } _, err := CreateInDirectory(".", expectedParams) if err != nil { @@ -355,7 +355,7 @@ func TestParamsToMap(t *testing.T) { Variables: map[string]string{ "AWS_PROFILE": "default", }, - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, }, expectedMap: map[string]string{ "DEFANG_PROVIDER": "aws", @@ -373,7 +373,7 @@ func TestParamsToMap(t *testing.T) { Variables: map[string]string{ "GCP_PROJECT_ID": "gcp-project-123", }, - Mode: modes.ModeBalanced, + Mode: modes.RecipeBalanced, }, expectedMap: map[string]string{ "DEFANG_PROVIDER": "gcp", @@ -415,7 +415,7 @@ func TestParamsFromMap(t *testing.T) { expectedParams: Parameters{ Provider: client.ProviderGCP, Region: "us-central1", - Mode: modes.ModeBalanced, + Mode: modes.RecipeBalanced, }, }, { @@ -432,7 +432,7 @@ func TestParamsFromMap(t *testing.T) { Variables: map[string]string{ "AWS_PROFILE": "default", }, - Mode: modes.ModeAffordable, + Mode: modes.RecipeAffordable, }, }, } diff --git a/src/pkg/stacks/wizard.go b/src/pkg/stacks/wizard.go index 3be103e1d..a2fa134f6 100644 --- a/src/pkg/stacks/wizard.go +++ b/src/pkg/stacks/wizard.go @@ -82,14 +82,14 @@ func (w *Wizard) CollectRemainingParameters(ctx context.Context, params *Paramet params.Region = region } - if params.Mode == modes.ModeUnspecified && params.Provider != client.ProviderDefang { + if params.Mode == modes.RecipeUnspecified && params.Provider != client.ProviderDefang { modeName, err := w.ec.RequestEnum(ctx, "Which recipe (deployment mode) do you want to deploy with?", "mode", modes.AllDeploymentModes(), ) if err != nil { return nil, fmt.Errorf("failed to elicit deployment mode: %w", err) } - params.Mode = modes.Parse(modeName) + params.Mode = modes.ParseRecipe(modeName) } if params.Name == "" { diff --git a/src/pkg/stacks/wizard_test.go b/src/pkg/stacks/wizard_test.go index 78722f953..9e043a716 100644 --- a/src/pkg/stacks/wizard_test.go +++ b/src/pkg/stacks/wizard_test.go @@ -134,6 +134,7 @@ func TestWizardCollectParameters(t *testing.T) { expectedCallOrder: []string{ "RequestEnum:provider", "RequestString:region", + "RequestEnum:mode", "RequestString:stack_name", "RequestString:aws_profile", }, @@ -162,6 +163,7 @@ func TestWizardCollectParameters(t *testing.T) { expectedCallOrder: []string{ "RequestEnum:provider", "RequestString:region", + "RequestEnum:mode", "RequestString:stack_name", "RequestEnum:aws_profile", }, @@ -192,6 +194,7 @@ func TestWizardCollectParameters(t *testing.T) { expectedCallOrder: []string{ "RequestEnum:provider", "RequestString:region", + "RequestEnum:mode", "RequestString:stack_name", "RequestString:gcp_project_id", }, @@ -220,6 +223,7 @@ func TestWizardCollectParameters(t *testing.T) { expectedCallOrder: []string{ "RequestEnum:provider", "RequestString:region", + "RequestEnum:mode", "RequestString:stack_name", "RequestString:gcp_project_id", }, @@ -261,6 +265,7 @@ func TestWizardCollectParameters(t *testing.T) { expectedCallOrder: []string{ "RequestEnum:provider", "RequestString:region", + "RequestEnum:mode", "RequestString:stack_name", }, }, @@ -315,6 +320,7 @@ func TestWizardCollectParameters(t *testing.T) { expectedCallOrder: []string{ "RequestEnum:provider", "RequestString:region", + "RequestEnum:mode", "RequestString:stack_name", }, }, @@ -337,6 +343,7 @@ func TestWizardCollectParameters(t *testing.T) { expectedCallOrder: []string{ "RequestEnum:provider", "RequestString:region", + "RequestEnum:mode", "RequestString:stack_name", "RequestEnum:aws_profile", }, @@ -358,6 +365,7 @@ func TestWizardCollectParameters(t *testing.T) { expectedCallOrder: []string{ "RequestEnum:provider", "RequestString:region", + "RequestEnum:mode", "RequestString:stack_name", "RequestString:gcp_project_id", }, @@ -371,6 +379,9 @@ func TestWizardCollectParameters(t *testing.T) { t.Cleanup(tt.cleanupEnv) mockController := newMockElicitationsController() + // The wizard now prompts for a deployment mode (recipe) on non-Defang + // providers; provide a default answer that cases may override. + mockController.enumResponses["mode"] = "BALANCED" tt.setupMock(mockController) var profileLister AWSProfileLister @@ -770,6 +781,9 @@ func TestWizardCollectRemainingParameters(t *testing.T) { t.Cleanup(tt.cleanupEnv) mockController := newMockElicitationsController() + // The wizard now prompts for a deployment mode (recipe) on non-Defang + // providers; provide a default answer that cases may override. + mockController.enumResponses["mode"] = "BALANCED" tt.setupMock(mockController) wizard := NewWizard(mockController) From 1f4d0601d2439edff99557de97ad9b9431b966ad Mon Sep 17 00:00:00 2001 From: Lionello Lunesu Date: Mon, 1 Jun 2026 11:14:22 -0700 Subject: [PATCH 4/6] fix mcp --- src/pkg/agent/tools/create_aws_stack.go | 2 +- src/pkg/agent/tools/create_azure_stack.go | 2 +- src/pkg/agent/tools/create_gcp_stack.go | 2 +- src/pkg/agent/tools/estimate.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pkg/agent/tools/create_aws_stack.go b/src/pkg/agent/tools/create_aws_stack.go index 235d01d1f..fe92bfc5b 100644 --- a/src/pkg/agent/tools/create_aws_stack.go +++ b/src/pkg/agent/tools/create_aws_stack.go @@ -22,7 +22,7 @@ func HandleCreateAWSStackTool(ctx context.Context, params CreateAWSStackParams, Name: params.Name, Region: params.Region, Provider: client.ProviderAWS, - Mode: modes.Recipe(params.Mode), + Mode: modes.ParseRecipe(params.Mode), Variables: map[string]string{ "AWS_PROFILE": params.AWS_Profile, }, diff --git a/src/pkg/agent/tools/create_azure_stack.go b/src/pkg/agent/tools/create_azure_stack.go index 519bd02d3..dd6f57fc3 100644 --- a/src/pkg/agent/tools/create_azure_stack.go +++ b/src/pkg/agent/tools/create_azure_stack.go @@ -22,7 +22,7 @@ func HandleCreateAzureStackTool(ctx context.Context, params CreateAzureStackPara Name: params.Name, Region: params.Location, Provider: client.ProviderAzure, - Mode: modes.Recipe(params.Mode), + Mode: modes.ParseRecipe(params.Mode), Variables: map[string]string{ "AZURE_SUBSCRIPTION_ID": params.AzureSubscriptionID, }, diff --git a/src/pkg/agent/tools/create_gcp_stack.go b/src/pkg/agent/tools/create_gcp_stack.go index 0ca810d73..78124a585 100644 --- a/src/pkg/agent/tools/create_gcp_stack.go +++ b/src/pkg/agent/tools/create_gcp_stack.go @@ -22,7 +22,7 @@ func HandleCreateGCPStackTool(ctx context.Context, params CreateGCPStackParams, Name: params.Name, Region: params.Region, Provider: client.ProviderGCP, - Mode: modes.Recipe(params.Mode), + Mode: modes.ParseRecipe(params.Mode), Variables: map[string]string{ "GCP_PROJECT_ID": params.GCPProjectID, }, diff --git a/src/pkg/agent/tools/estimate.go b/src/pkg/agent/tools/estimate.go index 1234a52e8..3cc86c67f 100644 --- a/src/pkg/agent/tools/estimate.go +++ b/src/pkg/agent/tools/estimate.go @@ -45,7 +45,7 @@ func HandleEstimateTool(ctx context.Context, loader client.Loader, params Estima return "", err } - recipe := modes.Recipe(params.DeploymentMode) + recipe := modes.ParseRecipe(params.DeploymentMode) term.Debug("Function invoked: cli.RunEstimate") estimate, err := cli.RunEstimate(ctx, project, fabric, defangProvider, providerID, params.Region, recipe) From e49df161fe7ad0c7cae03d2426f404fc5387133b Mon Sep 17 00:00:00 2001 From: Lionello Lunesu Date: Tue, 2 Jun 2026 16:30:45 -0700 Subject: [PATCH 5/6] fix: fail up for inactive recipe --- src/pkg/cli/client/mock.go | 6 +++++- src/pkg/cli/composeUp.go | 4 ++++ src/pkg/cli/composeUp_test.go | 38 +++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/pkg/cli/client/mock.go b/src/pkg/cli/client/mock.go index 487473b02..d5827eef7 100644 --- a/src/pkg/cli/client/mock.go +++ b/src/pkg/cli/client/mock.go @@ -195,6 +195,7 @@ func ServerStreamIterCtx[T any](ctx context.Context, stream ServerStream[T]) ite type MockFabricClient struct { FabricClient DelegateDomain string + Recipe *defangv1.Recipe // returned by GetRecipe; defaults to an active recipe when nil } func (m MockFabricClient) GetFabricClient() defangv1connect.FabricControllerClient { @@ -238,7 +239,10 @@ func (m MockFabricClient) PutDeployment(ctx context.Context, req *defangv1.PutDe } func (m MockFabricClient) GetRecipe(ctx context.Context, req *defangv1.GetRecipeRequest) (*defangv1.GetRecipeResponse, error) { - return &defangv1.GetRecipeResponse{Recipe: &defangv1.Recipe{Name: req.GetName()}}, nil + if m.Recipe != nil { + return &defangv1.GetRecipeResponse{Recipe: m.Recipe}, nil + } + return &defangv1.GetRecipeResponse{Recipe: &defangv1.Recipe{Name: req.GetName(), Active: true}}, nil } func (m MockFabricClient) ListDeployments(ctx context.Context, req *defangv1.ListDeploymentsRequest) (*defangv1.ListDeploymentsResponse, error) { diff --git a/src/pkg/cli/composeUp.go b/src/pkg/cli/composeUp.go index 7c2aed942..1f92ef8ed 100644 --- a/src/pkg/cli/composeUp.go +++ b/src/pkg/cli/composeUp.go @@ -144,6 +144,10 @@ func ComposeUp(ctx context.Context, fabric client.FabricClient, provider client. if err != nil { return nil, project, fmt.Errorf("failed to get recipe for deployment mode %q: %w", recipe, err) } + // Allow estimate/preview with an inactive recipe so teams can evaluate it before activating. + if upload != compose.UploadModeEstimate && upload != compose.UploadModePreview && !rresp.Recipe.GetActive() { + return nil, project, fmt.Errorf("recipe %q is not active", recipe) + } deployRequest := &client.DeployRequest{ DeployRequest: defangv1.DeployRequest{ diff --git a/src/pkg/cli/composeUp_test.go b/src/pkg/cli/composeUp_test.go index ecac4a7c0..cd5d20149 100644 --- a/src/pkg/cli/composeUp_test.go +++ b/src/pkg/cli/composeUp_test.go @@ -163,6 +163,44 @@ func TestComposeUp(t *testing.T) { }) require.ErrorContains(t, err, "downgrade deployment mode from HIGH_AVAILABILITY to AFFORDABLE") }) + + t.Run("inactive recipe", func(t *testing.T) { + inactiveClient := client.MockFabricClient{ + DelegateDomain: "example.com", + Recipe: &defangv1.Recipe{Name: "AFFORDABLE", Active: false}, + } + newProvider := func() *mockDeployProvider { + return &mockDeployProvider{MockProvider: client.MockProvider{UploadUrl: server.URL + "/"}} + } + + t.Run("cannot deploy", func(t *testing.T) { + _, _, err := ComposeUp(t.Context(), inactiveClient, newProvider(), stack, ComposeUpParams{ + Mode: modes.RecipeAffordable, + Project: proj, + UploadMode: compose.UploadModeDigest, + }) + require.ErrorContains(t, err, `recipe "AFFORDABLE" is not active`) + }) + + // Teams can estimate/preview an inactive recipe to decide whether to activate it. + allowed := []struct { + name string + mode compose.UploadMode + }{ + {"estimate is allowed", compose.UploadModeEstimate}, + {"preview is allowed", compose.UploadModePreview}, + } + for _, tc := range allowed { + t.Run(tc.name, func(t *testing.T) { + _, _, err := ComposeUp(t.Context(), inactiveClient, newProvider(), stack, ComposeUpParams{ + Mode: modes.RecipeAffordable, + Project: proj, + UploadMode: tc.mode, + }) + require.NoError(t, err) + }) + } + }) } func TestSplitManagedAndUnmanagedServices(t *testing.T) { From 9b7ad3adad365991cc67974ae39ef737dadfeec5 Mon Sep 17 00:00:00 2001 From: Lionello Lunesu Date: Fri, 5 Jun 2026 08:20:43 -0700 Subject: [PATCH 6/6] pr coments --- src/cmd/cli/command/recipe.go | 12 ++++++------ src/pkg/cli/composeUp.go | 2 +- src/pkg/cli/estimate.go | 16 ++++++++++------ src/pkg/cli/recipeActivate.go | 24 ++++++++++++------------ src/pkg/cli/recipeShow.go | 2 +- src/protos/io/defang/v1/fabric.pb.go | 14 ++++++++------ src/protos/io/defang/v1/fabric.proto | 2 +- 7 files changed, 39 insertions(+), 33 deletions(-) diff --git a/src/cmd/cli/command/recipe.go b/src/cmd/cli/command/recipe.go index e6e3aca79..a97bebb7e 100644 --- a/src/cmd/cli/command/recipe.go +++ b/src/cmd/cli/command/recipe.go @@ -8,20 +8,20 @@ import ( ) func makeRecipeCmd() *cobra.Command { - var stackCmd = &cobra.Command{ + var recipeCmd = &cobra.Command{ Use: "recipe", Aliases: []string{"recipes", "modes", "mode"}, Short: "Manage workspace recipes (deployment modes)", } recipeListCmd := makeRecipeListCmd() - stackCmd.AddCommand(recipeListCmd) + recipeCmd.AddCommand(recipeListCmd) recipeShowCmd := makeRecipeShowCmd() - stackCmd.AddCommand(recipeShowCmd) + recipeCmd.AddCommand(recipeShowCmd) recipeDeactivateCmd := makeRecipeDeactivateCmd() - stackCmd.AddCommand(recipeDeactivateCmd) + recipeCmd.AddCommand(recipeDeactivateCmd) recipeActivateCmd := makeRecipeActivateCmd() - stackCmd.AddCommand(recipeActivateCmd) - return stackCmd + recipeCmd.AddCommand(recipeActivateCmd) + return recipeCmd } func makeRecipeShowCmd() *cobra.Command { diff --git a/src/pkg/cli/composeUp.go b/src/pkg/cli/composeUp.go index 1f92ef8ed..7d4d11e7d 100644 --- a/src/pkg/cli/composeUp.go +++ b/src/pkg/cli/composeUp.go @@ -145,7 +145,7 @@ func ComposeUp(ctx context.Context, fabric client.FabricClient, provider client. return nil, project, fmt.Errorf("failed to get recipe for deployment mode %q: %w", recipe, err) } // Allow estimate/preview with an inactive recipe so teams can evaluate it before activating. - if upload != compose.UploadModeEstimate && upload != compose.UploadModePreview && !rresp.Recipe.GetActive() { + if !rresp.Recipe.GetActive() && upload != compose.UploadModeEstimate && upload != compose.UploadModePreview { return nil, project, fmt.Errorf("recipe %q is not active", recipe) } diff --git a/src/pkg/cli/estimate.go b/src/pkg/cli/estimate.go index 2db9daa52..8cb9f8de9 100644 --- a/src/pkg/cli/estimate.go +++ b/src/pkg/cli/estimate.go @@ -20,16 +20,16 @@ import ( defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" ) -func RunEstimate(ctx context.Context, project *compose.Project, client client.FabricClient, previewProvider client.Provider, estimateProviderID client.ProviderID, region string, recipe modes.Recipe) (*defangv1.EstimateResponse, error) { +func RunEstimate(ctx context.Context, project *compose.Project, fabric client.FabricClient, previewProvider client.Provider, estimateProviderID client.ProviderID, region string, recipe modes.Recipe) (*defangv1.EstimateResponse, error) { term.Debugf("Running estimate for project %s in region %s with mode %s", project.Name, region, recipe) - preview, err := GeneratePreview(ctx, project, client, previewProvider, estimateProviderID, recipe, region) + preview, err := GeneratePreview(ctx, project, fabric, previewProvider, estimateProviderID, recipe, region) if err != nil { return nil, err } term.Info("Preparing estimate") - estimate, err := client.Estimate(ctx, &defangv1.EstimateRequest{ + estimate, err := fabric.Estimate(ctx, &defangv1.EstimateRequest{ Provider: estimateProviderID.Value(), Region: region, PulumiPreview: []byte(preview), @@ -40,7 +40,7 @@ func RunEstimate(ctx context.Context, project *compose.Project, client client.Fa return estimate, nil } -func GeneratePreview(ctx context.Context, project *compose.Project, client client.FabricClient, previewProvider client.Provider, estimateProviderID client.ProviderID, recipe modes.Recipe, region string) (string, error) { +func GeneratePreview(ctx context.Context, project *compose.Project, fabric client.FabricClient, previewProvider client.Provider, estimateProviderID client.ProviderID, recipe modes.Recipe, region string) (string, error) { os.Setenv("DEFANG_JSON", "1") // HACK: always show JSON output for estimate since := time.Now().Add(-1 * time.Minute) // fetch logs since one minute ago to account for clock drift @@ -56,13 +56,17 @@ func GeneratePreview(ctx context.Context, project *compose.Project, client clien term.Debugf("Fixedup project: %s", string(composeData)) - // TODO: this will need to read the recipe from Fabric first - resp, err := client.Preview(ctx, &defangv1.PreviewRequest{ + rresp, err := fabric.GetRecipe(ctx, &defangv1.GetRecipeRequest{Name: recipe.String()}) + if err != nil { + return "", fmt.Errorf("failed to get recipe for deployment mode %q: %w", recipe, err) + } + resp, err := fabric.Preview(ctx, &defangv1.PreviewRequest{ Provider: estimateProviderID.Value(), Mode: recipe.Mode().Value(), Region: region, Compose: composeData, ProjectName: project.Name, + Recipe: rresp.Recipe, }) if err != nil { return "", err diff --git a/src/pkg/cli/recipeActivate.go b/src/pkg/cli/recipeActivate.go index b15c26be3..16a06f9b9 100644 --- a/src/pkg/cli/recipeActivate.go +++ b/src/pkg/cli/recipeActivate.go @@ -15,19 +15,19 @@ func RecipeActivate(ctx context.Context, fabric client.FabricClient, name string return fmt.Errorf("failed to get recipe: %w", err) } + recipe := resp.GetRecipe() + recipe.Active = active err = fabric.PutRecipe(ctx, &defangv1.PutRecipeRequest{ - Recipe: &defangv1.Recipe{ - Name: resp.Recipe.Name, - PulumiConfig: resp.Recipe.PulumiConfig, - Active: active, - }, + Recipe: recipe, }) - if err == nil { - state := "active" - if !active { - state = "inactive" - } - term.Info(fmt.Sprintf("Recipe %q is now %s.", name, state)) + if err != nil { + return err + } + + state := "active" + if !recipe.Active { + state = "inactive" } - return err + term.Info(fmt.Sprintf("Recipe %q is now %s.", recipe.Name, state)) + return nil } diff --git a/src/pkg/cli/recipeShow.go b/src/pkg/cli/recipeShow.go index bbfee7c94..c825caf3e 100644 --- a/src/pkg/cli/recipeShow.go +++ b/src/pkg/cli/recipeShow.go @@ -15,6 +15,6 @@ func RecipeShow(ctx context.Context, fabric client.FabricClient, recipeName stri return fmt.Errorf("failed to get recipe: %w", err) } - _, err = term.Println(resp.Recipe.PulumiConfig) + _, err = term.Println(resp.Recipe.GetPulumiConfig()) return err } diff --git a/src/protos/io/defang/v1/fabric.pb.go b/src/protos/io/defang/v1/fabric.pb.go index 12437bce5..6a443fb61 100644 --- a/src/protos/io/defang/v1/fabric.pb.go +++ b/src/protos/io/defang/v1/fabric.pb.go @@ -2773,9 +2773,10 @@ func (x *CanIUseResponse) GetForcedReason() string { type DeployRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Deprecated: Marked as deprecated in io/defang/v1/fabric.proto. - Project string `protobuf:"bytes,2,opt,name=project,proto3" json:"project,omitempty"` // deprecated; use compose.name - Mode DeploymentMode `protobuf:"varint,3,opt,name=mode,proto3,enum=io.defang.v1.DeploymentMode" json:"mode,omitempty"` - Compose []byte `protobuf:"bytes,4,opt,name=compose,proto3" json:"compose,omitempty"` // yaml (or json) + Project string `protobuf:"bytes,2,opt,name=project,proto3" json:"project,omitempty"` // deprecated; use compose.name + // Deprecated: Marked as deprecated in io/defang/v1/fabric.proto. + Mode DeploymentMode `protobuf:"varint,3,opt,name=mode,proto3,enum=io.defang.v1.DeploymentMode" json:"mode,omitempty"` // deprecated; use recipe + Compose []byte `protobuf:"bytes,4,opt,name=compose,proto3" json:"compose,omitempty"` // yaml (or json) DelegateDomain string `protobuf:"bytes,5,opt,name=delegate_domain,json=delegateDomain,proto3" json:"delegate_domain,omitempty"` DelegationSetId string `protobuf:"bytes,6,opt,name=delegation_set_id,json=delegationSetId,proto3" json:"delegation_set_id,omitempty"` // Deprecated: Marked as deprecated in io/defang/v1/fabric.proto. @@ -2824,6 +2825,7 @@ func (x *DeployRequest) GetProject() string { return "" } +// Deprecated: Marked as deprecated in io/defang/v1/fabric.proto. func (x *DeployRequest) GetMode() DeploymentMode { if x != nil { return x.Mode @@ -6677,10 +6679,10 @@ const file_io_defang_v1_fabric_proto_rawDesc = "" + "\x0epulumi_version\x18\x05 \x01(\tR\rpulumiVersion\x12\x1c\n" + "\tsignature\x18\x06 \x01(\fR\tsignature\x12%\n" + "\x0eforced_version\x18\a \x01(\bR\rforcedVersion\x12#\n" + - "\rforced_reason\x18\b \x01(\tR\fforcedReasonJ\x04\b\x01\x10\x02\"\xc7\x02\n" + + "\rforced_reason\x18\b \x01(\tR\fforcedReasonJ\x04\b\x01\x10\x02\"\xcb\x02\n" + "\rDeployRequest\x12\x1c\n" + - "\aproject\x18\x02 \x01(\tB\x02\x18\x01R\aproject\x120\n" + - "\x04mode\x18\x03 \x01(\x0e2\x1c.io.defang.v1.DeploymentModeR\x04mode\x12\x18\n" + + "\aproject\x18\x02 \x01(\tB\x02\x18\x01R\aproject\x124\n" + + "\x04mode\x18\x03 \x01(\x0e2\x1c.io.defang.v1.DeploymentModeB\x02\x18\x01R\x04mode\x12\x18\n" + "\acompose\x18\x04 \x01(\fR\acompose\x12'\n" + "\x0fdelegate_domain\x18\x05 \x01(\tR\x0edelegateDomain\x12*\n" + "\x11delegation_set_id\x18\x06 \x01(\tR\x0fdelegationSetId\x12\x1c\n" + diff --git a/src/protos/io/defang/v1/fabric.proto b/src/protos/io/defang/v1/fabric.proto index f74db358d..42c651f0d 100644 --- a/src/protos/io/defang/v1/fabric.proto +++ b/src/protos/io/defang/v1/fabric.proto @@ -395,7 +395,7 @@ message CanIUseResponse { message DeployRequest { reserved 1; // was: services string project = 2 [deprecated = true]; // deprecated; use compose.name - DeploymentMode mode = 3; + DeploymentMode mode = 3 [deprecated = true]; // deprecated; use recipe bytes compose = 4; // yaml (or json) string delegate_domain = 5; string delegation_set_id = 6;