diff --git a/cli/azd/cmd/actions/action_descriptor.go b/cli/azd/cmd/actions/action_descriptor.go index 267cdcabb05..286aafc0db6 100644 --- a/cli/azd/cmd/actions/action_descriptor.go +++ b/cli/azd/cmd/actions/action_descriptor.go @@ -148,6 +148,13 @@ type CommandGroupOptions struct { RootLevelHelp RootLevelHelpOption } +// EnvironmentOptions contains options for the environment flag and initialization +type EnvironmentOptions struct { + // Optional should be set to true when an azd environment is optional within an azd command. + // Well known use cases for this are for `azd init` and `azd show` + Optional bool +} + // Defines the type used for annotating a command as part of a group. type commandGroupAnnotationKey string @@ -186,6 +193,8 @@ type ActionDescriptorOptions struct { HelpOptions ActionHelpOptions // Defines grouping options for the command GroupingOptions CommandGroupOptions + // Defines options for the construction of the azd environment + Environment EnvironmentOptions } // Completion function used for cobra command flag completion diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index d32ec953241..205fefb2aca 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -195,50 +195,13 @@ func registerCommonDependencies(container *ioc.NestedContainer) { // Register an initialized environment based on the specified environment flag, or the default environment. // Note that referencing an *environment.Environment in a command automatically triggers a UI prompt if the // environment is uninitialized or a default environment doesn't yet exist. - container.MustRegisterScoped( - func(ctx context.Context, - azdContext *azdcontext.AzdContext, - envManager environment.Manager, - lazyEnv *lazy.Lazy[*environment.Environment], - envFlags internal.EnvFlag, - ) (*environment.Environment, error) { - if azdContext == nil { - return nil, azdcontext.ErrNoProject - } - - environmentName := envFlags.EnvironmentName - var err error - - env, err := envManager.LoadOrInitInteractive(ctx, environmentName) - if err != nil { - return nil, fmt.Errorf("loading environment: %w", err) - } - - // Reset lazy env value after loading or creating environment - // This allows any previous lazy instances (such as hooks) to now point to the same instance - lazyEnv.SetValue(env) + container.MustRegisterScoped(func(lazyEnv *lazy.Lazy[*environment.Environment]) (*environment.Environment, error) { + return lazyEnv.GetValue() + }) - return env, nil - }, - ) - container.MustRegisterScoped(func(lazyEnvManager *lazy.Lazy[environment.Manager]) environment.EnvironmentResolver { + container.MustRegisterScoped(func(lazyEnv *lazy.Lazy[*environment.Environment]) environment.EnvironmentResolver { return func(ctx context.Context) (*environment.Environment, error) { - azdCtx, err := azdcontext.NewAzdContext() - if err != nil { - return nil, err - } - defaultEnv, err := azdCtx.GetDefaultEnvironmentName() - if err != nil { - return nil, err - } - - // We need to lazy load the environment manager since it depends on azd context - envManager, err := lazyEnvManager.GetValue() - if err != nil { - return nil, err - } - - return envManager.Get(ctx, defaultEnv) + return lazyEnv.GetValue() } }) diff --git a/cli/azd/cmd/middleware/environment.go b/cli/azd/cmd/middleware/environment.go new file mode 100644 index 00000000000..9a18894c31d --- /dev/null +++ b/cli/azd/cmd/middleware/environment.go @@ -0,0 +1,82 @@ +package middleware + +import ( + "context" + "fmt" + + "github.com/azure/azure-dev/cli/azd/cmd/actions" + "github.com/azure/azure-dev/cli/azd/internal" + "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/lazy" +) + +// EnvironmentMiddleware is a middleware that loads the environment when not readily available +type EnvironmentMiddleware struct { + lazyAzdContext *lazy.Lazy[*azdcontext.AzdContext] + lazyEnvManager *lazy.Lazy[environment.Manager] + lazyEnv *lazy.Lazy[*environment.Environment] + envFlags internal.EnvFlag +} + +// NewEnvironmentMiddleware creates a new instance of the EnvironmentMiddleware +func NewEnvironmentMiddleware( + lazyAzdContext *lazy.Lazy[*azdcontext.AzdContext], + lazyEnvManager *lazy.Lazy[environment.Manager], + lazyEnv *lazy.Lazy[*environment.Environment], + envFlags internal.EnvFlag, +) Middleware { + return &EnvironmentMiddleware{ + lazyAzdContext: lazyAzdContext, + lazyEnvManager: lazyEnvManager, + lazyEnv: lazyEnv, + envFlags: envFlags, + } +} + +// Run runs the EnvironmentMiddleware to load the environment when not readily available +func (m *EnvironmentMiddleware) Run(ctx context.Context, next NextFn) (*actions.ActionResult, error) { + // We already have an environment, skip loading + // This will typically be the case when an environment has been created from a previous command like `azd init` + env, err := m.lazyEnv.GetValue() + if err == nil && env != nil { + return next(ctx) + } + + // Needs Azd context before we can have an environment + azdContext, err := m.lazyAzdContext.GetValue() + if err != nil { + // No Azd context errors will by handled downstream + return next(ctx) + } + + envManager, err := m.lazyEnvManager.GetValue() + if err != nil { + return nil, fmt.Errorf("loading environment manager: %w", err) + } + + // Check env flag (-e, --environment) and environment variable (AZURE_ENV_NAME) + environmentName := m.envFlags.EnvironmentName + if environmentName == "" { + environmentName, err = azdContext.GetDefaultEnvironmentName() + if err != nil { + return nil, err + } + } + + // Load or initialize environment interactively from user prompt + env, err = envManager.LoadOrInitInteractive(ctx, environmentName) + if err != nil { + //nolint:lll + return nil, fmt.Errorf( + "failed loading environment. Ensure environment has been set using flag (--environment, -e) or by setting environment variable 'AZURE_ENV_NAME'. %w", + err, + ) + } + + // Reset lazy env value after loading or creating environment + // This allows any previous lazy instances (such as hooks) to now point to the same instance + m.lazyEnv.SetValue(env) + + return next(ctx) +} diff --git a/cli/azd/cmd/middleware/environment_test.go b/cli/azd/cmd/middleware/environment_test.go new file mode 100644 index 00000000000..c7499cfef26 --- /dev/null +++ b/cli/azd/cmd/middleware/environment_test.go @@ -0,0 +1,151 @@ +package middleware + +import ( + "context" + "errors" + "testing" + + "github.com/azure/azure-dev/cli/azd/cmd/actions" + "github.com/azure/azure-dev/cli/azd/internal" + "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/lazy" + "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 Test_Environment_Already_Exists(t *testing.T) { + expectedEnv := environment.NewWithValues("test", map[string]string{ + environment.SubscriptionIdEnvVarName: "SUBSCRIPTION_ID", + }) + + mockContext := mocks.NewMockContext(context.Background()) + azdContext := azdcontext.NewAzdContextWithDirectory(t.TempDir()) + + middleware, lazyEnv := createMiddlewareForTest(azdContext, expectedEnv, internal.EnvFlag{}, &mockenv.MockEnvManager{}) + result, err := middleware.Run(*mockContext.Context, nextFn) + require.NoError(t, err) + require.NotNil(t, result) + + actualEnv, err := lazyEnv.GetValue() + require.NoError(t, err) + require.NotNil(t, actualEnv) + require.Equal(t, expectedEnv.Name(), actualEnv.Name()) +} + +func Test_Environment_No_Azd_Context(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + + middleware, lazyEnv := createMiddlewareForTest(nil, nil, internal.EnvFlag{}, &mockenv.MockEnvManager{}) + result, err := middleware.Run(*mockContext.Context, nextFn) + require.NoError(t, err) + require.NotNil(t, result) + + actualEnv, err := lazyEnv.GetValue() + require.Error(t, err) + require.Nil(t, actualEnv) +} + +func Test_Environment_With_Flag(t *testing.T) { + expectedEnv := environment.NewWithValues("flag-env", map[string]string{}) + + mockContext := mocks.NewMockContext(context.Background()) + azdContext := azdcontext.NewAzdContextWithDirectory(t.TempDir()) + envFlag := internal.EnvFlag{EnvironmentName: expectedEnv.Name()} + + envManager := &mockenv.MockEnvManager{} + envManager.On("LoadOrInitInteractive", mock.Anything, mock.Anything).Return(expectedEnv, nil) + + middleware, lazyEnv := createMiddlewareForTest(azdContext, nil, envFlag, envManager) + result, err := middleware.Run(*mockContext.Context, nextFn) + require.NoError(t, err) + require.NotNil(t, result) + + actualEnv, err := lazyEnv.GetValue() + require.NoError(t, err) + require.NotNil(t, actualEnv) + require.Equal(t, expectedEnv.Name(), actualEnv.Name()) +} + +func Test_Environment_From_Prompt(t *testing.T) { + expectedEnv := environment.NewWithValues("prompt-env", map[string]string{}) + + mockContext := mocks.NewMockContext(context.Background()) + azdContext := azdcontext.NewAzdContextWithDirectory(t.TempDir()) + + envManager := &mockenv.MockEnvManager{} + envManager.On("LoadOrInitInteractive", mock.Anything, mock.Anything).Return(expectedEnv, nil) + + middleware, lazyEnv := createMiddlewareForTest(azdContext, nil, internal.EnvFlag{}, envManager) + result, err := middleware.Run(*mockContext.Context, nextFn) + require.NoError(t, err) + require.NotNil(t, result) + + actualEnv, err := lazyEnv.GetValue() + require.NoError(t, err) + require.NotNil(t, actualEnv) + require.Equal(t, expectedEnv.Name(), actualEnv.Name()) +} + +func Test_Environment_From_Default(t *testing.T) { + expectedEnv := environment.NewWithValues("default-env", map[string]string{}) + + mockContext := mocks.NewMockContext(context.Background()) + azdContext := azdcontext.NewAzdContextWithDirectory(t.TempDir()) + err := azdContext.SetProjectState(azdcontext.ProjectState{ + DefaultEnvironment: expectedEnv.Name(), + }) + require.NoError(t, err) + + envManager := &mockenv.MockEnvManager{} + envManager.On("LoadOrInitInteractive", mock.Anything, mock.Anything).Return(expectedEnv, nil) + + middleware, lazyEnv := createMiddlewareForTest(azdContext, nil, internal.EnvFlag{}, envManager) + result, err := middleware.Run(*mockContext.Context, nextFn) + require.NoError(t, err) + require.NotNil(t, result) + + actualEnv, err := lazyEnv.GetValue() + require.NoError(t, err) + require.NotNil(t, actualEnv) + require.Equal(t, expectedEnv.Name(), actualEnv.Name()) +} + +func createMiddlewareForTest( + azdContext *azdcontext.AzdContext, + env *environment.Environment, + envFlag internal.EnvFlag, + mockEnvManager *mockenv.MockEnvManager, +) (Middleware, *lazy.Lazy[*environment.Environment]) { + // Setup environment mocks for save & reload + mockEnvManager.On("Save", mock.Anything, mock.Anything).Return(nil) + mockEnvManager.On("Reload", mock.Anything, mock.Anything).Return(nil) + + lazyAzdContext := lazy.NewLazy(func() (*azdcontext.AzdContext, error) { + if azdContext == nil { + return nil, azdcontext.ErrNoProject + } + + return azdContext, nil + }) + + lazyEnvManager := lazy.NewLazy(func() (environment.Manager, error) { + return mockEnvManager, nil + }) + + lazyEnv := lazy.NewLazy(func() (*environment.Environment, error) { + if env == nil { + return nil, errors.New("environemnt not found") + } + + return env, nil + }) + + return NewEnvironmentMiddleware(lazyAzdContext, lazyEnvManager, lazyEnv, envFlag), lazyEnv +} + +func nextFn(ctx context.Context) (*actions.ActionResult, error) { + return &actions.ActionResult{}, nil +} diff --git a/cli/azd/cmd/middleware/hooks.go b/cli/azd/cmd/middleware/hooks.go index 660afe2655a..4df0a740808 100644 --- a/cli/azd/cmd/middleware/hooks.go +++ b/cli/azd/cmd/middleware/hooks.go @@ -10,91 +10,68 @@ import ( "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/input" - "github.com/azure/azure-dev/cli/azd/pkg/lazy" "github.com/azure/azure-dev/cli/azd/pkg/project" ) type HooksMiddleware struct { - lazyEnvManager *lazy.Lazy[environment.Manager] - lazyEnv *lazy.Lazy[*environment.Environment] - lazyProjectConfig *lazy.Lazy[*project.ProjectConfig] - importManager *project.ImportManager - commandRunner exec.CommandRunner - console input.Console - options *Options + envManager environment.Manager + env *environment.Environment + projectConfig *project.ProjectConfig + importManager *project.ImportManager + commandRunner exec.CommandRunner + console input.Console + options *Options } // Creates a new instance of the Hooks middleware func NewHooksMiddleware( - lazyEnvManager *lazy.Lazy[environment.Manager], - lazyEnv *lazy.Lazy[*environment.Environment], - lazyProjectConfig *lazy.Lazy[*project.ProjectConfig], + envManager environment.Manager, + env *environment.Environment, + projectConfig *project.ProjectConfig, importManager *project.ImportManager, commandRunner exec.CommandRunner, console input.Console, options *Options, ) Middleware { return &HooksMiddleware{ - lazyEnvManager: lazyEnvManager, - lazyEnv: lazyEnv, - lazyProjectConfig: lazyProjectConfig, - importManager: importManager, - commandRunner: commandRunner, - console: console, - options: options, + envManager: envManager, + env: env, + projectConfig: projectConfig, + importManager: importManager, + commandRunner: commandRunner, + console: console, + options: options, } } // Runs the Hooks middleware func (m *HooksMiddleware) Run(ctx context.Context, next NextFn) (*actions.ActionResult, error) { - env, err := m.lazyEnv.GetValue() - if err != nil { - log.Println("azd environment is not available, skipping all hook registrations.") - return next(ctx) - } - - projectConfig, err := m.lazyProjectConfig.GetValue() - if err != nil || projectConfig == nil { - log.Println("azd project is not available, skipping all hook registrations.") - return next(ctx) - } - - if err := m.registerServiceHooks(ctx, env, projectConfig); err != nil { + if err := m.registerServiceHooks(ctx); err != nil { return nil, fmt.Errorf("failed registering service hooks, %w", err) } - return m.registerCommandHooks(ctx, env, projectConfig, next) + return m.registerCommandHooks(ctx, next) } // Register command level hooks for the executing cobra command & action // Invokes the middleware next function -func (m *HooksMiddleware) registerCommandHooks( - ctx context.Context, - env *environment.Environment, - projectConfig *project.ProjectConfig, - next NextFn, -) (*actions.ActionResult, error) { - if projectConfig.Hooks == nil || len(projectConfig.Hooks) == 0 { +func (m *HooksMiddleware) registerCommandHooks(ctx context.Context, next NextFn) (*actions.ActionResult, error) { + if m.projectConfig.Hooks == nil || len(m.projectConfig.Hooks) == 0 { log.Println( "azd project is not available or does not contain any command hooks, skipping command hook registrations.", ) return next(ctx) } - envManager, err := m.lazyEnvManager.GetValue() - if err != nil { - return nil, fmt.Errorf("failed getting environment manager, %w", err) - } - - hooksManager := ext.NewHooksManager(projectConfig.Path) + hooksManager := ext.NewHooksManager(m.projectConfig.Path) hooksRunner := ext.NewHooksRunner( hooksManager, m.commandRunner, - envManager, + m.envManager, m.console, - projectConfig.Path, - projectConfig.Hooks, - env, + m.projectConfig.Path, + m.projectConfig.Hooks, + m.env, ) var actionResult *actions.ActionResult @@ -102,7 +79,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 @@ -121,17 +98,8 @@ func (m *HooksMiddleware) registerCommandHooks( // Registers event handlers for all services within the project configuration // Runs hooks for each matching event handler -func (m *HooksMiddleware) registerServiceHooks( - ctx context.Context, - env *environment.Environment, - projectConfig *project.ProjectConfig, -) error { - envManager, err := m.lazyEnvManager.GetValue() - if err != nil { - return fmt.Errorf("failed getting environment manager, %w", err) - } - - stableServices, err := m.importManager.ServiceStable(ctx, projectConfig) +func (m *HooksMiddleware) registerServiceHooks(ctx context.Context) error { + stableServices, err := m.importManager.ServiceStable(ctx, m.projectConfig) if err != nil { return fmt.Errorf("failed getting services: %w", err) } @@ -148,11 +116,11 @@ func (m *HooksMiddleware) registerServiceHooks( serviceHooksRunner := ext.NewHooksRunner( serviceHooksManager, m.commandRunner, - envManager, + m.envManager, m.console, service.Path(), service.Hooks, - env, + m.env, ) for hookName := range service.Hooks { diff --git a/cli/azd/cmd/middleware/hooks_test.go b/cli/azd/cmd/middleware/hooks_test.go index f0541f58770..225e5e59ed7 100644 --- a/cli/azd/cmd/middleware/hooks_test.go +++ b/cli/azd/cmd/middleware/hooks_test.go @@ -11,7 +11,6 @@ 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" @@ -328,22 +327,10 @@ func runMiddleware( envManager.On("Save", mock.Anything, mock.Anything).Return(nil) envManager.On("Reload", mock.Anything, mock.Anything).Return(nil) - lazyEnvManager := lazy.NewLazy(func() (environment.Manager, error) { - return envManager, nil - }) - - lazyEnv := lazy.NewLazy(func() (*environment.Environment, error) { - return env, nil - }) - - lazyProjectConfig := lazy.NewLazy(func() (*project.ProjectConfig, error) { - return projectConfig, nil - }) - middleware := NewHooksMiddleware( - lazyEnvManager, - lazyEnv, - lazyProjectConfig, + envManager, + env, + projectConfig, project.NewImportManager(nil), mockContext.CommandRunner, mockContext.Console, diff --git a/cli/azd/cmd/root.go b/cli/azd/cmd/root.go index d38ea8cc78c..3bd930d2eed 100644 --- a/cli/azd/cmd/root.go +++ b/cli/azd/cmd/root.go @@ -157,6 +157,9 @@ func NewRootCmd( GroupingOptions: actions.CommandGroupOptions{ RootLevelHelp: actions.CmdGroupMonitor, }, + Environment: actions.EnvironmentOptions{ + Optional: true, + }, }) //deprecate:cmd hide login @@ -189,6 +192,9 @@ func NewRootCmd( GroupingOptions: actions.CommandGroupOptions{ RootLevelHelp: actions.CmdGroupConfig, }, + Environment: actions.EnvironmentOptions{ + Optional: true, + }, }) root. @@ -335,6 +341,13 @@ func NewRootCmd( UseMiddleware("experimentation", middleware.NewExperimentationMiddleware). UseMiddlewareWhen("telemetry", middleware.NewTelemetryMiddleware, func(descriptor *actions.ActionDescriptor) bool { return !descriptor.Options.DisableTelemetry + }). + UseMiddlewareWhen("environment", middleware.NewEnvironmentMiddleware, func(descriptor *actions.ActionDescriptor) bool { + // The environment middleware will only be applied to commands that have the environment flag + // AND + // on commands where an environment is always required + _, err := descriptor.Options.Command.Flags().GetString(internal.EnvironmentNameFlagName) + return err == nil && !descriptor.Options.Environment.Optional }) // Register common dependencies for the IoC rootContainer diff --git a/cli/azd/test/functional/aspire_test.go b/cli/azd/test/functional/aspire_test.go index fe3d4578132..3d8da8317c2 100644 --- a/cli/azd/test/functional/aspire_test.go +++ b/cli/azd/test/functional/aspire_test.go @@ -202,9 +202,12 @@ func Test_CLI_Aspire_DetectGen(t *testing.T) { cli.WorkingDirectory = dir cli.Env = append(cli.Env, os.Environ()...) //nolint:lll - cli.Env = append(cli.Env, "AZD_ALPHA_ENABLE_INFRASYNTH=true") + cli.Env = append(cli.Env, + fmt.Sprintf("AZURE_ENV_NAME=%s", envName), + "AZD_ALPHA_ENABLE_INFRASYNTH=true", + ) - _, err = cli.RunCommand(ctx, "infra", "synth") + _, err = cli.RunCommand(ctx, "infra", "synth", "--no-prompt") require.NoError(t, err) bicepCli, err := bicep.NewBicepCli(ctx, mockinput.NewMockConsole(), exec.NewCommandRunner(nil))