diff --git a/cli/azd/cmd/down.go b/cli/azd/cmd/down.go index 27c854fca80..36302336965 100644 --- a/cli/azd/cmd/down.go +++ b/cli/azd/cmd/down.go @@ -19,6 +19,7 @@ import ( inf "github.com/azure/azure-dev/cli/azd/pkg/infra" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "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/output" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/azure/azure-dev/cli/azd/pkg/project" @@ -68,7 +69,7 @@ type downAction struct { args []string provisionManager *provisioning.Manager importManager *project.ImportManager - env *environment.Environment + lazyEnv *lazy.Lazy[*environment.Environment] envManager environment.Manager console input.Console projectConfig *project.ProjectConfig @@ -79,7 +80,7 @@ func newDownAction( args []string, flags *downFlags, provisionManager *provisioning.Manager, - env *environment.Environment, + lazyEnv *lazy.Lazy[*environment.Environment], envManager environment.Manager, projectConfig *project.ProjectConfig, console input.Console, @@ -89,7 +90,7 @@ func newDownAction( return &downAction{ flags: flags, provisionManager: provisionManager, - env: env, + lazyEnv: lazyEnv, envManager: envManager, console: console, projectConfig: projectConfig, @@ -108,6 +109,26 @@ func (a *downAction) Run(ctx context.Context) (*actions.ActionResult, error) { startTime := time.Now() + // Get the environment non-interactively (respects -e flag or default environment) + env, err := a.lazyEnv.GetValue() + if err != nil { + if errors.Is(err, environment.ErrNotFound) { + return nil, &internal.ErrorWithSuggestion{ + Err: errors.New("environment not found"), + Suggestion: "Run \"azd env list\" to see available environments, " + + "\"azd env new\" to create a new one, or specify a valid environment name with -e", + } + } + if errors.Is(err, environment.ErrNameNotSpecified) { + return nil, &internal.ErrorWithSuggestion{ + Err: errors.New("no environment selected"), + Suggestion: "Run \"azd init\" or \"azd env new\" to create an environment, " + + "\"azd env select\" to set a default, or run \"azd down -e \" to target a specific environment", + } + } + return nil, err + } + infra, err := a.importManager.ProjectInfrastructure(ctx, a.projectConfig) if err != nil { return nil, err @@ -155,7 +176,7 @@ func (a *downAction) Run(ctx context.Context) (*actions.ActionResult, error) { } // Invalidate cache after successful down so azd show will refresh - if err := a.envManager.InvalidateEnvCache(ctx, a.env.Name()); err != nil { + if err := a.envManager.InvalidateEnvCache(ctx, env.Name()); err != nil { log.Printf("warning: failed to invalidate state cache: %v", err) } diff --git a/cli/azd/cmd/down_test.go b/cli/azd/cmd/down_test.go new file mode 100644 index 00000000000..9d283103225 --- /dev/null +++ b/cli/azd/cmd/down_test.go @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/pkg/alpha" + "github.com/azure/azure-dev/cli/azd/pkg/cloud" + "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/infra/provisioning" + "github.com/azure/azure-dev/cli/azd/pkg/lazy" + "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockenv" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func newTestProvisionManager( + mockContext *mocks.MockContext, + lazyEnv *lazy.Lazy[*environment.Environment], + envManager environment.Manager, +) *provisioning.Manager { + alphaManager := alpha.NewFeaturesManagerWithConfig(config.NewEmptyConfig()) + return provisioning.NewManager( + mockContext.Container, + func() (provisioning.ProviderKind, error) { return provisioning.Bicep, nil }, + envManager, + lazyEnv, + mockContext.Console, + alphaManager, + nil, + cloud.AzurePublic(), + ) +} + +func newTestDownAction( + t *testing.T, + mockContext *mocks.MockContext, + envManager *mockenv.MockEnvManager, + lazyEnv *lazy.Lazy[*environment.Environment], + provisionManager *provisioning.Manager, +) *downAction { + t.Helper() + alphaManager := alpha.NewFeaturesManagerWithConfig(config.NewEmptyConfig()) + action := newDownAction( + []string{}, + &downFlags{}, + provisionManager, + lazyEnv, + envManager, + &project.ProjectConfig{}, + mockContext.Console, + alphaManager, + project.NewImportManager(nil), + ) + return action.(*downAction) +} + +func Test_DownAction_NoEnvironments_ReturnsError(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + + envManager := &mockenv.MockEnvManager{} + + // lazyEnv must NOT be called when no env exists and it returns ErrNameNotSpecified + lazyEnv := lazy.NewLazy(func() (*environment.Environment, error) { + return nil, environment.ErrNameNotSpecified + }) + provisionManager := newTestProvisionManager(mockContext, lazyEnv, envManager) + + action := newTestDownAction(t, mockContext, envManager, lazyEnv, provisionManager) + + _, err := action.Run(*mockContext.Context) + require.Error(t, err) + + var suggestionErr *internal.ErrorWithSuggestion + require.True(t, errors.As(err, &suggestionErr)) + require.Contains(t, suggestionErr.Error(), "no environment selected") + require.Contains(t, suggestionErr.Suggestion, "azd env new") +} + +func Test_DownAction_EnvironmentNotFound_ReturnsError(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + + envManager := &mockenv.MockEnvManager{} + + // Simulate -e flag pointing to a missing environment + lazyEnv := lazy.NewLazy(func() (*environment.Environment, error) { + return nil, fmt.Errorf("'missing-env': %w", environment.ErrNotFound) + }) + provisionManager := newTestProvisionManager(mockContext, lazyEnv, envManager) + + action := newTestDownAction(t, mockContext, envManager, lazyEnv, provisionManager) + + _, err := action.Run(*mockContext.Context) + require.Error(t, err) + + var suggestionErr *internal.ErrorWithSuggestion + require.True(t, errors.As(err, &suggestionErr)) + require.Contains(t, suggestionErr.Error(), "environment not found") + require.Contains(t, suggestionErr.Suggestion, "azd env list") +} + +func Test_DownAction_NoDefaultEnvironment_ReturnsError(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + + envManager := &mockenv.MockEnvManager{} + + // No -e flag and no default environment set + lazyEnv := lazy.NewLazy(func() (*environment.Environment, error) { + return nil, environment.ErrNameNotSpecified + }) + provisionManager := newTestProvisionManager(mockContext, lazyEnv, envManager) + + action := newTestDownAction(t, mockContext, envManager, lazyEnv, provisionManager) + + _, err := action.Run(*mockContext.Context) + require.Error(t, err) + + var suggestionErr *internal.ErrorWithSuggestion + require.True(t, errors.As(err, &suggestionErr)) + require.Contains(t, suggestionErr.Error(), "no environment selected") + require.Contains(t, suggestionErr.Suggestion, "azd env select") +} + +func Test_DownAction_EnvironmentExists_ProceedsToProvisioning(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + + envManager := &mockenv.MockEnvManager{} + envManager.On("InvalidateEnvCache", mock.Anything, mock.Anything).Return(nil) + + env := environment.NewWithValues("test-env", nil) + lazyEnv := lazy.From(env) + provisionManager := newTestProvisionManager(mockContext, lazyEnv, envManager) + + action := newTestDownAction(t, mockContext, envManager, lazyEnv, provisionManager) + + _, err := action.Run(*mockContext.Context) + // The action must get past the env check and reach provisioning. + // It will fail on Initialize (no IaC provider in mock container), which is expected. + // The key assertion is: the error is NOT an env-check error. + require.Error(t, err) + var suggestionErr *internal.ErrorWithSuggestion + require.False(t, errors.As(err, &suggestionErr), "Expected a provisioning error, not an env-check error") +} diff --git a/cli/azd/cmd/middleware/hooks.go b/cli/azd/cmd/middleware/hooks.go index e45dda6244e..921a78c2d45 100644 --- a/cli/azd/cmd/middleware/hooks.go +++ b/cli/azd/cmd/middleware/hooks.go @@ -14,13 +14,14 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/ext" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/ioc" + "github.com/azure/azure-dev/cli/azd/pkg/lazy" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/azure/azure-dev/cli/azd/pkg/project" ) type HooksMiddleware struct { envManager environment.Manager - env *environment.Environment + lazyEnv *lazy.Lazy[*environment.Environment] projectConfig *project.ProjectConfig importManager *project.ImportManager commandRunner exec.CommandRunner @@ -32,7 +33,7 @@ type HooksMiddleware struct { // Creates a new instance of the Hooks middleware func NewHooksMiddleware( envManager environment.Manager, - env *environment.Environment, + lazyEnv *lazy.Lazy[*environment.Environment], projectConfig *project.ProjectConfig, importManager *project.ImportManager, commandRunner exec.CommandRunner, @@ -42,7 +43,7 @@ func NewHooksMiddleware( ) Middleware { return &HooksMiddleware{ envManager: envManager, - env: env, + lazyEnv: lazyEnv, projectConfig: projectConfig, importManager: importManager, commandRunner: commandRunner, @@ -81,6 +82,11 @@ func (m *HooksMiddleware) registerCommandHooks( return next(ctx) } + env, err := m.lazyEnv.GetValue() + if err != nil { + return nil, err + } + hooksManager := ext.NewHooksManager(m.projectConfig.Path, m.commandRunner) hooksRunner := ext.NewHooksRunner( hooksManager, @@ -89,7 +95,7 @@ func (m *HooksMiddleware) registerCommandHooks( m.console, m.projectConfig.Path, m.projectConfig.Hooks, - m.env, + env, m.serviceLocator, ) @@ -98,7 +104,7 @@ func (m *HooksMiddleware) registerCommandHooks( commandNames := []string{m.options.CommandPath} commandNames = append(commandNames, m.options.Aliases...) - err := hooksRunner.Invoke(ctx, commandNames, func() error { + err = hooksRunner.Invoke(ctx, commandNames, func() error { result, err := next(ctx) if err != nil { return err @@ -131,6 +137,11 @@ func (m *HooksMiddleware) registerServiceHooks(ctx context.Context) error { continue } + env, err := m.lazyEnv.GetValue() + if err != nil { + return err + } + serviceHooksManager := ext.NewHooksManager(service.Path(), m.commandRunner) serviceHooksRunner := ext.NewHooksRunner( serviceHooksManager, @@ -139,7 +150,7 @@ func (m *HooksMiddleware) registerServiceHooks(ctx context.Context) error { m.console, service.Path(), service.Hooks, - m.env, + env, m.serviceLocator, ) diff --git a/cli/azd/cmd/middleware/hooks_test.go b/cli/azd/cmd/middleware/hooks_test.go index f33d531d9dc..30b6e96496d 100644 --- a/cli/azd/cmd/middleware/hooks_test.go +++ b/cli/azd/cmd/middleware/hooks_test.go @@ -15,6 +15,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/ext" + "github.com/azure/azure-dev/cli/azd/pkg/lazy" "github.com/azure/azure-dev/cli/azd/pkg/project" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/azure/azure-dev/cli/azd/test/mocks/mockenv" @@ -345,7 +346,7 @@ func runMiddleware( middleware := NewHooksMiddleware( envManager, - env, + lazy.From(env), projectConfig, project.NewImportManager(nil), mockContext.CommandRunner, diff --git a/cli/azd/pkg/infra/provisioning/manager.go b/cli/azd/pkg/infra/provisioning/manager.go index 306f9dd600e..480a29d6aa8 100644 --- a/cli/azd/pkg/infra/provisioning/manager.go +++ b/cli/azd/pkg/infra/provisioning/manager.go @@ -18,6 +18,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/ioc" + "github.com/azure/azure-dev/cli/azd/pkg/lazy" "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" @@ -32,7 +33,7 @@ type Manager struct { serviceLocator ioc.ServiceLocator defaultProvider DefaultProviderResolver envManager environment.Manager - env *environment.Environment + lazyEnv *lazy.Lazy[*environment.Environment] console input.Console provider Provider alphaFeatureManager *alpha.FeatureManager @@ -101,7 +102,12 @@ func (m *Manager) Deploy(ctx context.Context) (*DeployResult, error) { m.console.StopSpinner(ctx, "Didn't find new changes.", input.StepSkipped) } - if err := UpdateEnvironment(ctx, deployResult.Deployment.Outputs, m.env, m.envManager); err != nil { + env, err := m.lazyEnv.GetValue() + if err != nil { + return nil, fmt.Errorf("getting environment: %w", err) + } + + if err := UpdateEnvironment(ctx, deployResult.Deployment.Outputs, env, m.envManager); err != nil { return nil, fmt.Errorf("updating environment with deployment outputs: %w", err) } @@ -109,7 +115,7 @@ func (m *Manager) Deploy(ctx context.Context) (*DeployResult, error) { if !filepath.IsAbs(infraRoot) { infraRoot = filepath.Join(m.projectPath, m.options.Path) } - bindMountOperations, err := azdFileShareUploadOperations(infraRoot, *m.env) + bindMountOperations, err := azdFileShareUploadOperations(infraRoot, *env) azdOperationsEnabled := m.alphaFeatureManager.IsEnabled(AzdOperationsFeatureKey) if !azdOperationsEnabled && len(bindMountOperations) > 0 { m.console.Message(ctx, ErrBindMountOperationDisabled.Error()) @@ -119,7 +125,7 @@ func (m *Manager) Deploy(ctx context.Context) (*DeployResult, error) { return nil, fmt.Errorf("looking for azd fileShare upload operations: %w", err) } if err := doBindMountOperation( - ctx, bindMountOperations, *m.env, m.console, m.fileShareService, m.cloud.StorageEndpointSuffix); err != nil { + ctx, bindMountOperations, *env, m.console, m.fileShareService, m.cloud.StorageEndpointSuffix); err != nil { return nil, fmt.Errorf("error running bind mount operation: %w", err) } } @@ -298,14 +304,19 @@ func (m *Manager) Destroy(ctx context.Context, options DestroyOptions) (*Destroy return nil, fmt.Errorf("error deleting Azure resources: %w", err) } + env, err := m.lazyEnv.GetValue() + if err != nil { + return nil, fmt.Errorf("getting environment: %w", err) + } + // Remove any outputs from the template from the environment since destroying the infrastructure // invalidated them all. for _, key := range destroyResult.InvalidatedEnvKeys { - m.env.DotenvDelete(key) + env.DotenvDelete(key) } // Update environment files to remove invalid infrastructure parameters - if err := m.envManager.Save(ctx, m.env); err != nil { + if err := m.envManager.Save(ctx, env); err != nil { return nil, fmt.Errorf("saving environment: %w", err) } @@ -426,7 +437,7 @@ func NewManager( serviceLocator ioc.ServiceLocator, defaultProvider DefaultProviderResolver, envManager environment.Manager, - env *environment.Environment, + lazyEnv *lazy.Lazy[*environment.Environment], console input.Console, alphaFeatureManager *alpha.FeatureManager, fileShareService storage.FileShareService, @@ -436,7 +447,7 @@ func NewManager( serviceLocator: serviceLocator, defaultProvider: defaultProvider, envManager: envManager, - env: env, + lazyEnv: lazyEnv, console: console, alphaFeatureManager: alphaFeatureManager, fileShareService: fileShareService, diff --git a/cli/azd/pkg/infra/provisioning/manager_test.go b/cli/azd/pkg/infra/provisioning/manager_test.go index fa241a787c6..1e71059a7cf 100644 --- a/cli/azd/pkg/infra/provisioning/manager_test.go +++ b/cli/azd/pkg/infra/provisioning/manager_test.go @@ -16,6 +16,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning/test" "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/prompt" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/azure/azure-dev/cli/azd/test/mocks/mockaccount" @@ -49,7 +50,7 @@ func TestProvisionInitializesEnvironment(t *testing.T) { mockContext.Container, defaultProvider, envManager, - env, + lazy.From(env), mockContext.Console, mockContext.AlphaFeaturesManager, nil, @@ -76,7 +77,7 @@ func TestManagerPreview(t *testing.T) { mockContext.Container, defaultProvider, envManager, - env, + lazy.From(env), mockContext.Console, mockContext.AlphaFeaturesManager, nil, @@ -105,7 +106,7 @@ func TestManagerGetState(t *testing.T) { mockContext.Container, defaultProvider, envManager, - env, + lazy.From(env), mockContext.Console, mockContext.AlphaFeaturesManager, nil, @@ -134,7 +135,7 @@ func TestManagerDeploy(t *testing.T) { mockContext.Container, defaultProvider, envManager, - env, + lazy.From(env), mockContext.Console, mockContext.AlphaFeaturesManager, nil, @@ -169,7 +170,7 @@ func TestManagerDestroyWithPositiveConfirmation(t *testing.T) { mockContext.Container, defaultProvider, envManager, - env, + lazy.From(env), mockContext.Console, mockContext.AlphaFeaturesManager, nil, @@ -205,7 +206,7 @@ func TestManagerDestroyWithNegativeConfirmation(t *testing.T) { mockContext.Container, defaultProvider, envManager, - env, + lazy.From(env), mockContext.Console, mockContext.AlphaFeaturesManager, nil,