diff --git a/README.md b/README.md index a05907af1..4352d560a 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,7 @@ The Defang CLI recognizes the following environment variables: - `DEFANG_PULUMI_DIFF` - If set to `true`, shows the Pulumi diff during deployments; defaults to `false` - `DEFANG_PULUMI_DIR` - Run Pulumi from this folder, instead of spawning a cloud task; requires `--debug` (BYOC only) - `DEFANG_PULUMI_VERSION` - Override the version of the Pulumi image to use (`aws` provider only) +- `DEFANG_RECIPE` - The deployment mode / recipe to use; defaults to `AFFORDABLE` - `DEFANG_SUFFIX` - The suffix to use for all BYOC resources; defaults to the stack name, or `beta` if unset. - `DEFANG_WORKSPACE` - The workspace (name or ID) to use; preferred way to select which workspace the CLI uses - `NO_COLOR` - If set to any value, disables color output; by default, color output is enabled depending on the terminal diff --git a/pkgs/defang/cli.nix b/pkgs/defang/cli.nix index 3f49b62ad..4cae3c40f 100644 --- a/pkgs/defang/cli.nix +++ b/pkgs/defang/cli.nix @@ -7,7 +7,7 @@ buildGo125Module { pname = "defang-cli"; version = "git"; src = lib.cleanSource ../../src; - vendorHash = "sha256-qNsk3rEco0mzBIPodsp3GZpzgaIzthAPSIMmVCja50I="; + vendorHash = "sha256-Qyi3P35xTsY98tSH7VrMMj1LeX9dRMmI4ANX9E2e+nU="; subPackages = [ "cmd/cli" ]; diff --git a/pkgs/npm/README.md b/pkgs/npm/README.md index edfe03ac9..e6cc768cd 100644 --- a/pkgs/npm/README.md +++ b/pkgs/npm/README.md @@ -49,6 +49,7 @@ The Defang CLI recognizes the following environment variables: - `DEFANG_PULUMI_DIFF` - If set to `true`, shows the Pulumi diff during deployments; defaults to `false` - `DEFANG_PULUMI_DIR` - Run Pulumi from this folder, instead of spawning a cloud task; requires `--debug` (BYOC only) - `DEFANG_PULUMI_VERSION` - Override the version of the Pulumi image to use (`aws` provider only) +- `DEFANG_RECIPE` - The deployment mode / recipe to use; defaults to `AFFORDABLE` - `DEFANG_SUFFIX` - The suffix to use for all BYOC resources; defaults to the stack name, or `beta` if unset. - `DEFANG_WORKSPACE` - The workspace (name or ID) to use; preferred way to select which workspace the CLI uses - `NO_COLOR` - If set to any value, disables color output; by default, color output is enabled depending on the terminal diff --git a/src/README.md b/src/README.md index edfe03ac9..e6cc768cd 100644 --- a/src/README.md +++ b/src/README.md @@ -49,6 +49,7 @@ The Defang CLI recognizes the following environment variables: - `DEFANG_PULUMI_DIFF` - If set to `true`, shows the Pulumi diff during deployments; defaults to `false` - `DEFANG_PULUMI_DIR` - Run Pulumi from this folder, instead of spawning a cloud task; requires `--debug` (BYOC only) - `DEFANG_PULUMI_VERSION` - Override the version of the Pulumi image to use (`aws` provider only) +- `DEFANG_RECIPE` - The deployment mode / recipe to use; defaults to `AFFORDABLE` - `DEFANG_SUFFIX` - The suffix to use for all BYOC resources; defaults to the stack name, or `beta` if unset. - `DEFANG_WORKSPACE` - The workspace (name or ID) to use; preferred way to select which workspace the CLI uses - `NO_COLOR` - If set to any value, disables color output; by default, color output is enabled depending on the terminal diff --git a/src/cmd/cli/command/cd.go b/src/cmd/cli/command/cd.go index 21043431d..22cb0e716 100644 --- a/src/cmd/cli/command/cd.go +++ b/src/cmd/cli/command/cd.go @@ -196,7 +196,7 @@ var cdPreviewCmd = &cobra.Command{ } return cli.Preview(ctx, project, global.Client, session.Provider, cli.ComposeUpParams{ - Mode: session.Stack.Mode, + Recipe: session.Stack.Recipe, Project: project, UploadMode: compose.UploadModePreview, }) diff --git a/src/cmd/cli/command/commands.go b/src/cmd/cli/command/commands.go index 082ebdc55..fc076472e 100644 --- a/src/cmd/cli/command/commands.go +++ b/src/cmd/cli/command/commands.go @@ -23,7 +23,6 @@ import ( "github.com/DefangLabs/defang/src/pkg/login" "github.com/DefangLabs/defang/src/pkg/mcp" "github.com/DefangLabs/defang/src/pkg/migrate" - "github.com/DefangLabs/defang/src/pkg/modes" "github.com/DefangLabs/defang/src/pkg/scope" "github.com/DefangLabs/defang/src/pkg/stacks" "github.com/DefangLabs/defang/src/pkg/term" @@ -186,8 +185,6 @@ func SetupCommands(version string) { cdCmd.AddCommand(cdListCmd) cdCmd.AddCommand(cdCancelCmd) cdCmd.AddCommand(cdOutputsCmd) - cdPreviewCmd.Flags().VarP(&global.Stack.Mode, "mode", "m", fmt.Sprintf("deployment mode; one of %v", modes.AllDeploymentModes())) - cdPreviewCmd.RegisterFlagCompletionFunc("mode", cobra.FixedCompletions(modes.AllDeploymentModes(), cobra.ShellCompDirectiveNoFileComp)) cdCmd.AddCommand(cdPreviewCmd) cdInstallCmd.Flags().Bool("force", false, "force the installation of the CD resources into the cluster (allow downgrades)") cdCmd.AddCommand(cdInstallCmd) @@ -319,6 +316,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) @@ -500,7 +499,7 @@ func canIUseProvider(ctx context.Context, provider client.Provider, projectName // provider-specific login paths such as GitHub Actions OIDC federation run instead of // falling through to the plain default credential chain. func authenticateProvider(ctx context.Context, provider client.Provider) error { - if err := provider.Authenticate(ctx, !global.NonInteractive); err != nil { + if err := provider.Authenticate(ctx, global.Interactive()); err != nil { return fmt.Errorf("failed to authenticate with provider: %w", err) } return nil diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index bae0186a5..0e822ce11 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -112,7 +112,7 @@ func makeComposeUpCmd() *cobra.Command { Name: stacks.MakeDefaultName(accountInfo.Provider, accountInfo.Region), Provider: accountInfo.Provider, Region: accountInfo.Region, - Mode: session.Stack.Mode, + Recipe: session.Stack.Recipe, }) if err != nil { term.Debug("Failed to create stack:", err) @@ -133,7 +133,7 @@ func makeComposeUpCmd() *cobra.Command { deploy, project, err := cli.ComposeUp(ctx, global.Client, session.Provider, session.Stack, cli.ComposeUpParams{ Project: project, UploadMode: upload, - Mode: session.Stack.Mode, + Recipe: session.Stack.Recipe, }) if err != nil { composeErr := err @@ -206,7 +206,6 @@ func makeComposeUpCmd() *cobra.Command { composeUpCmd.Flags().Bool("force", false, "force a build of the image even if nothing has changed; implies --build") composeUpCmd.Flags().Bool("tail", false, "tail the service logs after updating") // no-op, but keep for backwards compatibility _ = composeUpCmd.Flags().MarkHidden("tail") - composeUpCmd.Flags().VarP(&global.Stack.Mode, "mode", "m", fmt.Sprintf("deployment mode; one of %v", modes.AllDeploymentModes())) composeUpCmd.Flags().Bool("build", false, "build images before starting services") // docker-compose compatibility composeUpCmd.Flags().Bool("wait", true, "wait for services to be running|healthy") // docker-compose compatibility _ = composeUpCmd.Flags().MarkHidden("wait") @@ -243,7 +242,7 @@ func confirmDeployment(targetDirectory string, existingDeployments []*defangv1.D Name: stackName, Provider: accountInfo.Provider, Region: accountInfo.Region, - Mode: global.Stack.Mode, + Recipe: global.Stack.Recipe, }) if err != nil { term.Debugf("Failed to create stack %v", err) @@ -571,7 +570,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, + Recipe: 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 2f50ec44b..12c92b7ea 100644 --- a/src/cmd/cli/command/estimate.go +++ b/src/cmd/cli/command/estimate.go @@ -43,32 +43,33 @@ 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.Recipe == modes.RecipeUnspecified { + global.Stack.Recipe = modes.RecipeAffordable } if region == "" { region = client.GetRegion(global.Stack.Provider) // This sets the default region based on the provider } - estimate, err := cli.RunEstimate(ctx, project, global.Client, previewProvider, global.Stack.Provider, region, global.Stack.Mode) + estimate, err := cli.RunEstimate(ctx, project, global.Client, previewProvider, global.Stack.Provider, region, global.Stack.Recipe) if err != nil { return fmt.Errorf("failed to run estimate: %w", err) } term.Debugf("Estimate: %+v", estimate) - cli.PrintEstimate(global.Stack.Mode, estimate, term.DefaultTerm) + cli.PrintEstimate(global.Stack.Recipe, estimate, term.DefaultTerm) return nil }, } - estimateCmd.Flags().VarP(&global.Stack.Mode, "mode", "m", fmt.Sprintf("deployment mode; one of %v", modes.AllDeploymentModes())) + estimateCmd.Flags().VarP(&global.Stack.Recipe, "mode", "m", fmt.Sprintf("deployment mode; one of %v", modes.AllDeploymentModes())) estimateCmd.Flags().StringVarP(&global.Stack.Region, "region", "r", global.Stack.Region, "which cloud region to estimate") return estimateCmd } 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/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 4881c236c..8d15d84fd 100644 --- a/src/cmd/cli/command/globals.go +++ b/src/cmd/cli/command/globals.go @@ -107,11 +107,11 @@ func NewGlobalConfig() *GlobalConfig { } } - mode := modes.ModeUnspecified - if fromEnv, ok := os.LookupEnv("DEFANG_MODE"); ok { - err := mode.Set(fromEnv) + recipe := modes.RecipeUnspecified + if key, fromEnv := pkg.GetFirstEnv("DEFANG_RECIPE", "DEFANG_MODE"); key != "" { + err := recipe.Set(fromEnv) if err != nil { - term.Debugf("invalid DEFANG_MODE value: %v", err) + term.Debugf("invalid %s value: %v", key, err) } } @@ -137,7 +137,7 @@ func NewGlobalConfig() *GlobalConfig { Stack: stacks.Parameters{ Name: pkg.Getenv("DEFANG_STACK", ""), Provider: provider, - Mode: mode, + Recipe: recipe, Region: client.GetRegion(provider), }, Verbose: pkg.GetenvBool("DEFANG_VERBOSE"), @@ -158,8 +158,8 @@ func (global *GlobalConfig) ToMap() map[string]string { if regionVarName != "" && global.Stack.Region != "" { m[regionVarName] = global.Stack.Region } - if global.Stack.Mode != modes.ModeUnspecified { - m["DEFANG_MODE"] = global.Stack.Mode.String() + if global.Stack.Recipe != modes.RecipeUnspecified { + m["DEFANG_RECIPE"] = global.Stack.Recipe.String() } m["DEFANG_VERBOSE"] = strconv.FormatBool(global.Verbose) return m diff --git a/src/cmd/cli/command/globals_test.go b/src/cmd/cli/command/globals_test.go index 5103e8897..98f91a8c8 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, Recipe: modes.RecipeUnspecified}, FabricAddr: "", TenantSelection: "", } @@ -61,7 +61,6 @@ func Test_configurationPrecedence(t *testing.T) { flags.BoolVarP(&testConfig.Verbose, "verbose", "v", testConfig.Verbose, "verbose logging") flags.BoolVar(&testConfig.Debug, "debug", testConfig.Debug, "debug logging for troubleshooting the CLI") flags.BoolVar(&testConfig.NonInteractive, "non-interactive", testConfig.NonInteractive, "disable interactive prompts / no TTY") - flags.VarP(&testConfig.Stack.Mode, "mode", "m", "deployment mode") // Set flags based on user input (these override env and stack file values) for flagName, flagValue := range tt.flags { @@ -109,8 +108,8 @@ func Test_configurationPrecedence(t *testing.T) { if testConfig.Stack.Provider != tt.expected.Stack.Provider { t.Errorf("expected Stack.Provider to be '%s', got '%s'", tt.expected.Stack.Provider, testConfig.Stack.Provider) } - if testConfig.Stack.Mode != tt.expected.Stack.Mode { - t.Errorf("expected Stack.Mode to be '%s', got '%s'", tt.expected.Stack.Mode, testConfig.Stack.Mode) + if testConfig.Stack.Recipe != tt.expected.Stack.Recipe { + t.Errorf("expected Stack.Mode to be '%s', got '%s'", tt.expected.Stack.Recipe, testConfig.Stack.Recipe) } if testConfig.FabricAddr != tt.expected.FabricAddr { t.Errorf("expected FabricAddr to be '%s', got '%s'", tt.expected.FabricAddr, testConfig.FabricAddr) diff --git a/src/cmd/cli/command/recipe.go b/src/cmd/cli/command/recipe.go new file mode 100644 index 000000000..a97bebb7e --- /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 recipeCmd = &cobra.Command{ + Use: "recipe", + Aliases: []string{"recipes", "modes", "mode"}, + Short: "Manage workspace recipes (deployment modes)", + } + recipeListCmd := makeRecipeListCmd() + recipeCmd.AddCommand(recipeListCmd) + recipeShowCmd := makeRecipeShowCmd() + recipeCmd.AddCommand(recipeShowCmd) + recipeDeactivateCmd := makeRecipeDeactivateCmd() + recipeCmd.AddCommand(recipeDeactivateCmd) + recipeActivateCmd := makeRecipeActivateCmd() + recipeCmd.AddCommand(recipeActivateCmd) + return recipeCmd +} + +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/session.go b/src/cmd/cli/command/session.go index 4eb2d4f72..a1785d956 100644 --- a/src/cmd/cli/command/session.go +++ b/src/cmd/cli/command/session.go @@ -37,7 +37,7 @@ func newCommandSessionWithOpts(cmd *cobra.Command, opts commandSessionOpts) (*se options := session.SessionLoaderOptions{ LoaderOptions: loaderOptionsForCommand(cmd), GetStackOpts: stacks.GetStackOpts{ - Interactive: !global.NonInteractive, + Interactive: global.Interactive(), Default: global.Stack, SelectStackOptions: stacks.SelectStackOptions{ AllowStackCreation: opts.AllowStackCreation, @@ -57,7 +57,7 @@ func newCommandSessionWithOpts(cmd *cobra.Command, opts commandSessionOpts) (*se return nil, err } if opts.CheckAccountInfo { - if err := session.Provider.Authenticate(ctx, !global.NonInteractive); err != nil { + if err := session.Provider.Authenticate(ctx, global.Interactive()); err != nil { return nil, fmt.Errorf("failed to authenticate with provider %q: %w", session.Stack.Provider, err) } _, err = session.Provider.AccountInfo(ctx) diff --git a/src/cmd/cli/command/stack.go b/src/cmd/cli/command/stack.go index 5f6b5b40e..cd6b440c9 100644 --- a/src/cmd/cli/command/stack.go +++ b/src/cmd/cli/command/stack.go @@ -68,13 +68,17 @@ func makeStackNewCmd() *cobra.Command { } } - var region, _ = cmd.Flags().GetString("region") - params := stacks.Parameters{ Name: stackName, Provider: global.Stack.Provider, // default provider - Region: region, - Mode: global.Stack.Mode, + Region: global.Stack.Region, + Recipe: global.Stack.Recipe, + } + + if params.Recipe != "" { + if err := validateRecipeName(ctx, params.Recipe); err != nil { + return err + } } if global.NonInteractive { @@ -106,7 +110,8 @@ func makeStackNewCmd() *cobra.Command { return nil }, } - stackNewCmd.Flags().VarP(&global.Stack.Mode, "mode", "m", fmt.Sprintf("deployment mode; one of %v", modes.AllDeploymentModes())) + stackNewCmd.Flags().VarP(&global.Stack.Recipe, "mode", "m", fmt.Sprintf("legacy deployment mode; one of %v", modes.AllDeploymentModes())) + stackNewCmd.Flags().Var(&global.Stack.Recipe, "recipe", "deployment mode/recipe for the new stack") stackNewCmd.Flags().StringVarP(&global.Stack.Region, "region", "r", global.Stack.Region, "Cloud region for the stack deployment") return stackNewCmd @@ -252,14 +257,13 @@ func makeStackShowCmd() *cobra.Command { } func promptForStackParameters(ctx context.Context, params *stacks.Parameters) error { - wizard := stacks.NewWizard(ec) + wizard := stacks.NewWizard(ec, global.Client) newParams, err := wizard.CollectRemainingParameters(ctx, params) if err != nil { return err } *params = *newParams - return nil } @@ -279,3 +283,26 @@ func remoteStackExists(ctx context.Context, project string, stack string) (bool, } return resp.GetStack() != nil, nil } + +func validateRecipeName(ctx context.Context, recipeName modes.Recipe) error { + // Validate the recipe name by fetching the list from the server. + resp, err := global.Client.ListRecipes(ctx) + if err != nil { + term.Warnf("unable to validate recipe %q: %v", recipeName, err) + return nil + } + + var activeRecipes []string + for _, r := range resp.GetRecipes() { + if modes.ParseRecipe(r.GetName()) == recipeName { + if !r.GetActive() { + term.Warnf("recipe %q is currently deactivated", recipeName) + } + return nil + } + if r.GetActive() { + activeRecipes = append(activeRecipes, r.GetName()) + } + } + return fmt.Errorf("invalid recipe %q; available recipes: %v", recipeName, activeRecipes) +} diff --git a/src/cmd/cli/command/stack_test.go b/src/cmd/cli/command/stack_test.go index ab6ad26c7..db7d3dadf 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, + Recipe: modes.RecipeAffordable, }, { Name: "teststack2", Provider: client.ProviderGCP, Region: "us-central1", - Mode: modes.ModeBalanced, + Recipe: 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, + Recipe: 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, + Recipe: 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, + Recipe: 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, + Recipe: 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, + Recipe: modes.RecipeAffordable, Variables: map[string]string{ "AWS_PROFILE": "default", }, @@ -247,7 +247,7 @@ func TestLoadStackEnv(t *testing.T) { "DEFANG_PROVIDER": "aws", "AWS_REGION": "us-west-2", "AWS_PROFILE": "default", - "DEFANG_MODE": "affordable", + "DEFANG_RECIPE": "affordable", }, }, { @@ -255,7 +255,7 @@ func TestLoadStackEnv(t *testing.T) { parameters: stacks.Parameters{ Provider: client.ProviderGCP, Region: "us-central1", - Mode: modes.ModeBalanced, + Recipe: modes.RecipeBalanced, Variables: map[string]string{ "GCP_PROJECT_ID": "my-gcp-project", "DEFANG_PREFIX": "test", @@ -266,7 +266,7 @@ func TestLoadStackEnv(t *testing.T) { "DEFANG_PROVIDER": "gcp", "GOOGLE_REGION": "us-central1", "GCP_PROJECT_ID": "my-gcp-project", - "DEFANG_MODE": "balanced", + "DEFANG_RECIPE": "balanced", "DEFANG_PREFIX": "test", "DEFANG_SUFFIX": "dev", }, @@ -276,7 +276,7 @@ func TestLoadStackEnv(t *testing.T) { parameters: stacks.Parameters{ Provider: client.ProviderAWS, Region: "us-west-2", - Mode: modes.ModeAffordable, + Recipe: modes.RecipeAffordable, Variables: map[string]string{ "AWS_PROFILE": "default", "DEFANG_PREFIX": "test", @@ -287,11 +287,49 @@ func TestLoadStackEnv(t *testing.T) { "DEFANG_PROVIDER": "aws", "AWS_REGION": "us-west-2", "AWS_PROFILE": "default", - "DEFANG_MODE": "affordable", + "DEFANG_RECIPE": "affordable", "DEFANG_PREFIX": "test", "DEFANG_SUFFIX": "dev", }, }, + { + name: "Azure parameters", + parameters: stacks.Parameters{ + Provider: client.ProviderAzure, + Region: "eastus", + Recipe: modes.RecipeBalanced, + Variables: map[string]string{ + "AZURE_SUBSCRIPTION_ID": "my-azure-subscription", + "DEFANG_PREFIX": "test", + "DEFANG_SUFFIX": "dev", + }, + }, + expectedEnv: map[string]string{ + "DEFANG_PROVIDER": "azure", + "AZURE_LOCATION": "eastus", + "AZURE_SUBSCRIPTION_ID": "my-azure-subscription", + "DEFANG_RECIPE": "balanced", + "DEFANG_PREFIX": "test", + "DEFANG_SUFFIX": "dev", + }, + }, + { + name: "With custom recipe", + parameters: stacks.Parameters{ + Provider: client.ProviderAWS, + Region: "us-west-2", + Recipe: "my-custom-recipe", + Variables: map[string]string{ + "AWS_PROFILE": "default", + }, + }, + expectedEnv: map[string]string{ + "DEFANG_PROVIDER": "aws", + "AWS_REGION": "us-west-2", + "AWS_PROFILE": "default", + "DEFANG_RECIPE": "my-custom-recipe", + }, + }, } for _, tt := range tests { diff --git a/src/pkg/agent/tools/create_aws_stack.go b/src/pkg/agent/tools/create_aws_stack.go index 9807ef8d7..fc9e2daa7 100644 --- a/src/pkg/agent/tools/create_aws_stack.go +++ b/src/pkg/agent/tools/create_aws_stack.go @@ -14,20 +14,15 @@ type CreateAWSStackParams struct { Name string `json:"stack" jsonschema:"required,description=The name of the stack to use for all tool calls."` Region string `json:"region" jsonschema:"required,description=The AWS region to create the stack in."` AWS_Profile string `json:"aws_profile" jsonschema:"required,description=The AWS profile to use when creating the stack."` - Mode string `json:"mode" jsonschema:"enum=affordable,enum=balanced,enum=high_availability,description=The deployment mode for the stack."` + Recipe string `json:"recipe" jsonschema:"enum=affordable,enum=balanced,enum=high_availability,description=The deployment mode/recipe for the stack."` } 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, + Recipe: modes.ParseRecipe(params.Recipe), 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..425a8f3e5 100644 --- a/src/pkg/agent/tools/create_azure_stack.go +++ b/src/pkg/agent/tools/create_azure_stack.go @@ -14,20 +14,15 @@ type CreateAzureStackParams struct { Name string `json:"stack" jsonschema:"required,description=The name of the stack to use for all tool calls."` Location string `json:"location" jsonschema:"required,description=The Azure location to create the stack in."` AzureSubscriptionID string `json:"azure_subscription_id" jsonschema:"required,description=The Azure subscription ID to use when creating the stack."` - Mode string `json:"mode" jsonschema:"enum=affordable,enum=balanced,enum=high_availability,description=The deployment mode for the stack."` + Recipe string `json:"recipe" jsonschema:"enum=affordable,enum=balanced,enum=high_availability,description=The deployment mode/recipe for the stack."` } 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, + Recipe: modes.ParseRecipe(params.Recipe), 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..977e23e83 100644 --- a/src/pkg/agent/tools/create_gcp_stack.go +++ b/src/pkg/agent/tools/create_gcp_stack.go @@ -14,20 +14,15 @@ type CreateGCPStackParams struct { Name string `json:"stack" jsonschema:"required,description=The name of the stack to use for all tool calls."` Region string `json:"region" jsonschema:"required,description=The GCP region to create the stack in."` GCPProjectID string `json:"gcp_project_id" jsonschema:"required,description=The GCP project ID to use when creating the stack."` - Mode string `json:"mode" jsonschema:"enum=affordable,enum=balanced,enum=high_availability,description=The deployment mode for the stack."` + Recipe string `json:"recipe" jsonschema:"enum=affordable,enum=balanced,enum=high_availability,description=The deployment mode/recipe for the stack."` } 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, + Recipe: modes.ParseRecipe(params.Recipe), 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..08c1a1dc3 100644 --- a/src/pkg/agent/tools/current_stack_test.go +++ b/src/pkg/agent/tools/current_stack_test.go @@ -23,13 +23,13 @@ func TestHandleCurrentStackTool(t *testing.T) { Name: "test-stack", Provider: client.ProviderAWS, Region: "us-test-2", - Mode: modes.ModeAffordable, + Recipe: modes.RecipeAffordable, Variables: map[string]string{ "AWS_PROFILE": "default", }, }, }, - expected: "This currently selected stack is \"test-stack\": AWS_PROFILE=\"default\"\nAWS_REGION=\"us-test-2\"\nDEFANG_MODE=\"affordable\"\nDEFANG_PROVIDER=\"aws\"", + expected: "This currently selected stack is \"test-stack\": AWS_PROFILE=\"default\"\nAWS_REGION=\"us-test-2\"\nDEFANG_PROVIDER=\"aws\"\nDEFANG_RECIPE=\"affordable\"", expectedError: false, }, } 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..92396ce42 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, + Recipe: 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..3cc86c67f 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.ParseRecipe(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/provider.go b/src/pkg/agent/tools/provider.go index 0b5886ea1..46ce113fc 100644 --- a/src/pkg/agent/tools/provider.go +++ b/src/pkg/agent/tools/provider.go @@ -34,7 +34,7 @@ func NewProviderPreparer(pc ProviderCreator, ec elicitations.Controller, fc clie func (pp *providerPreparer) SetupProvider(ctx context.Context, stack *stacks.Parameters) (*client.ProviderID, client.Provider, error) { if stack.Name == "" && pp.ec.IsSupported() { - selector := stacks.NewSelector(pp.ec, pp.sm) + selector := stacks.NewSelector(pp.ec, pp.sm, pp.fc) newStack, err := selector.SelectStack(ctx, stacks.SelectStackOptions{ AllowStackCreation: true, }) diff --git a/src/pkg/agent/tools/select_stack_test.go b/src/pkg/agent/tools/select_stack_test.go index 4782557d0..28388cfdd 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, + Recipe: 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, + Recipe: 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/byoc/aws/byoc.go b/src/pkg/cli/client/byoc/aws/byoc.go index e8ef67135..d4918cf72 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 609f9b215..b972fd5b2 100644 --- a/src/pkg/cli/client/byoc/azure/byoc.go +++ b/src/pkg/cli/client/byoc/azure/byoc.go @@ -533,6 +533,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..b3c982737 100644 --- a/src/pkg/cli/client/byoc/gcp/byoc.go +++ b/src/pkg/cli/client/byoc/gcp/byoc.go @@ -26,6 +26,7 @@ import ( "github.com/DefangLabs/defang/src/pkg/dns" "github.com/DefangLabs/defang/src/pkg/http" "github.com/DefangLabs/defang/src/pkg/logs" + "github.com/DefangLabs/defang/src/pkg/modes" "github.com/DefangLabs/defang/src/pkg/term" "github.com/DefangLabs/defang/src/pkg/tokenstore" "github.com/DefangLabs/defang/src/pkg/types" @@ -398,7 +399,7 @@ type cdCommand struct { command []string delegateDomain string etag types.ETag - mode defangv1.DeploymentMode + recipe modes.Recipe project string statesUrl string eventsUrl string @@ -422,7 +423,7 @@ func (b *ByocGcp) runCdCommand(ctx context.Context, cmd cdCommand) (string, erro "DEFANG_CD_IMAGE": b.CDImage, // used by down/destroy to schedule cleanup job with the same image "DEFANG_DEBUG": os.Getenv("DEFANG_DEBUG"), // TODO: use the global DoDebug flag "DEFANG_JSON": os.Getenv("DEFANG_JSON"), - "DEFANG_MODE": strings.ToLower(cmd.mode.String()), + "DEFANG_MODE": strings.ToLower(cmd.recipe.Mode().String()), "DEFANG_ORG": string(b.TenantLabel), "DEFANG_PREFIX": b.Prefix, "DEFANG_PULUMI_DEBUG": os.Getenv("DEFANG_PULUMI_DEBUG"), @@ -557,6 +558,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 @@ -588,7 +590,7 @@ func (b *ByocGcp) deploy(ctx context.Context, req *client.DeployRequest, command command: []string{command, payload}, delegateDomain: req.DelegateDomain, etag: etag, - mode: req.Mode, + recipe: modes.Recipe(req.RecipeName), project: project.Name, statesUrl: req.StatesUrl, eventsUrl: req.EventsUrl, 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/mock.go b/src/pkg/cli/client/mock.go index dab0cf0ca..74d74d646 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 { @@ -237,6 +238,20 @@ 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) { + 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) ListRecipes(ctx context.Context) (*defangv1.ListRecipesResponse, error) { + if m.Recipe != nil { + return &defangv1.ListRecipesResponse{Recipes: []*defangv1.Recipe{m.Recipe}}, nil + } + return &defangv1.ListRecipesResponse{}, 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/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..dee4de9c2 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 { @@ -79,6 +80,7 @@ func putDeploymentAndStack(ctx context.Context, provider client.Provider, fabric ProviderAccountId: accountInfo.AccountID, LastDeployedAt: now, StackFile: []byte(stackFile), + Recipe: req.Recipe, }, }) if err != nil { @@ -120,6 +122,7 @@ func putDeploymentAndStack(ctx context.Context, provider client.Provider, fabric CdId: req.CdId, CdType: req.CdType, Compose: req.Compose, + Recipe: req.Recipe, }, }) } 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 145dc47ee..1b934943a 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 + Recipe 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.Recipe 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,21 +130,33 @@ 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: recipe.String()}) + 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 !rresp.Recipe.GetActive() && upload != compose.UploadModeEstimate && upload != compose.UploadModePreview { + return nil, project, fmt.Errorf("recipe %q is not active", recipe) } deployRequest := &client.DeployRequest{ DeployRequest: defangv1.DeployRequest{ - Mode: mode.Value(), + Mode: recipe.Mode().Value(), Project: project.Name, Compose: composeYaml, DelegateDomain: delegateDomain.Zone, }, + Recipe: rresp.Recipe, } delegation, err := provider.PrepareDomainDelegation(ctx, client.PrepareDomainDelegationRequest{ @@ -199,7 +211,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, @@ -207,6 +219,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/composeUp_dockerfile_test.go b/src/pkg/cli/composeUp_dockerfile_test.go index c51e63484..7f0e42a53 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, + Recipe: 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, + Recipe: 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, + Recipe: 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..0b15a48e1 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, + Recipe: modes.RecipeAffordable, Project: proj, UploadMode: compose.UploadModeDigest, }) @@ -157,12 +157,50 @@ func TestComposeUp(t *testing.T) { Mode: defangv1.DeploymentMode_PRODUCTION, } _, _, err = ComposeUp(t.Context(), mc, mp, stack, ComposeUpParams{ - Mode: modes.ModeAffordable, + Recipe: modes.RecipeAffordable, Project: proj, UploadMode: compose.UploadModeDigest, }) 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{ + Recipe: 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{ + Recipe: modes.RecipeAffordable, + Project: proj, + UploadMode: tc.mode, + }) + require.NoError(t, err) + }) + } + }) } func TestSplitManagedAndUnmanagedServices(t *testing.T) { @@ -334,7 +372,7 @@ func TestComposeUpStops(t *testing.T) { } resp, project, err := ComposeUp(ctx, fabric, provider, stack, ComposeUpParams{ - Mode: modes.ModeUnspecified, + Recipe: modes.RecipeUnspecified, Project: project, UploadMode: compose.UploadModeDigest, }) @@ -369,7 +407,7 @@ func TestComposeConfigWithoutLogin(t *testing.T) { stack := &stacks.Parameters{} _, _, err := ComposeUp(t.Context(), fabric, provider, stack, ComposeUpParams{ - Mode: modes.ModeUnspecified, + Recipe: modes.RecipeUnspecified, Project: project, UploadMode: compose.UploadModeIgnore, }) diff --git a/src/pkg/cli/estimate.go b/src/pkg/cli/estimate.go index 4ea2ffd8f..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, 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, 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, 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, mode modes.Mode, 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,12 +56,17 @@ func GeneratePreview(ctx context.Context, project *compose.Project, client clien term.Debugf("Fixedup project: %s", string(composeData)) - 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: mode.Value(), + Mode: recipe.Mode().Value(), Region: region, Compose: composeData, ProjectName: project.Name, + Recipe: rresp.Recipe, }) if err != nil { return "", err @@ -115,22 +120,19 @@ 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: - term.Println("Estimate for Deployment Mode: AFFORDABLE") + case modes.RecipeAffordable: term.Println(affordableModeEstimateSummary) - case modes.ModeBalanced: - term.Println("Estimate for Deployment Mode: BALANCED") + case modes.RecipeBalanced: term.Println(balancedModeEstimateSummary) - case modes.ModeHighAvailability: - term.Println("Estimate for Deployment Mode: HIGH_AVAILABILITY") + case modes.RecipeHighAvailability: term.Println(highAvailabilityModeEstimateSummary) default: - panic("unexpected mode") } term.Table(tableItems, "Cost", "Quantity", "Service", "Description") diff --git a/src/pkg/cli/preview_test.go b/src/pkg/cli/preview_test.go index ef45449b9..4ea11446d 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{Recipe: 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{Recipe: modes.RecipeUnspecified, Project: project}) if err != nil { t.Errorf("got error: %v, want nil", err) } diff --git a/src/pkg/cli/recipeActivate.go b/src/pkg/cli/recipeActivate.go new file mode 100644 index 000000000..16a06f9b9 --- /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) + } + + recipe := resp.GetRecipe() + recipe.Active = active + err = fabric.PutRecipe(ctx, &defangv1.PutRecipeRequest{ + Recipe: recipe, + }) + if err != nil { + return err + } + + state := "active" + if !recipe.Active { + state = "inactive" + } + term.Info(fmt.Sprintf("Recipe %q is now %s.", recipe.Name, state)) + return nil +} 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..c825caf3e --- /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.GetPulumiConfig()) + return err +} diff --git a/src/pkg/cli/stacks.go b/src/pkg/cli/stacks.go index ca9dbd924..51be20dde 100644 --- a/src/pkg/cli/stacks.go +++ b/src/pkg/cli/stacks.go @@ -42,11 +42,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.Recipe.Mode().Value(), + Recipe: &defangv1.Recipe{ + Name: stack.Recipe.String(), + }, IsDefault: true, StackFile: []byte(stackFile), }, diff --git a/src/pkg/cli/stacks_test.go b/src/pkg/cli/stacks_test.go index a4afbe513..722f352a9 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, + Recipe: 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, + Recipe: 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, + Recipe: 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", Recipe: 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", Recipe: 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", Recipe: 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", Recipe: 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", Recipe: modes.RecipeAffordable}) assert.NoError(t, err) remover := &mockStacksRemover{} diff --git a/src/pkg/cli/tail_test.go b/src/pkg/cli/tail_test.go index 135f495b0..58ef3282a 100644 --- a/src/pkg/cli/tail_test.go +++ b/src/pkg/cli/tail_test.go @@ -553,6 +553,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/mode.go similarity index 93% rename from src/pkg/modes/modes.go rename to src/pkg/modes/mode.go index 5ccd354ce..2969493a5 100644 --- a/src/pkg/modes/modes.go +++ b/src/pkg/modes/mode.go @@ -9,6 +9,7 @@ import ( defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" ) +// Deprecated: use Recipe instead of deployment Mode type Mode defangv1.DeploymentMode const ( @@ -72,6 +73,7 @@ func (b Mode) Value() defangv1.DeploymentMode { return defangv1.DeploymentMode(b) } +// Deprecated: query active Recipes instead of deployment Mode func AllDeploymentModes() []string { var modes []string for _, i := range slices.Sorted(maps.Keys(defangv1.DeploymentMode_name)) { 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/recipe.go b/src/pkg/modes/recipe.go new file mode 100644 index 000000000..1dc4fd499 --- /dev/null +++ b/src/pkg/modes/recipe.go @@ -0,0 +1,74 @@ +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 "MODE_UNSPECIFIED": + return RecipeUnspecified + 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" +} + +// Mode converts a Recipe to a protobuf DeploymentMode; it is the inverse of FromMode. +// Deprecated: for backwards compat; custom recipes have no corresponding DeploymentMode. +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..cf2fb9a66 100644 --- a/src/pkg/session/session.go +++ b/src/pkg/session/session.go @@ -79,8 +79,8 @@ 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 { - stack.Mode = newMode + if newMode := sl.opts.Default.Recipe; newMode != modes.RecipeUnspecified { + stack.Recipe = newMode } if err := stacks.LoadStackEnv(*stack, true); err != nil { diff --git a/src/pkg/session/session_test.go b/src/pkg/session/session_test.go index f9ca984bc..9944287e6 100644 --- a/src/pkg/session/session_test.go +++ b/src/pkg/session/session_test.go @@ -151,20 +151,20 @@ func TestLoadSession(t *testing.T) { options: SessionLoaderOptions{ GetStackOpts: stacks.GetStackOpts{ Default: stacks.Parameters{ - Name: "existingstack", - Mode: modes.ModeAffordable, + Name: "existingstack", + Recipe: modes.RecipeAffordable, }, }, }, existingStack: &stacks.Parameters{ Name: "existingstack", Provider: client.ProviderAWS, - Mode: modes.ModeBalanced, + Recipe: modes.RecipeBalanced, }, expectedStack: &stacks.Parameters{ Name: "existingstack", Provider: client.ProviderAWS, - Mode: modes.ModeAffordable, + Recipe: modes.RecipeAffordable, Variables: map[string]string{ "DEFANG_PROVIDER": "aws", "DEFANG_MODE": "affordable", @@ -226,7 +226,7 @@ func TestLoadSession(t *testing.T) { require.NotNil(t, session.Stack) assert.Equal(t, tt.expectedStack.Name, session.Stack.Name) assert.Equal(t, tt.expectedStack.Provider, session.Stack.Provider) - assert.Equal(t, tt.expectedStack.Mode, session.Stack.Mode) + assert.Equal(t, tt.expectedStack.Recipe, session.Stack.Recipe) // Verify environment variables for key, expectedValue := range tt.expectedEnv { diff --git a/src/pkg/stacks/manager.go b/src/pkg/stacks/manager.go index c491eafe1..bbd3d1599 100644 --- a/src/pkg/stacks/manager.go +++ b/src/pkg/stacks/manager.go @@ -23,6 +23,7 @@ type Lister interface { GetDefaultStack(ctx context.Context, req *defangv1.GetDefaultStackRequest) (*defangv1.GetStackResponse, error) GetStack(ctx context.Context, req *defangv1.GetStackRequest) (*defangv1.GetStackResponse, error) ListStacks(ctx context.Context, req *defangv1.ListStacksRequest) (*defangv1.ListStacksResponse, error) + ListRecipes(ctx context.Context) (*defangv1.ListRecipesResponse, error) } type manager struct { @@ -118,7 +119,7 @@ func (sm *manager) ListRemote(ctx context.Context) ([]ListItem, error) { stackParams = append(stackParams, ListItem{ Name: params.Name, Provider: params.Provider, - Mode: params.Mode, + Mode: params.Recipe, Region: params.Region, Account: account, DeployedAt: deployedAt, @@ -144,8 +145,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.Recipe == modes.RecipeUnspecified { + params.Recipe = modes.FromMode(stack.GetMode()) } if params.Region == "" { params.Region = stack.GetRegion() @@ -234,6 +235,12 @@ func GetFallbackStack(defaults Parameters) (*Parameters, string, error) { whence = "--provider flag" } defaults.Name = DefaultBeta + // When the provider is set via the -P flag, the region default in global.Stack + // was computed before the flag was parsed (provider was still Auto), so it may be + // empty. Resolve the provider's default region here so provider auth can proceed. + if defaults.Region == "" { + defaults.Region = client.GetRegion(defaults.Provider) + } return &defaults, whence, nil } return nil, "", errors.New("no provider specified") @@ -317,7 +324,7 @@ func (sm *manager) getSpecifiedStack(ctx context.Context, name string) (*Paramet } func (sm *manager) getStackInteractively(ctx context.Context, opts GetStackOpts) (*Parameters, string, error) { - stackSelector := NewSelector(sm.ec, sm) + stackSelector := NewSelector(sm.ec, sm, sm.fabric) // TODO: pass fallback stack to selector wizard for pre-filling stack, err := stackSelector.SelectStack(ctx, opts.SelectStackOptions) if err != nil { diff --git a/src/pkg/stacks/manager_test.go b/src/pkg/stacks/manager_test.go index 3c52ce12e..10bf98adf 100644 --- a/src/pkg/stacks/manager_test.go +++ b/src/pkg/stacks/manager_test.go @@ -54,6 +54,10 @@ func (m *mockFabricClient) GetDefaultStack(ctx context.Context, req *defangv1.Ge }, nil } +func (m *mockFabricClient) ListRecipes(ctx context.Context) (*defangv1.ListRecipesResponse, error) { + return &defangv1.ListRecipesResponse{}, nil +} + func TestNewManager(t *testing.T) { workingDir := "/tmp/test-dir" mockClient := &mockFabricClient{} @@ -89,7 +93,7 @@ func TestManager_CreateListLoad(t *testing.T) { Variables: map[string]string{ "AWS_PROFILE": "default", }, - Mode: modes.ModeAffordable, + Recipe: modes.RecipeAffordable, } filename, err := manager.Create(params) @@ -111,7 +115,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 +124,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.Recipe, "Expected mode affordable") } func TestManager_CreateGCPStack(t *testing.T) { @@ -143,7 +147,7 @@ func TestManager_CreateGCPStack(t *testing.T) { Variables: map[string]string{ "GCP_PROJECT_ID": "my-project", }, - Mode: modes.ModeBalanced, + Recipe: modes.RecipeBalanced, } filename, err := manager.Create(params) @@ -157,7 +161,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.Recipe, "Expected mode balanced") } func TestManager_CreateMultipleStacks(t *testing.T) { @@ -181,7 +185,7 @@ func TestManager_CreateMultipleStacks(t *testing.T) { Variables: map[string]string{ "AWS_PROFILE": "default", }, - Mode: modes.ModeAffordable, + Recipe: modes.RecipeAffordable, }, { Name: "stack2", @@ -190,13 +194,13 @@ func TestManager_CreateMultipleStacks(t *testing.T) { Variables: map[string]string{ "GCP_PROJECT_ID": "project2", }, - Mode: modes.ModeHighAvailability, + Recipe: modes.RecipeHighAvailability, }, { Name: "stack3", Provider: client.ProviderDO, Region: "nyc1", - Mode: modes.ModeBalanced, + Recipe: modes.RecipeBalanced, }, } @@ -221,6 +225,60 @@ func TestManager_CreateMultipleStacks(t *testing.T) { } } +func TestGetFallbackStack(t *testing.T) { + tests := []struct { + name string + defaults Parameters + awsRegion string + wantErr bool + wantRegion string + }{ + { + name: "no provider errors", + defaults: Parameters{Provider: client.ProviderAuto}, + wantErr: true, + }, + { + // The -P flag sets the provider after the region default was computed + // (while provider was still Auto), so region arrives empty and must be + // filled with the provider default. + name: "provider set but region empty gets provider default", + defaults: Parameters{Provider: client.ProviderAWS}, + wantRegion: client.RegionDefaultAWS, + }, + { + name: "empty region falls back to AWS_REGION env", + defaults: Parameters{Provider: client.ProviderAWS}, + awsRegion: "eu-west-1", + wantRegion: "eu-west-1", + }, + { + name: "explicit region is preserved", + defaults: Parameters{Provider: client.ProviderAWS, Region: "ap-south-1"}, + awsRegion: "eu-west-1", + wantRegion: "ap-south-1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("AWS_REGION", tt.awsRegion) // registers restore of the original value + if tt.awsRegion == "" { + os.Unsetenv("AWS_REGION") + } + + stack, _, err := GetFallbackStack(tt.defaults) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, DefaultBeta, stack.Name) + assert.Equal(t, tt.wantRegion, stack.Region) + }) + } +} + func TestManager_LoadNonexistentStack(t *testing.T) { // Create a temporary directory for testing tmpDir := t.TempDir() @@ -281,7 +339,7 @@ func TestManager_CreateDuplicateStack(t *testing.T) { Name: "duplicatestack", Provider: client.ProviderAWS, Region: "us-east-1", - Mode: modes.ModeAffordable, + Recipe: modes.RecipeAffordable, } // Create the first stack @@ -393,7 +451,7 @@ GOOGLE_REGION=us-central1 Variables: map[string]string{ "AWS_PROFILE": "default", }, - Mode: modes.ModeAffordable, + Recipe: modes.RecipeAffordable, } _, err = manager.Create(localParams) require.NoError(t, err, "First Create() failed") @@ -406,7 +464,7 @@ GOOGLE_REGION=us-central1 Variables: map[string]string{ "AWS_PROFILE": "default", }, - Mode: modes.ModeAffordable, + Recipe: modes.RecipeAffordable, } _, err = manager.Create(localOnlyParams) require.NoError(t, err, "Create() failed") @@ -430,7 +488,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 +602,7 @@ func TestManager_WorkingDirectoryMatches(t *testing.T) { Variables: map[string]string{ "AWS_PROFILE": "default", }, - Mode: modes.ModeAffordable, + Recipe: modes.RecipeAffordable, } // Create should work @@ -601,7 +659,7 @@ GOOGLE_REGION=us-central1 Variables: map[string]string{ "AWS_PROFILE": "default", }, - Mode: modes.ModeAffordable, + Recipe: modes.RecipeAffordable, } // Create should fail diff --git a/src/pkg/stacks/selector.go b/src/pkg/stacks/selector.go index b5465bacb..4ce72bf92 100644 --- a/src/pkg/stacks/selector.go +++ b/src/pkg/stacks/selector.go @@ -25,11 +25,11 @@ type stackSelector struct { wizard *Wizard } -func NewSelector(ec elicitations.Controller, sm Manager) *stackSelector { +func NewSelector(ec elicitations.Controller, sm Manager, recipeLister RecipeLister) *stackSelector { return &stackSelector{ ec: ec, sm: sm, - wizard: NewWizard(ec), + wizard: NewWizard(ec, recipeLister), } } diff --git a/src/pkg/stacks/selector_test.go b/src/pkg/stacks/selector_test.go index 09741eec0..6e6a5a0f4 100644 --- a/src/pkg/stacks/selector_test.go +++ b/src/pkg/stacks/selector_test.go @@ -121,10 +121,10 @@ func TestStackSelector_SelectStack_ExistingStack(t *testing.T) { Name: "production", Provider: client.ProviderAWS, Region: "us-west-2", - Mode: modes.ModeUnspecified, + Recipe: modes.RecipeUnspecified, } - selector := NewSelector(mockEC, mockSM) + selector := NewSelector(mockEC, mockSM, nil) result, err := selector.SelectStack(ctx, SelectStackOptions{}) @@ -165,10 +165,10 @@ func TestStackSelector_SelectOrCreateStack_ExistingStack(t *testing.T) { Name: "production", Provider: client.ProviderAWS, Region: "us-west-2", - Mode: modes.ModeUnspecified, + Recipe: modes.RecipeUnspecified, } - selector := NewSelector(mockEC, mockSM) + selector := NewSelector(mockEC, mockSM, nil) result, err := selector.SelectStack(ctx, SelectStackOptions{AllowStackCreation: true}) @@ -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?", "recipe", modes.AllDeploymentModes()).Return("BALANCED", nil) + // Mock wizard parameter collection newStackParams := &Parameters{ Name: "staging", Provider: client.ProviderAWS, Region: "us-east-1", + Recipe: modes.RecipeBalanced, Variables: map[string]string{ "AWS_PROFILE": "staging", }, @@ -251,8 +255,8 @@ func TestStackSelector_SelectStack_CreateNewStack(t *testing.T) { mockProfileLister := &MockAWSProfileLister{} mockProfileLister.On("ListProfiles").Return([]string{"default"}, nil) - selector := NewSelector(mockEC, mockSM) - selector.wizard = NewWizardWithProfileLister(mockEC, mockProfileLister) + selector := NewSelector(mockEC, mockSM, nil) + selector.wizard = NewWizardWithProfileLister(mockEC, nil, mockProfileLister) result, err := selector.SelectStack(ctx, SelectStackOptions{AllowStackCreation: true}) @@ -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?", "recipe", modes.AllDeploymentModes()).Return("BALANCED", nil) + // Mock wizard parameter collection newStackParams := &Parameters{ Name: "firststack", Provider: client.ProviderAWS, Region: "us-west-2", + Recipe: modes.RecipeBalanced, Variables: map[string]string{ "AWS_PROFILE": "default", }, @@ -327,8 +335,8 @@ func TestStackSelector_SelectStack_NoExistingStacks(t *testing.T) { mockProfileLister := &MockAWSProfileLister{} mockProfileLister.On("ListProfiles").Return([]string{"default"}, nil) - selector := NewSelector(mockEC, mockSM) - selector.wizard = NewWizardWithProfileLister(mockEC, mockProfileLister) + selector := NewSelector(mockEC, mockSM, nil) + selector.wizard = NewWizardWithProfileLister(mockEC, nil, mockProfileLister) result, err := selector.SelectStack(ctx, SelectStackOptions{AllowStackCreation: true}) @@ -349,7 +357,7 @@ func TestStackSelector_SelectStack_ElicitationsNotSupported(t *testing.T) { // Mock that elicitations are not supported mockEC.On("IsSupported").Return(false) - selector := NewSelector(mockEC, mockSM) + selector := NewSelector(mockEC, mockSM, nil) result, err := selector.SelectStack(ctx, SelectStackOptions{}) @@ -373,7 +381,7 @@ func TestStackSelector_SelectStack_ListStacksError(t *testing.T) { // Mock error when listing stacks mockSM.On("List", ctx).Return([]ListItem{}, errors.New("failed to access stack storage")) - selector := NewSelector(mockEC, mockSM) + selector := NewSelector(mockEC, mockSM, nil) result, err := selector.SelectStack(ctx, SelectStackOptions{}) @@ -404,7 +412,7 @@ func TestStackSelector_SelectStack_ElicitationError(t *testing.T) { expectedOptions := []string{"production [aws us-west-2]"} mockEC.On("RequestEnum", ctx, "Select a stack", "stack", expectedOptions).Return("", errors.New("user cancelled selection")) - selector := NewSelector(mockEC, mockSM) + selector := NewSelector(mockEC, mockSM, nil) result, err := selector.SelectStack(ctx, SelectStackOptions{}) @@ -439,7 +447,7 @@ func TestStackSelector_SelectStack_WizardError(t *testing.T) { providerOptions := []string{"Defang Playground", "AWS", "DigitalOcean", "Google Cloud Platform", "Azure"} mockEC.On("RequestEnum", ctx, "Where do you want to deploy?", "provider", providerOptions).Return("", errors.New("user cancelled wizard")) - selector := NewSelector(mockEC, mockSM) + selector := NewSelector(mockEC, mockSM, nil) result, err := selector.SelectStack(ctx, SelectStackOptions{AllowStackCreation: true}) assert.Error(t, err) @@ -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?", "recipe", modes.AllDeploymentModes()).Return("BALANCED", nil) + // Mock wizard parameter collection newStackParams := &Parameters{ Name: "staging", Provider: client.ProviderAWS, Region: "us-east-1", + Recipe: modes.RecipeBalanced, Variables: map[string]string{ "AWS_PROFILE": "staging", }, @@ -523,8 +535,8 @@ func TestStackSelector_SelectStack_CreateStackError(t *testing.T) { mockProfileLister := &MockAWSProfileLister{} mockProfileLister.On("ListProfiles").Return([]string{"default"}, nil) - selector := NewSelector(mockEC, mockSM) - selector.wizard = NewWizardWithProfileLister(mockEC, mockProfileLister) + selector := NewSelector(mockEC, mockSM, nil) + selector.wizard = NewWizardWithProfileLister(mockEC, nil, mockProfileLister) result, err := selector.SelectStack(ctx, SelectStackOptions{AllowStackCreation: true}) assert.Error(t, err) @@ -566,7 +578,7 @@ func TestStackSelector_SelectStack_ShowsAccountInLabel(t *testing.T) { Region: "us-west-2", } - selector := NewSelector(mockEC, mockSM) + selector := NewSelector(mockEC, mockSM, nil) result, err := selector.SelectStack(ctx, SelectStackOptions{}) assert.NoError(t, err) diff --git a/src/pkg/stacks/stacks.go b/src/pkg/stacks/stacks.go index 28860b374..32987b9e2 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 + Recipe modes.Recipe Region string Variables map[string]string } @@ -49,8 +49,8 @@ func (sp Parameters) ToMap() map[string]string { vars[regionVarName] = sp.Region } } - if sp.Mode != modes.ModeUnspecified { - vars["DEFANG_MODE"] = strings.ToLower(sp.Mode.String()) + if sp.Recipe != modes.RecipeUnspecified { + vars["DEFANG_RECIPE"] = strings.ToLower(sp.Recipe.String()) } return vars } @@ -69,18 +69,17 @@ 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 - if val, ok := variables["DEFANG_MODE"]; ok { - err := mode.Set(val) - if err != nil { - return nil, fmt.Errorf("invalid DEFANG_MODE value: %w", err) - } + var recipe modes.Recipe + if val, ok := variables["DEFANG_RECIPE"]; ok { + recipe = modes.ParseRecipe(val) + } else if val, ok := variables["DEFANG_MODE"]; ok { + recipe = modes.ParseRecipe(val) } return &Parameters{ Variables: variables, Provider: provider, Region: region, - Mode: mode, + Recipe: recipe, }, nil } @@ -159,7 +158,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 @@ -197,7 +196,7 @@ func ListInDirectory(workingDirectory string) ([]ListItem, error) { stacks = append(stacks, ListItem{ Name: params.Name, Provider: params.Provider, - Mode: params.Mode, + Mode: params.Recipe, Region: params.Region, Account: params.Account(), }) diff --git a/src/pkg/stacks/stacks_test.go b/src/pkg/stacks/stacks_test.go index e66b32f6a..fc17eab87 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, + Recipe: 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, + Recipe: 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, + Recipe: 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, + Recipe: 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, + Recipe: modes.RecipeAffordable, }, expectErr: true, }, @@ -124,7 +124,7 @@ func TestRepeatCreate(t *testing.T) { Name: "repeattest", Provider: client.ProviderGCP, Region: "us-central1", - Mode: modes.ModeBalanced, + Recipe: 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, + Recipe: modes.RecipeAffordable, } stackFile, err := CreateInDirectory(".", params) if err != nil { @@ -218,9 +218,9 @@ func TestMarshal(t *testing.T) { Name: "teststack", Provider: client.ProviderGCP, Region: "us-central1", - Mode: modes.ModeBalanced, + Recipe: modes.RecipeBalanced, }, - expectedContent: "DEFANG_MODE=\"balanced\"\nDEFANG_PROVIDER=\"gcp\"\nGOOGLE_REGION=\"us-central1\"", + expectedContent: "DEFANG_PROVIDER=\"gcp\"\nDEFANG_RECIPE=\"balanced\"\nGOOGLE_REGION=\"us-central1\"", }, { name: "AWS provider", @@ -228,9 +228,9 @@ func TestMarshal(t *testing.T) { Name: "awsstack", Provider: client.ProviderAWS, Region: "us-east-1", - Mode: modes.ModeAffordable, + Recipe: modes.RecipeAffordable, }, - expectedContent: "AWS_REGION=\"us-east-1\"\nDEFANG_MODE=\"affordable\"\nDEFANG_PROVIDER=\"aws\"", + expectedContent: "AWS_REGION=\"us-east-1\"\nDEFANG_PROVIDER=\"aws\"\nDEFANG_RECIPE=\"affordable\"", }, { name: "Unspecified mode", @@ -238,7 +238,7 @@ func TestMarshal(t *testing.T) { Name: "nomodestack", Provider: client.ProviderAWS, Region: "us-west-1", - Mode: modes.ModeUnspecified, + Recipe: modes.RecipeUnspecified, }, expectedContent: "AWS_REGION=\"us-west-1\"\nDEFANG_PROVIDER=\"aws\"", }, @@ -248,9 +248,9 @@ func TestMarshal(t *testing.T) { Name: "noregionstack", Provider: client.ProviderGCP, Region: "", - Mode: modes.ModeAffordable, + Recipe: modes.RecipeAffordable, }, - expectedContent: "DEFANG_MODE=\"affordable\"\nDEFANG_PROVIDER=\"gcp\"", + expectedContent: "DEFANG_PROVIDER=\"gcp\"\nDEFANG_RECIPE=\"affordable\"", }, } @@ -280,7 +280,7 @@ DEFANG_MODE=BALANCED expectedParams: Parameters{ Provider: client.ProviderGCP, Region: "us-central1", - Mode: modes.ModeBalanced, + Recipe: modes.RecipeBalanced, }, }, { @@ -292,22 +292,45 @@ DEFANG_MODE=AFFORDABLE expectedParams: Parameters{ Provider: client.ProviderAWS, Region: "us-east-1", - Mode: modes.ModeAffordable, + Recipe: modes.RecipeAffordable, + }, + }, + { + name: "Azure provider", + content: `DEFANG_PROVIDER=azure +AZURE_LOCATION=eastus +DEFANG_MODE=STAGING +`, + expectedParams: Parameters{ + Provider: client.ProviderAzure, + Region: "eastus", + Recipe: modes.RecipeBalanced, + }, + }, + { + name: "With recipe", + content: `DEFANG_PROVIDER=aws +AWS_REGION=us-west-2 +DEFANG_RECIPE=affordable +`, + expectedParams: Parameters{ + Provider: client.ProviderAWS, + Region: "us-west-2", + Recipe: modes.RecipeAffordable, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - params, err := parseContent(tt.content) + params, err := NewParametersFromContent(tt.name, []byte(tt.content)) if err != nil { t.Errorf("Parse() error = %v", err) return } - regionEnvVarName := client.GetRegionVarName(tt.expectedParams.Provider) - assert.Equal(t, tt.expectedParams.Provider.String(), params["DEFANG_PROVIDER"]) - assert.Equal(t, tt.expectedParams.Region, params[regionEnvVarName]) - assert.Equal(t, tt.expectedParams.Mode.String(), params["DEFANG_MODE"]) + assert.Equal(t, tt.expectedParams.Provider, params.Provider) + assert.Equal(t, tt.expectedParams.Region, params.Region) + assert.Equal(t, tt.expectedParams.Recipe, params.Recipe) }) } } @@ -321,7 +344,7 @@ func TestReadInDirectory(t *testing.T) { Name: stackName, Provider: client.ProviderAWS, Region: "us-west-2", - Mode: modes.ModeAffordable, + Recipe: modes.RecipeAffordable, } _, err := CreateInDirectory(".", expectedParams) if err != nil { @@ -334,7 +357,7 @@ func TestReadInDirectory(t *testing.T) { } if params.Provider != expectedParams.Provider || params.Region != expectedParams.Region || - params.Mode != expectedParams.Mode { + params.Recipe != expectedParams.Recipe { t.Errorf("Read() = %v, want %v", params, expectedParams) } }) @@ -355,13 +378,13 @@ func TestParamsToMap(t *testing.T) { Variables: map[string]string{ "AWS_PROFILE": "default", }, - Mode: modes.ModeAffordable, + Recipe: modes.RecipeAffordable, }, expectedMap: map[string]string{ "DEFANG_PROVIDER": "aws", "AWS_REGION": "us-west-2", "AWS_PROFILE": "default", - "DEFANG_MODE": "affordable", + "DEFANG_RECIPE": "affordable", }, }, { @@ -373,13 +396,13 @@ func TestParamsToMap(t *testing.T) { Variables: map[string]string{ "GCP_PROJECT_ID": "gcp-project-123", }, - Mode: modes.ModeBalanced, + Recipe: modes.RecipeBalanced, }, expectedMap: map[string]string{ "DEFANG_PROVIDER": "gcp", "GOOGLE_REGION": "us-central1", "GCP_PROJECT_ID": "gcp-project-123", - "DEFANG_MODE": "balanced", + "DEFANG_RECIPE": "balanced", }, }, } @@ -410,12 +433,12 @@ func TestParamsFromMap(t *testing.T) { inputMap: map[string]string{ "DEFANG_PROVIDER": "gcp", "GOOGLE_REGION": "us-central1", - "DEFANG_MODE": "balanced", + "DEFANG_RECIPE": "balanced", }, expectedParams: Parameters{ Provider: client.ProviderGCP, Region: "us-central1", - Mode: modes.ModeBalanced, + Recipe: modes.RecipeBalanced, }, }, { @@ -424,7 +447,7 @@ func TestParamsFromMap(t *testing.T) { "DEFANG_PROVIDER": "aws", "AWS_REGION": "us-west-2", "AWS_PROFILE": "default", - "DEFANG_MODE": "affordable", + "DEFANG_RECIPE": "affordable", }, expectedParams: Parameters{ Provider: client.ProviderAWS, @@ -432,7 +455,7 @@ func TestParamsFromMap(t *testing.T) { Variables: map[string]string{ "AWS_PROFILE": "default", }, - Mode: modes.ModeAffordable, + Recipe: modes.RecipeAffordable, }, }, } @@ -446,7 +469,7 @@ func TestParamsFromMap(t *testing.T) { if resultParams.Provider != tt.expectedParams.Provider || resultParams.Region != tt.expectedParams.Region || - resultParams.Mode != tt.expectedParams.Mode || + resultParams.Recipe != tt.expectedParams.Recipe || resultParams.Variables["AWS_PROFILE"] != tt.expectedParams.Variables["AWS_PROFILE"] { t.Errorf("ParamsFromMap() = %+v, want %+v", resultParams, tt.expectedParams) } diff --git a/src/pkg/stacks/wizard.go b/src/pkg/stacks/wizard.go index b82c34c40..f2024de09 100644 --- a/src/pkg/stacks/wizard.go +++ b/src/pkg/stacks/wizard.go @@ -13,23 +13,35 @@ 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" + "github.com/DefangLabs/defang/src/pkg/term" + defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" ) +// RecipeLister fetches the recipes available in the workspace. It is satisfied +// by client.FabricClient and the stacks Lister. +type RecipeLister interface { + ListRecipes(ctx context.Context) (*defangv1.ListRecipesResponse, error) +} + type Wizard struct { ec elicitations.Controller + recipeLister RecipeLister profileLister AWSProfileLister } -func NewWizard(ec elicitations.Controller) *Wizard { +func NewWizard(ec elicitations.Controller, recipeLister RecipeLister) *Wizard { return &Wizard{ ec: ec, + recipeLister: recipeLister, profileLister: &FileSystemAWSProfileLister{}, } } -func NewWizardWithProfileLister(ec elicitations.Controller, profileLister AWSProfileLister) *Wizard { +func NewWizardWithProfileLister(ec elicitations.Controller, recipeLister RecipeLister, profileLister AWSProfileLister) *Wizard { return &Wizard{ ec: ec, + recipeLister: recipeLister, profileLister: profileLister, } } @@ -81,6 +93,19 @@ func (w *Wizard) CollectRemainingParameters(ctx context.Context, params *Paramet params.Region = region } + if params.Recipe == modes.RecipeUnspecified && params.Provider != client.ProviderDefang { + // Skip the prompt entirely when Fabric reports no active recipes. + if recipeNames := w.activeRecipeNames(ctx); len(recipeNames) > 0 { + recipeName, err := w.ec.RequestEnum(ctx, "Which recipe (deployment mode) do you want to deploy with?", "recipe", + recipeNames, + ) + if err != nil { + return nil, fmt.Errorf("failed to elicit deployment mode: %w", err) + } + params.Recipe = modes.ParseRecipe(recipeName) + } + } + 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", @@ -158,6 +183,30 @@ func (w *Wizard) CollectRemainingParameters(ctx context.Context, params *Paramet return params, nil } +// activeRecipeNames returns the recipe names to offer in the wizard, fetched +// from Fabric. When no recipe lister is configured or Fabric is unreachable it +// falls back to the (non-empty) built-in deployment modes. When Fabric reports +// zero active recipes it returns an empty slice, signalling the caller to skip +// the recipe prompt. +func (w *Wizard) activeRecipeNames(ctx context.Context) []string { + if w.recipeLister == nil { + return modes.AllDeploymentModes() + } + resp, err := w.recipeLister.ListRecipes(ctx) + if err != nil { + term.Debugf("could not list recipes, falling back to built-in modes: %v", err) + return modes.AllDeploymentModes() + } + var names []string + for _, recipe := range resp.GetRecipes() { + if recipe.GetActive() { + names = append(names, recipe.GetName()) + } + } + sort.Strings(names) + return names +} + type AWSProfileLister interface { ListProfiles() ([]string, error) } @@ -195,6 +244,9 @@ func (f *FileSystemAWSProfileLister) ListProfiles() ([]string, error) { } } f.Close() + if err := scanner.Err(); err != nil { + continue // skip files with read errors + } } result := make([]string, 0, len(profiles)) diff --git a/src/pkg/stacks/wizard_test.go b/src/pkg/stacks/wizard_test.go index 78722f953..37d7dd6ac 100644 --- a/src/pkg/stacks/wizard_test.go +++ b/src/pkg/stacks/wizard_test.go @@ -8,6 +8,8 @@ import ( "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/elicitations" + "github.com/DefangLabs/defang/src/pkg/modes" + defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" "github.com/stretchr/testify/assert" ) @@ -19,6 +21,7 @@ type mockElicitationsController struct { errors map[string]error enumErrors map[string]error defaultErrors map[string]error + enumOptions map[string][]string callOrder []string supported bool } @@ -31,6 +34,7 @@ func newMockElicitationsController() *mockElicitationsController { errors: make(map[string]error), enumErrors: make(map[string]error), defaultErrors: make(map[string]error), + enumOptions: make(map[string][]string), supported: true, } } @@ -58,6 +62,9 @@ func (m *mockElicitationsController) RequestString(ctx context.Context, message, func (m *mockElicitationsController) RequestEnum(ctx context.Context, message, field string, options []string) (string, error) { m.callOrder = append(m.callOrder, "RequestEnum:"+field) + if m.enumOptions != nil { + m.enumOptions[field] = options + } if err, exists := m.enumErrors[field]; exists { return "", err } @@ -87,7 +94,7 @@ func (m *mockElicitationsController) IsSupported() bool { func TestNewWizard(t *testing.T) { mockController := newMockElicitationsController() - wizard := NewWizard(mockController) + wizard := NewWizard(mockController, nil) if wizard == nil { t.Fatal("NewWizard returned nil") @@ -134,6 +141,7 @@ func TestWizardCollectParameters(t *testing.T) { expectedCallOrder: []string{ "RequestEnum:provider", "RequestString:region", + "RequestEnum:recipe", "RequestString:stack_name", "RequestString:aws_profile", }, @@ -162,6 +170,7 @@ func TestWizardCollectParameters(t *testing.T) { expectedCallOrder: []string{ "RequestEnum:provider", "RequestString:region", + "RequestEnum:recipe", "RequestString:stack_name", "RequestEnum:aws_profile", }, @@ -192,6 +201,7 @@ func TestWizardCollectParameters(t *testing.T) { expectedCallOrder: []string{ "RequestEnum:provider", "RequestString:region", + "RequestEnum:recipe", "RequestString:stack_name", "RequestString:gcp_project_id", }, @@ -220,6 +230,7 @@ func TestWizardCollectParameters(t *testing.T) { expectedCallOrder: []string{ "RequestEnum:provider", "RequestString:region", + "RequestEnum:recipe", "RequestString:stack_name", "RequestString:gcp_project_id", }, @@ -261,6 +272,7 @@ func TestWizardCollectParameters(t *testing.T) { expectedCallOrder: []string{ "RequestEnum:provider", "RequestString:region", + "RequestEnum:recipe", "RequestString:stack_name", }, }, @@ -315,6 +327,7 @@ func TestWizardCollectParameters(t *testing.T) { expectedCallOrder: []string{ "RequestEnum:provider", "RequestString:region", + "RequestEnum:recipe", "RequestString:stack_name", }, }, @@ -337,6 +350,7 @@ func TestWizardCollectParameters(t *testing.T) { expectedCallOrder: []string{ "RequestEnum:provider", "RequestString:region", + "RequestEnum:recipe", "RequestString:stack_name", "RequestEnum:aws_profile", }, @@ -358,6 +372,7 @@ func TestWizardCollectParameters(t *testing.T) { expectedCallOrder: []string{ "RequestEnum:provider", "RequestString:region", + "RequestEnum:recipe", "RequestString:stack_name", "RequestString:gcp_project_id", }, @@ -371,6 +386,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["recipe"] = "BALANCED" tt.setupMock(mockController) var profileLister AWSProfileLister @@ -383,7 +401,7 @@ func TestWizardCollectParameters(t *testing.T) { } } - wizard := NewWizardWithProfileLister(mockController, profileLister) + wizard := NewWizardWithProfileLister(mockController, nil, profileLister) ctx := t.Context() // Execute @@ -429,7 +447,7 @@ func TestWizardCollectParameters(t *testing.T) { func TestWizardSetSupported(t *testing.T) { mockController := newMockElicitationsController() - wizard := NewWizard(mockController) + wizard := NewWizard(mockController, nil) // Test initial state if !mockController.IsSupported() { @@ -770,9 +788,12 @@ 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["recipe"] = "BALANCED" tt.setupMock(mockController) - wizard := NewWizard(mockController) + wizard := NewWizard(mockController, nil) ctx := t.Context() // Execute @@ -800,3 +821,74 @@ func TestWizardCollectRemainingParameters(t *testing.T) { }) } } + +// mockRecipeLister is a mock implementation of RecipeLister. +type mockRecipeLister struct { + resp *defangv1.ListRecipesResponse + err error +} + +func (m *mockRecipeLister) ListRecipes(ctx context.Context) (*defangv1.ListRecipesResponse, error) { + return m.resp, m.err +} + +func TestWizardRecipeOptions(t *testing.T) { + tests := []struct { + name string + recipeLister RecipeLister + expectedOptions []string // nil means the recipe prompt should be skipped + }{ + { + name: "active recipes from Fabric, sorted and filtered", + recipeLister: &mockRecipeLister{resp: &defangv1.ListRecipesResponse{Recipes: []*defangv1.Recipe{ + {Name: "PRODUCTION", Active: true}, + {Name: "LEGACY", Active: false}, + {Name: "AFFORDABLE", Active: true}, + }}}, + expectedOptions: []string{"AFFORDABLE", "PRODUCTION"}, + }, + { + name: "nil recipe lister falls back to built-in modes", + recipeLister: nil, + expectedOptions: modes.AllDeploymentModes(), + }, + { + name: "Fabric error falls back to built-in modes", + recipeLister: &mockRecipeLister{err: errors.New("fabric unreachable")}, + expectedOptions: modes.AllDeploymentModes(), + }, + { + name: "no active recipes skips the prompt", + recipeLister: &mockRecipeLister{resp: &defangv1.ListRecipesResponse{Recipes: []*defangv1.Recipe{ + {Name: "LEGACY", Active: false}, + }}}, + expectedOptions: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockController := newMockElicitationsController() + mockController.enumResponses["provider"] = "AWS" + mockController.defaultResponses["region"] = "us-east-1" + mockController.defaultResponses["stack_name"] = "awsuseast1" + mockController.defaultResponses["aws_profile"] = "default" + mockController.enumResponses["aws_profile"] = "default" + if tt.expectedOptions != nil { + // Answer the recipe prompt with whatever the first offered option is. + mockController.enumResponses["recipe"] = tt.expectedOptions[0] + } + t.Setenv("AWS_PROFILE", "default") + + wizard := NewWizard(mockController, tt.recipeLister) + result, err := wizard.CollectParameters(t.Context()) + assert.NoError(t, err) + + assert.Equal(t, tt.expectedOptions, mockController.enumOptions["recipe"], "recipe options mismatch") + if tt.expectedOptions == nil { + assert.NotContains(t, mockController.callOrder, "RequestEnum:mode", "recipe prompt should be skipped") + assert.Equal(t, modes.RecipeUnspecified, result.Recipe, "mode should be left unspecified") + } + }) + } +} diff --git a/src/protos/io/defang/v1/fabric.pb.go b/src/protos/io/defang/v1/fabric.pb.go index 6ea1cc9bf..090ffae1e 100644 --- a/src/protos/io/defang/v1/fabric.pb.go +++ b/src/protos/io/defang/v1/fabric.pb.go @@ -2781,9 +2781,10 @@ func (x *CanIUseResponse) GetAwsApnId() 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_name + 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. @@ -2832,6 +2833,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 @@ -6687,10 +6689,10 @@ const file_io_defang_v1_fabric_proto_rawDesc = "" + "\x0eforced_version\x18\a \x01(\bR\rforcedVersion\x12#\n" + "\rforced_reason\x18\b \x01(\tR\fforcedReason\x12\x1c\n" + "\n" + - "aws_apn_id\x18\t \x01(\tR\bawsApnIdJ\x04\b\x01\x10\x02\"\xc7\x02\n" + + "aws_apn_id\x18\t \x01(\tR\bawsApnIdJ\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 a38ab363a..eb729cb26 100644 --- a/src/protos/io/defang/v1/fabric.proto +++ b/src/protos/io/defang/v1/fabric.proto @@ -396,7 +396,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_name bytes compose = 4; // yaml (or json) string delegate_domain = 5; string delegation_set_id = 6;