Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cli/azd/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
302 changes: 275 additions & 27 deletions cli/azd/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand All @@ -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
}

Expand Down Expand Up @@ -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"),
)),
})
}

Expand Down
Loading
Loading