diff --git a/cli/azd/CHANGELOG.md b/cli/azd/CHANGELOG.md index 4494daa8395..0f9c6a9a31b 100644 --- a/cli/azd/CHANGELOG.md +++ b/cli/azd/CHANGELOG.md @@ -8,6 +8,8 @@ ### Bugs Fixed +- [[#8561]](https://github.com/Azure/azure-dev/pull/8561) Make `azd init` idempotent with respect to the environment: re-running init in an initialized project now reuses the existing environment instead of failing with "environment already initialized". With `--no-prompt` and no `-e`, the recorded default environment is reused, and an explicitly requested environment is created and promoted to the default when it does not already exist. + ### Other Changes ## 1.25.5 (2026-06-05) diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index a2ae59f37b1..4280f6a9b17 100644 --- a/cli/azd/cmd/init.go +++ b/cli/azd/cmd/init.go @@ -62,7 +62,11 @@ func newInitCmd() *cobra.Command { When used with --template, a new directory is created (named after the template) and the project is initialized inside it — similar to git clone. -Pass "." as the directory to initialize in the current directory instead.`, +Pass "." as the directory to initialize in the current directory instead. + +Re-running init in an initialized project is idempotent: the existing environment is +reused instead of failing. With --no-prompt and no -e, the recorded default environment +is reused.`, Args: cobra.MaximumNArgs(1), } } @@ -424,6 +428,9 @@ func (i *initAction) Run(ctx context.Context) (_ *actions.ActionResult, retErr e } if _, err := i.initializeEnv(ctx, azdCtx, template.Metadata); err != nil { + if errors.Is(err, errInitEnvCancelled) { + return initCancelledResult(), nil + } return nil, err } @@ -496,12 +503,18 @@ func (i *initAction) Run(ctx context.Context) (_ *actions.ActionResult, retErr e ) } if err != nil { + if errors.Is(err, errInitEnvCancelled) { + return initCancelledResult(), nil + } return nil, err } case initEnvironment: tracing.SetUsageAttributes(fields.InitMethod.String("environment")) env, err := i.initializeEnv(ctx, azdCtx, templates.Metadata{}) if err != nil { + if errors.Is(err, errInitEnvCancelled) { + return initCancelledResult(), nil + } return nil, err } @@ -804,21 +817,6 @@ func (i *initAction) initializeEnv( return nil, fmt.Errorf("retrieving default environment name: %w", err) } - if envName != "" { - return nil, environment.NewEnvironmentInitError(envName) - } - - base := filepath.Base(azdCtx.ProjectDirectory()) - examples := []string{} - for _, c := range []string{"dev", "test", "prod"} { - suggest := environment.CleanName(base + "-" + c) - if len(suggest) > environment.EnvironmentNameMaxLength { - suggest = suggest[len(suggest)-environment.EnvironmentNameMaxLength:] - } - - examples = append(examples, suggest) - } - // Environment manager requires azd context // Azd context isn't available in init so lazy instantiating // it here after the template is hydrated and the context is available @@ -827,28 +825,43 @@ func (i *initAction) initializeEnv( return nil, err } - envSpec := environment.Spec{ - Name: i.flags.EnvironmentName, - Subscription: i.flags.subscription, - Location: i.flags.location, - Examples: examples, - } - - env, err := envManager.Create(ctx, envSpec) + env, outcome, err := i.resolveInitEnv(ctx, azdCtx, envManager, envName, i.flags.EnvironmentName) if err != nil { - return nil, fmt.Errorf("loading environment: %w", err) + return nil, err } + // Record the resolved environment as the default. This is a no-op when it already + // is, and repairs a missing or stale default when reusing/recovering an existing + // environment from a previous (possibly partial) initialization. It also promotes an + // explicitly requested environment to the default when switching away from a previous + // one. if err := azdCtx.SetProjectState(azdcontext.ProjectState{DefaultEnvironment: env.Name()}); err != nil { return nil, fmt.Errorf("saving default environment: %w", err) } + // When reusing an environment, only fill in values that are absent so changes the + // user made between runs aren't clobbered. On a fresh create, apply unconditionally. + existingDotenv := env.Dotenv() + setEnvValue := func(key, value string) { + if outcome.reused { + if _, ok := existingDotenv[key]; ok { + return + } + } + env.DotenvSet(key, value) + } + // Copy template metadata into environment values for key, value := range templateMetadata.Variables { - env.DotenvSet(key, value) + setEnvValue(key, value) } for key, value := range templateMetadata.Config { + if outcome.reused { + if _, ok := env.Config.Get(key); ok { + continue + } + } if err := env.Config.Set(key, value); err != nil { return nil, fmt.Errorf("setting environment config: %w", err) } @@ -859,13 +872,241 @@ func (i *initAction) initializeEnv( return nil, fmt.Errorf("loading initial env file values: %w", err) } for key, value := range initialValuesFromEnv { - env.DotenvSet(key, value) + setEnvValue(key, value) + } + + // Explicit flags represent direct user intent, so they always win — even on reuse — + // overriding any value previously stored in the environment. + if i.flags.location != "" { + env.DotenvSet(environment.LocationEnvVarName, i.flags.location) + } + if i.flags.subscription != "" { + env.DotenvSet(environment.SubscriptionIdEnvVarName, i.flags.subscription) } if err := envManager.Save(ctx, env); err != nil { return nil, fmt.Errorf("saving environment: %w", err) } + // Print exactly one message describing the outcome, only after the environment has + // been persisted. Switching the default takes precedence; an already-acknowledged + // reuse (the user confirmed or selected it) needs no extra line. + switch { + case outcome.promotedFrom != "": + i.console.Message( + ctx, + fmt.Sprintf("Switching the default environment to %s.", output.WithHighLightFormat(env.Name())), + ) + case outcome.reused && !outcome.acknowledged: + i.console.Message( + ctx, + fmt.Sprintf("Reusing existing environment %s.", output.WithHighLightFormat(env.Name())), + ) + } + + return env, nil +} + +// errInitEnvCancelled signals that the user declined to reuse an existing environment. +// init treats this as a clean, no-op cancellation rather than a failure: nothing about +// the environment is mutated (no environment is created and the recorded default is left +// unchanged). +var errInitEnvCancelled = errors.New("init cancelled by user") + +// initCancelledResult returns the action result used when the user cancels init by +// declining to reuse an existing environment. +func initCancelledResult() *actions.ActionResult { + return &actions.ActionResult{ + Message: &actions.ResultMessage{Header: "Init cancelled."}, + } +} + +// initEnvOutcome describes how resolveInitEnv resolved the environment so that +// initializeEnv can centralize user-facing messaging and the set-if-absent logic. +type initEnvOutcome struct { + // reused is true when an existing environment was reused rather than newly created. + reused bool + // promotedFrom is the previous default environment name when init switched the + // recorded default to a different environment. It is empty when the default was + // unchanged. + promotedFrom string + // acknowledged is true when an interactive prompt (confirm or select) already + // surfaced the reuse to the user, so initializeEnv should not print a duplicate + // "Reusing existing environment" line. + acknowledged bool +} + +// resolveInitEnv determines which environment `azd init` should use, returning the +// environment and an outcome describing how it was resolved. +// +// When a default environment already exists, init is idempotent rather than failing: +// - An explicitly requested environment (`-e`) that already exists is reused (interactive +// runs confirm first, defaulting to reuse); declining cancels init via +// errInitEnvCancelled. An explicitly requested environment that does not yet exist is +// created and promoted to the project default, without prompting. +// - Without `-e`, non-interactive runs reuse the recorded default while interactive runs +// are asked whether to reuse it or create a new one (defaulting to reuse). +// +// Orphaned environment folders and stale default configuration left behind by a previous +// partial init are recovered instead of dead-ending. +func (i *initAction) resolveInitEnv( + ctx context.Context, + azdCtx *azdcontext.AzdContext, + envManager environment.Manager, + defaultEnvName string, + requested string, +) (*environment.Environment, initEnvOutcome, error) { + // Case A: No default environment is recorded for the project yet. Reuse an orphaned + // environment folder left by a previous partial init when the requested name matches + // one, otherwise create a new environment (deriving or prompting for a name when the + // requested name is empty). + if defaultEnvName == "" { + env, reused, err := i.getOrCreateEnv(ctx, azdCtx, envManager, requested) + return env, initEnvOutcome{reused: reused}, err + } + + // Case B: An environment name was explicitly requested while a default already exists. + if requested != "" { + // Validate the requested name up front so an invalid name surfaces a clear error + // rather than a misleading "already initialized" one. + if !environment.IsValidEnvironmentName(requested) { + return nil, initEnvOutcome{}, environment.InvalidEnvironmentNameError(requested) + } + + // Resolving to a differently-named environment promotes it to the project default. + promotedFrom := "" + if requested != defaultEnvName { + promotedFrom = defaultEnvName + } + + _, err := envManager.Get(ctx, requested) + switch { + case err == nil: + // The requested environment already exists: reuse it. Interactive runs confirm + // first (defaulting to reuse); declining cancels init. Non-interactive runs + // reuse silently, preserving idempotent agent re-runs. + if !i.console.IsNoPromptMode() { + confirmed, confirmErr := i.console.Confirm(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("Reuse the existing environment '%s'?", requested), + DefaultValue: true, + }) + if confirmErr != nil { + return nil, initEnvOutcome{}, confirmErr + } + if !confirmed { + return nil, initEnvOutcome{}, errInitEnvCancelled + } + } + + env, reused, err := i.getOrCreateEnv(ctx, azdCtx, envManager, requested) + return env, initEnvOutcome{ + reused: reused, + promotedFrom: promotedFrom, + acknowledged: !i.console.IsNoPromptMode(), + }, err + case errors.Is(err, environment.ErrNotFound): + // The requested environment does not exist yet: create it without prompting in + // either mode. + env, reused, err := i.getOrCreateEnv(ctx, azdCtx, envManager, requested) + return env, initEnvOutcome{reused: reused, promotedFrom: promotedFrom}, err + default: + // Surface real I/O or config errors rather than silently proceeding. + return nil, initEnvOutcome{}, fmt.Errorf("checking existing environment '%s': %w", requested, err) + } + } + + // Case C: No environment name was requested while a default exists. + if i.console.IsNoPromptMode() { + // Non-interactive runs reuse the recorded default silently. + env, reused, err := i.getOrCreateEnv(ctx, azdCtx, envManager, defaultEnvName) + return env, initEnvOutcome{reused: reused}, err + } + + // Interactive: let the user choose between reusing the existing environment and + // creating a new one, defaulting to (and recommending) reuse. + reuseOption := fmt.Sprintf("Reuse the existing environment '%s'", defaultEnvName) + createOption := "Create a new environment" + choice, err := i.console.Select(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf( + "An environment named '%s' already exists for this project. How do you want to proceed?", + defaultEnvName, + ), + Options: []string{reuseOption, createOption}, + DefaultValue: reuseOption, + }) + if err != nil { + return nil, initEnvOutcome{}, err + } + + if choice == 0 { + env, reused, err := i.getOrCreateEnv(ctx, azdCtx, envManager, defaultEnvName) + return env, initEnvOutcome{reused: reused, acknowledged: true}, err + } + + // Create a new environment, prompting for (or deriving) a fresh name. + env, reused, err := i.getOrCreateEnv(ctx, azdCtx, envManager, "") + return env, initEnvOutcome{reused: reused, acknowledged: true}, err +} + +// getOrCreateEnv returns the environment with the given name, reusing it when it already +// exists (recovering from a previous partial init) and creating it otherwise. The +// returned bool reports whether an existing environment was reused (true) versus newly +// created (false). When name is empty a new environment is always created, prompting for +// (or deriving) a name. +func (i *initAction) getOrCreateEnv( + ctx context.Context, + azdCtx *azdcontext.AzdContext, + envManager environment.Manager, + name string, +) (*environment.Environment, bool, error) { + if name != "" { + env, err := envManager.Get(ctx, name) + switch { + case err == nil: + return env, true, nil + case errors.Is(err, environment.ErrNotFound): + // Not found: fall through to create. This also recovers a stale default + // whose environment folder is missing. + default: + return nil, false, fmt.Errorf("loading existing environment '%s': %w", name, err) + } + } + + env, err := i.createInitEnv(ctx, azdCtx, envManager, name) + return env, false, err +} + +// createInitEnv creates a new environment for init. When name is empty the user is +// prompted for one, using suggestions derived from the project directory name. +func (i *initAction) createInitEnv( + ctx context.Context, + azdCtx *azdcontext.AzdContext, + envManager environment.Manager, + name string, +) (*environment.Environment, error) { + base := filepath.Base(azdCtx.ProjectDirectory()) + examples := []string{} + for _, c := range []string{"dev", "test", "prod"} { + suggest := environment.CleanName(base + "-" + c) + if len(suggest) > environment.EnvironmentNameMaxLength { + suggest = suggest[len(suggest)-environment.EnvironmentNameMaxLength:] + } + + examples = append(examples, suggest) + } + + envSpec := environment.Spec{ + Name: name, + Subscription: i.flags.subscription, + Location: i.flags.location, + Examples: examples, + } + + env, err := envManager.Create(ctx, envSpec) + if err != nil { + return nil, fmt.Errorf("loading environment: %w", err) + } + return env, nil } @@ -959,6 +1200,13 @@ func getCmdInitHelpDescription(*cobra.Command) string { formatHelpNote( "To view all available sample templates, including those submitted by the azd community, visit: " + output.WithLinkFormat("https://azure.github.io/awesome-azd") + "."), + formatHelpNote( + fmt.Sprintf("Re-running %s in an initialized project reuses the existing environment; "+ + "with %s and no %s, the recorded default environment is reused.", + output.WithHighLightFormat("init"), + output.WithHighLightFormat("--no-prompt"), + output.WithHighLightFormat("-e"), + )), }) } diff --git a/cli/azd/cmd/init_env_test.go b/cli/azd/cmd/init_env_test.go new file mode 100644 index 00000000000..2ae960e15c3 --- /dev/null +++ b/cli/azd/cmd/init_env_test.go @@ -0,0 +1,530 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/lazy" + "github.com/azure/azure-dev/cli/azd/pkg/templates" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/stretchr/testify/require" +) + +// setupInitializeEnvTest wires an initAction with a real environment.Manager backed by +// an isolated temp project directory so initializeEnv can be exercised end-to-end without +// network access. The working directory is changed to the project directory so that +// repository.InitEnvFileValues (which reads .env from the CWD) is deterministic. +func setupInitializeEnvTest( + t *testing.T, + mockContext *mocks.MockContext, + flags *initFlags, +) (*initAction, *azdcontext.AzdContext, environment.Manager) { + t.Helper() + + projectDir := t.TempDir() + t.Chdir(projectDir) + + azdCtx := azdcontext.NewAzdContextWithDirectory(projectDir) + envManager := freshEnvManager(t, mockContext, azdCtx) + + action := &initAction{ + console: mockContext.Console, + flags: flags, + lazyEnvManager: lazy.From(envManager), + } + + return action, azdCtx, envManager +} + +func TestInitializeEnv(t *testing.T) { + t.Run("FreshCreateWithRequestedName", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + flags := &initFlags{} + flags.EnvironmentName = "fresh-dev" + + action, azdCtx, _ := setupInitializeEnvTest(t, mockContext, flags) + + env, err := action.initializeEnv(*mockContext.Context, azdCtx, templates.Metadata{}) + require.NoError(t, err) + require.Equal(t, "fresh-dev", env.Name()) + + defaultEnv, err := azdCtx.GetDefaultEnvironmentName() + require.NoError(t, err) + require.Equal(t, "fresh-dev", defaultEnv) + + out := strings.Join(mockContext.Console.Output(), "\n") + require.NotContains(t, out, "Reusing existing environment") + require.NotContains(t, out, "Switching the default environment") + }) + + t.Run("InteractiveReuseSelectNoNameRequested", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + flags := &initFlags{} + + action, azdCtx, envManager := setupInitializeEnvTest(t, mockContext, flags) + seedDefaultEnv(t, *mockContext.Context, azdCtx, envManager, "existing-dev", nil) + + var selectMessage string + mockContext.Console.WhenSelect(func(options input.ConsoleOptions) bool { + return strings.Contains(options.Message, "already exists") + }).RespondFn(func(options input.ConsoleOptions) (any, error) { + selectMessage = options.Message + return 0, nil // Reuse + }) + + env, err := action.initializeEnv(*mockContext.Context, azdCtx, templates.Metadata{}) + require.NoError(t, err) + require.Equal(t, "existing-dev", env.Name()) + require.Contains(t, selectMessage, "existing-dev") + + defaultEnv, err := azdCtx.GetDefaultEnvironmentName() + require.NoError(t, err) + require.Equal(t, "existing-dev", defaultEnv) + + // The select itself is the acknowledgment; no extra "Reusing" line should print. + require.NotContains(t, strings.Join(mockContext.Console.Output(), "\n"), "Reusing existing environment") + + envs, err := envManager.List(*mockContext.Context) + require.NoError(t, err) + require.Len(t, envs, 1) + }) + + t.Run("InteractiveExactMatchConfirmsReuse", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + flags := &initFlags{} + flags.EnvironmentName = "existing-dev" + + action, azdCtx, envManager := setupInitializeEnvTest(t, mockContext, flags) + seedDefaultEnv(t, *mockContext.Context, azdCtx, envManager, "existing-dev", nil) + + var confirmMessage string + mockContext.Console.WhenConfirm(func(options input.ConsoleOptions) bool { + return strings.Contains(options.Message, "Reuse the existing environment") + }).RespondFn(func(options input.ConsoleOptions) (any, error) { + confirmMessage = options.Message + return true, nil + }) + + env, err := action.initializeEnv(*mockContext.Context, azdCtx, templates.Metadata{}) + require.NoError(t, err) + require.Equal(t, "existing-dev", env.Name()) + require.Contains(t, confirmMessage, "existing-dev") + + // The confirm is the acknowledgment; reusing the current default prints no extra + // "Reusing" or "Switching" line. + out := strings.Join(mockContext.Console.Output(), "\n") + require.NotContains(t, out, "Reusing existing environment") + require.NotContains(t, out, "Switching the default environment") + + envs, err := envManager.List(*mockContext.Context) + require.NoError(t, err) + require.Len(t, envs, 1) + }) + + t.Run("InteractiveExistingNonDefaultConfirmYesReusesAndPromotes", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + flags := &initFlags{} + flags.EnvironmentName = "other-dev" + + action, azdCtx, envManager := setupInitializeEnvTest(t, mockContext, flags) + seedDefaultEnv(t, *mockContext.Context, azdCtx, envManager, "existing-dev", nil) + // A second, non-default environment that already exists on disk. + _, err := envManager.Create(*mockContext.Context, environment.Spec{Name: "other-dev"}) + require.NoError(t, err) + + mockContext.Console.WhenConfirm(func(options input.ConsoleOptions) bool { + return strings.Contains(options.Message, "Reuse the existing environment") + }).Respond(true) + + env, err := action.initializeEnv(*mockContext.Context, azdCtx, templates.Metadata{}) + require.NoError(t, err) + require.Equal(t, "other-dev", env.Name()) + + defaultEnv, err := azdCtx.GetDefaultEnvironmentName() + require.NoError(t, err) + require.Equal(t, "other-dev", defaultEnv) + + // Promoting to a different default surfaces the switch note (and never "Reusing"). + out := strings.Join(mockContext.Console.Output(), "\n") + require.Contains(t, out, "Switching the default environment") + require.NotContains(t, out, "Reusing existing environment") + }) + + t.Run("InteractiveExistingNonDefaultConfirmNoCancels", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + flags := &initFlags{} + flags.EnvironmentName = "other-dev" + + action, azdCtx, envManager := setupInitializeEnvTest(t, mockContext, flags) + seedDefaultEnv(t, *mockContext.Context, azdCtx, envManager, "existing-dev", nil) + _, err := envManager.Create(*mockContext.Context, environment.Spec{Name: "other-dev"}) + require.NoError(t, err) + + mockContext.Console.WhenConfirm(func(options input.ConsoleOptions) bool { + return strings.Contains(options.Message, "Reuse the existing environment") + }).Respond(false) + + _, err = action.initializeEnv(*mockContext.Context, azdCtx, templates.Metadata{}) + require.ErrorIs(t, err, errInitEnvCancelled) + + // Nothing mutated: the recorded default is unchanged. + defaultEnv, err := azdCtx.GetDefaultEnvironmentName() + require.NoError(t, err) + require.Equal(t, "existing-dev", defaultEnv) + }) + + t.Run("NonInteractiveExistingNonDefaultReusesAndPromotes", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + mockContext.Console.SetNoPromptMode(true) + flags := &initFlags{} + flags.EnvironmentName = "other-dev" + + action, azdCtx, envManager := setupInitializeEnvTest(t, mockContext, flags) + seedDefaultEnv(t, *mockContext.Context, azdCtx, envManager, "existing-dev", nil) + _, err := envManager.Create(*mockContext.Context, environment.Spec{Name: "other-dev"}) + require.NoError(t, err) + + env, err := action.initializeEnv(*mockContext.Context, azdCtx, templates.Metadata{}) + require.NoError(t, err) + require.Equal(t, "other-dev", env.Name()) + + defaultEnv, err := azdCtx.GetDefaultEnvironmentName() + require.NoError(t, err) + require.Equal(t, "other-dev", defaultEnv) + + require.Contains(t, strings.Join(mockContext.Console.Output(), "\n"), "Switching the default environment") + }) + + t.Run("RequestedNewNameCreatesAndPromotesWithoutPrompt", func(t *testing.T) { + // A non-existent requested name is created and promoted to the default in both + // non-interactive and interactive modes, without any prompt. No Select/Confirm + // handler is registered: if a prompt were shown, the mock console would panic. + for _, noPrompt := range []bool{true, false} { + t.Run(map[bool]string{true: "NoPrompt", false: "Interactive"}[noPrompt], func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + mockContext.Console.SetNoPromptMode(noPrompt) + flags := &initFlags{} + flags.EnvironmentName = "new-dev" + + action, azdCtx, envManager := setupInitializeEnvTest(t, mockContext, flags) + seedDefaultEnv(t, *mockContext.Context, azdCtx, envManager, "existing-dev", nil) + + env, err := action.initializeEnv(*mockContext.Context, azdCtx, templates.Metadata{}) + require.NoError(t, err) + require.Equal(t, "new-dev", env.Name()) + + defaultEnv, err := azdCtx.GetDefaultEnvironmentName() + require.NoError(t, err) + require.Equal(t, "new-dev", defaultEnv) + + out := strings.Join(mockContext.Console.Output(), "\n") + require.Contains(t, out, "Switching the default environment") + require.NotContains(t, out, "already exists") + + envs, err := envManager.List(*mockContext.Context) + require.NoError(t, err) + require.Len(t, envs, 2) + }) + } + }) + + t.Run("InteractiveCreateNewPromptsForName", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + flags := &initFlags{} + + action, azdCtx, envManager := setupInitializeEnvTest(t, mockContext, flags) + seedDefaultEnv(t, *mockContext.Context, azdCtx, envManager, "existing-dev", nil) + + mockContext.Console.WhenSelect(func(options input.ConsoleOptions) bool { + return strings.Contains(options.Message, "already exists") + }).Respond(1) // Create new + mockContext.Console.WhenPrompt(func(options input.ConsoleOptions) bool { + return strings.Contains(options.Message, "environment name") + }).Respond("prompted-dev") + + env, err := action.initializeEnv(*mockContext.Context, azdCtx, templates.Metadata{}) + require.NoError(t, err) + require.Equal(t, "prompted-dev", env.Name()) + + defaultEnv, err := azdCtx.GetDefaultEnvironmentName() + require.NoError(t, err) + require.Equal(t, "prompted-dev", defaultEnv) + + // Creating a new environment must never print "Reusing". + require.NotContains(t, strings.Join(mockContext.Console.Output(), "\n"), "Reusing existing environment") + }) + + t.Run("NonInteractiveReuseOnMatch", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + mockContext.Console.SetNoPromptMode(true) + flags := &initFlags{} + flags.EnvironmentName = "existing-dev" + + action, azdCtx, envManager := setupInitializeEnvTest(t, mockContext, flags) + seedDefaultEnv(t, *mockContext.Context, azdCtx, envManager, "existing-dev", nil) + + env, err := action.initializeEnv(*mockContext.Context, azdCtx, templates.Metadata{}) + require.NoError(t, err) + require.Equal(t, "existing-dev", env.Name()) + + out := strings.Join(mockContext.Console.Output(), "\n") + require.Contains(t, out, "Reusing existing environment") + require.NotContains(t, out, "Switching the default environment") + }) + + t.Run("NonInteractiveReuseOnEmptyRequest", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + mockContext.Console.SetNoPromptMode(true) + flags := &initFlags{} + + action, azdCtx, envManager := setupInitializeEnvTest(t, mockContext, flags) + seedDefaultEnv(t, *mockContext.Context, azdCtx, envManager, "existing-dev", nil) + + env, err := action.initializeEnv(*mockContext.Context, azdCtx, templates.Metadata{}) + require.NoError(t, err) + require.Equal(t, "existing-dev", env.Name()) + + // Reusing the recorded default in --no-prompt mode tells the user it was reused. + require.Contains(t, strings.Join(mockContext.Console.Output(), "\n"), "Reusing existing environment") + }) + + t.Run("NonInteractiveStaleDefaultDifferentNameCreates", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + mockContext.Console.SetNoPromptMode(true) + flags := &initFlags{} + flags.EnvironmentName = "real-dev" + + action, azdCtx, _ := setupInitializeEnvTest(t, mockContext, flags) + + // A stale default points at an environment whose folder is gone. An explicitly + // requested, different name is created and promoted to the default. + require.NoError(t, azdCtx.SetProjectState(azdcontext.ProjectState{DefaultEnvironment: "ghost-dev"})) + + env, err := action.initializeEnv(*mockContext.Context, azdCtx, templates.Metadata{}) + require.NoError(t, err) + require.Equal(t, "real-dev", env.Name()) + + defaultEnv, err := azdCtx.GetDefaultEnvironmentName() + require.NoError(t, err) + require.Equal(t, "real-dev", defaultEnv) + + require.Contains(t, strings.Join(mockContext.Console.Output(), "\n"), "Switching the default environment") + }) + + t.Run("NonInteractiveInvalidRequestedNameErrors", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + mockContext.Console.SetNoPromptMode(true) + flags := &initFlags{} + flags.EnvironmentName = "invalid name with spaces" + + action, azdCtx, envManager := setupInitializeEnvTest(t, mockContext, flags) + seedDefaultEnv(t, *mockContext.Context, azdCtx, envManager, "existing-dev", nil) + + _, err := action.initializeEnv(*mockContext.Context, azdCtx, templates.Metadata{}) + require.Error(t, err) + // Must not report "already initialized" — the real problem is the invalid name. + var initErr *environment.EnvironmentInitError + require.False(t, errors.As(err, &initErr), "expected invalid-name error, not EnvironmentInitError") + require.Contains(t, err.Error(), "invalid") + }) + + t.Run("NonInteractiveCorruptDefaultEnvPropagatesError", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + mockContext.Console.SetNoPromptMode(true) + flags := &initFlags{} + // Request the corrupt environment by name so its load error surfaces. + flags.EnvironmentName = "corrupt-dev" + + action, azdCtx, envManager := setupInitializeEnvTest(t, mockContext, flags) + + // Create the default env normally so its directory exists. + seedDefaultEnv(t, *mockContext.Context, azdCtx, envManager, "corrupt-dev", nil) + + // Overwrite config.json with invalid JSON to simulate a real load error. + configPath := filepath.Join(azdCtx.EnvironmentRoot("corrupt-dev"), environment.ConfigFileName) + require.NoError(t, os.WriteFile(configPath, []byte("{invalid json"), 0600)) + + _, err := action.initializeEnv(*mockContext.Context, azdCtx, templates.Metadata{}) + require.Error(t, err) + // The I/O error must surface rather than being swallowed. + var initErr *environment.EnvironmentInitError + require.False(t, errors.As(err, &initErr), "expected load error, not EnvironmentInitError") + require.Contains(t, err.Error(), "checking existing environment") + }) + + t.Run("OrphanFolderRecovery", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + flags := &initFlags{} + flags.EnvironmentName = "orphan-dev" + + action, azdCtx, envManager := setupInitializeEnvTest(t, mockContext, flags) + + // Create the environment folder but never record it as the project default, + // emulating a previous partial init. + _, err := envManager.Create(*mockContext.Context, environment.Spec{Name: "orphan-dev"}) + require.NoError(t, err) + + defaultEnv, err := azdCtx.GetDefaultEnvironmentName() + require.NoError(t, err) + require.Empty(t, defaultEnv) + + env, err := action.initializeEnv(*mockContext.Context, azdCtx, templates.Metadata{}) + require.NoError(t, err) + require.Equal(t, "orphan-dev", env.Name()) + + defaultEnv, err = azdCtx.GetDefaultEnvironmentName() + require.NoError(t, err) + require.Equal(t, "orphan-dev", defaultEnv) + }) + + t.Run("StaleConfigRecovery", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + mockContext.Console.SetNoPromptMode(true) + flags := &initFlags{} + + action, azdCtx, envManager := setupInitializeEnvTest(t, mockContext, flags) + + // Record a default environment whose folder does not exist. + require.NoError(t, azdCtx.SetProjectState(azdcontext.ProjectState{DefaultEnvironment: "ghost-dev"})) + + env, err := action.initializeEnv(*mockContext.Context, azdCtx, templates.Metadata{}) + require.NoError(t, err) + require.Equal(t, "ghost-dev", env.Name()) + + // The environment folder is now created and loadable. + reloaded, err := envManager.Get(*mockContext.Context, "ghost-dev") + require.NoError(t, err) + require.Equal(t, "ghost-dev", reloaded.Name()) + }) + + t.Run("MetadataNotClobberedOnReuse", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + mockContext.Console.SetNoPromptMode(true) + flags := &initFlags{} + flags.EnvironmentName = "existing-dev" + + action, azdCtx, envManager := setupInitializeEnvTest(t, mockContext, flags) + seedDefaultEnv(t, *mockContext.Context, azdCtx, envManager, "existing-dev", func(env *environment.Environment) { + env.DotenvSet("USER_KEY", "original") + require.NoError(t, env.Config.Set("user.config", "original")) + }) + + metadata := templates.Metadata{ + Variables: map[string]string{ + "USER_KEY": "fromTemplate", + "TEMPLATE_KEY": "templateValue", + }, + Config: map[string]string{ + "user.config": "fromTemplate", + "template.config": "templateValue", + }, + } + + env, err := action.initializeEnv(*mockContext.Context, azdCtx, metadata) + require.NoError(t, err) + + dotenv := env.Dotenv() + require.Equal(t, "original", dotenv["USER_KEY"], "user value must not be clobbered") + require.Equal(t, "templateValue", dotenv["TEMPLATE_KEY"], "absent template value should be added") + + userConfig, ok := env.Config.Get("user.config") + require.True(t, ok) + require.Equal(t, "original", userConfig, "user config must not be clobbered") + + templateConfig, ok := env.Config.Get("template.config") + require.True(t, ok) + require.Equal(t, "templateValue", templateConfig, "absent template config should be added") + + // Reload from disk via a fresh manager to confirm the values were persisted. + reloadManager := freshEnvManager(t, mockContext, azdCtx) + reloaded, err := reloadManager.Get(*mockContext.Context, "existing-dev") + require.NoError(t, err) + require.Equal(t, "original", reloaded.Dotenv()["USER_KEY"]) + require.Equal(t, "templateValue", reloaded.Dotenv()["TEMPLATE_KEY"]) + }) + + t.Run("ReuseHonorsExplicitFlagsOverExisting", func(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + mockContext.Console.SetNoPromptMode(true) + flags := &initFlags{} + flags.EnvironmentName = "existing-dev" + flags.location = "eastus2" + flags.subscription = "sub-from-flag" + + action, azdCtx, envManager := setupInitializeEnvTest(t, mockContext, flags) + seedDefaultEnv(t, *mockContext.Context, azdCtx, envManager, "existing-dev", func(env *environment.Environment) { + // Subscription already set; location intentionally absent. + env.DotenvSet(environment.SubscriptionIdEnvVarName, "sub-already-set") + // User-edited metadata that must not be clobbered on reuse. + env.DotenvSet("USER_KEY", "original") + }) + + metadata := templates.Metadata{ + Variables: map[string]string{ + "USER_KEY": "fromTemplate", + "TEMPLATE_KEY": "templateValue", + }, + } + + env, err := action.initializeEnv(*mockContext.Context, azdCtx, metadata) + require.NoError(t, err) + + dotenv := env.Dotenv() + require.Equal(t, "eastus2", dotenv[environment.LocationEnvVarName], "absent location should be filled from flag") + // Explicit flags represent direct user intent and win even over an existing value. + require.Equal(t, "sub-from-flag", dotenv[environment.SubscriptionIdEnvVarName], + "explicit subscription flag must win over existing value") + // Template metadata, by contrast, remains set-if-absent and must not clobber user edits. + require.Equal(t, "original", dotenv["USER_KEY"], "user-edited metadata must not be clobbered") + require.Equal(t, "templateValue", dotenv["TEMPLATE_KEY"], "absent template metadata should be added") + }) +} + +// freshEnvManager builds a new environment.Manager over the same azd context so tests can +// read environment state from disk without hitting the manager's in-memory cache. +func freshEnvManager( + t *testing.T, + mockContext *mocks.MockContext, + azdCtx *azdcontext.AzdContext, +) environment.Manager { + t.Helper() + configManager := config.NewFileConfigManager(config.NewManager()) + localDataStore := environment.NewLocalFileDataStore(azdCtx, configManager) + envManager, err := environment.NewManager(nil, azdCtx, mockContext.Console, localDataStore, nil) + require.NoError(t, err) + return envManager +} + +// seedDefaultEnv creates an environment, optionally mutates it, saves it, and records it +// as the project default — emulating a prior successful init. +func seedDefaultEnv( + t *testing.T, + ctx context.Context, + azdCtx *azdcontext.AzdContext, + envManager environment.Manager, + name string, + mutate func(env *environment.Environment), +) { + t.Helper() + + env, err := envManager.Create(ctx, environment.Spec{Name: name}) + require.NoError(t, err) + + if mutate != nil { + mutate(env) + require.NoError(t, envManager.Save(ctx, env)) + } + + require.NoError(t, azdCtx.SetProjectState(azdcontext.ProjectState{DefaultEnvironment: name})) +} diff --git a/cli/azd/cmd/testdata/TestUsage-azd-init.snap b/cli/azd/cmd/testdata/TestUsage-azd-init.snap index f0c3c201829..d1376a8210a 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-init.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-init.snap @@ -4,6 +4,7 @@ Initialize a new application. When using --template, creates a project directory • Running init without flags specified will prompt you to initialize using your existing code, or from a template. • When using --template, a new directory is created (named after the template) and the project is initialized inside it. Pass . as the directory to use the current directory instead. • To view all available sample templates, including those submitted by the azd community, visit: https://azure.github.io/awesome-azd. + • Re-running init in an initialized project reuses the existing environment; with --no-prompt and no -e, the recorded default environment is reused. Usage azd init [flags] diff --git a/cli/azd/test/functional/init_test.go b/cli/azd/test/functional/init_test.go index fd5c455d236..99b195e3898 100644 --- a/cli/azd/test/functional/init_test.go +++ b/cli/azd/test/functional/init_test.go @@ -259,6 +259,44 @@ func Test_CLI_Init_WithinExistingProject(t *testing.T) { require.NoError(t, err) } +// Test_CLI_Init_Idempotent_Environment verifies that re-running `azd init` in a project +// that already has an environment is idempotent: re-init with the same environment name +// reuses it without erroring, --no-prompt with no -e reuses the recorded default, and an +// explicitly requested name that does not yet exist is created and promoted to the default. +func Test_CLI_Init_Idempotent_Environment(t *testing.T) { + ctx, cancel := newTestContext(t) + defer cancel() + + dir := tempDirWithDiagnostics(t) + + cli := azdcli.NewCLI(t) + cli.WorkingDirectory = dir + cli.Env = append(os.Environ(), "AZURE_LOCATION=eastus2") + + // Create a minimal project with an environment named TESTENV. + _, err := cli.RunCommandWithStdIn(ctx, "\n", "init", "--minimal", "-e", "TESTENV") + require.NoError(t, err) + require.FileExists(t, getTestEnvPath(dir, "TESTENV")) + + // Re-running init with the same environment name and --no-prompt must succeed + // (idempotent reuse) rather than failing with "environment already initialized". + _, err = cli.RunCommand(ctx, "init", "-e", "TESTENV", "--no-prompt") + require.NoError(t, err) + require.FileExists(t, getTestEnvPath(dir, "TESTENV")) + + // Re-running with --no-prompt and no -e reuses the recorded default environment. + _, err = cli.RunCommand(ctx, "init", "--no-prompt") + require.NoError(t, err) + require.FileExists(t, getTestEnvPath(dir, "TESTENV")) + + // Requesting a different, non-existent environment name in --no-prompt mode now + // creates it and promotes it to the project default rather than erroring. + _, err = cli.RunCommand(ctx, "init", "-e", "OTHERENV", "--no-prompt") + require.NoError(t, err) + require.FileExists(t, getTestEnvPath(dir, "OTHERENV")) + require.FileExists(t, getTestEnvPath(dir, "TESTENV")) +} + func Test_CLI_Init_CanUseTemplate(t *testing.T) { // running this test in parallel is ok as it uses a t.TempDir() t.Parallel()