diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 4721aa0864e..f369c560619 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -243,6 +243,12 @@ overrides: - userosscache - docstates - dylib + - filename: docs/recording-functional-tests-guide.md + words: + - httptest + - Logf + - Getenv + - httptest ignorePaths: - "**/*_test.go" - "**/mock*.go" diff --git a/cli/azd/cmd/auth_login.go b/cli/azd/cmd/auth_login.go index 69d5b2e4648..753b9f69439 100644 --- a/cli/azd/cmd/auth_login.go +++ b/cli/azd/cmd/auth_login.go @@ -196,13 +196,6 @@ func (lf *loginFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandO lf.global = global } -func newLoginFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) *loginFlags { - flags := &loginFlags{} - flags.Bind(cmd.Flags(), global) - - return flags -} - func newLoginCmd(parent string) *cobra.Command { return &cobra.Command{ Use: "login", @@ -237,9 +230,6 @@ type loginAction struct { commandRunner exec.CommandRunner } -// it is important to update both newAuthLoginAction and newLoginAction at the same time -// newAuthLoginAction is the action that is bound to `azd auth login`, -// and newLoginAction is the action that is bound to `azd login` func newAuthLoginAction( formatter output.Formatter, writer io.Writer, @@ -262,31 +252,6 @@ func newAuthLoginAction( } } -// it is important to update both newAuthLoginAction and newLoginAction at the same time -// newAuthLoginAction is the action that is bound to `azd auth login`, -// and newLoginAction is the action that is bound to `azd login` -func newLoginAction( - formatter output.Formatter, - writer io.Writer, - authManager *auth.Manager, - accountSubManager *account.SubscriptionsManager, - flags *loginFlags, - console input.Console, - annotations CmdAnnotations, - commandRunner exec.CommandRunner, -) actions.Action { - return &loginAction{ - formatter: formatter, - writer: writer, - console: console, - authManager: authManager, - accountSubManager: accountSubManager, - flags: flags, - annotations: annotations, - commandRunner: commandRunner, - } -} - func (la *loginAction) Run(ctx context.Context) (*actions.ActionResult, error) { if len(la.flags.scopes) == 0 { la.flags.scopes = la.authManager.LoginScopes() diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 23a88980725..b9da1b850c3 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -385,6 +385,18 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai log.Panic("failed to resolve console for unknown flags error:", err) } + // Check for deprecated commands and provide helpful redirection messages + if unknownCommand == "login" { + console.Message(ctx, "Error: The 'azd login' command has been removed.") + console.Message(ctx, "Please use 'azd auth login' instead.") + return fmt.Errorf("unknown command 'login'") + } + if unknownCommand == "logout" { + console.Message(ctx, "Error: The 'azd logout' command has been removed.") + console.Message(ctx, "Please use 'azd auth logout' instead.") + return fmt.Errorf("unknown command 'logout'") + } + // If unknown flags were found before a non-built-in command, return an error with helpful guidance if len(unknownFlags) > 0 { flagsList := strings.Join(unknownFlags, ", ") diff --git a/cli/azd/cmd/env.go b/cli/azd/cmd/env.go index d4bd9ff2612..49e15ea9d48 100644 --- a/cli/azd/cmd/env.go +++ b/cli/azd/cmd/env.go @@ -108,6 +108,38 @@ func envActions(root *actions.ActionDescriptor) *actions.ActionDescriptor { ActionResolver: newEnvGetValueAction, }) + // Add env config sub-command group + configGroup := group.Add("config", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{ + Use: "config", + Short: "Manage environment configuration (ex: stored in .azure//config.json).", + }, + HelpOptions: actions.ActionHelpOptions{ + Description: getCmdEnvConfigHelpDescription, + Footer: getCmdEnvConfigHelpFooter, + }, + }) + + configGroup.Add("get", &actions.ActionDescriptorOptions{ + Command: newEnvConfigGetCmd(), + FlagsResolver: newEnvConfigGetFlags, + ActionResolver: newEnvConfigGetAction, + OutputFormats: []output.Format{output.JsonFormat}, + DefaultFormat: output.JsonFormat, + }) + + configGroup.Add("set", &actions.ActionDescriptorOptions{ + Command: newEnvConfigSetCmd(), + FlagsResolver: newEnvConfigSetFlags, + ActionResolver: newEnvConfigSetAction, + }) + + configGroup.Add("unset", &actions.ActionDescriptorOptions{ + Command: newEnvConfigUnsetCmd(), + FlagsResolver: newEnvConfigUnsetFlags, + ActionResolver: newEnvConfigUnsetAction, + }) + return group } @@ -1350,6 +1382,294 @@ func (eg *envGetValueAction) Run(ctx context.Context) (*actions.ActionResult, er return nil, nil } +// azd env config get + +func newEnvConfigGetCmd() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Gets a configuration value from the environment.", + Long: "Gets a configuration value from the environment's config.json file.", + Args: cobra.ExactArgs(1), + } +} + +type envConfigGetFlags struct { + internal.EnvFlag + global *internal.GlobalCommandOptions +} + +func newEnvConfigGetFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) *envConfigGetFlags { + flags := &envConfigGetFlags{} + flags.Bind(cmd.Flags(), global) + return flags +} + +func (f *envConfigGetFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandOptions) { + f.EnvFlag.Bind(local, global) + f.global = global +} + +type envConfigGetAction struct { + azdCtx *azdcontext.AzdContext + envManager environment.Manager + formatter output.Formatter + writer io.Writer + flags *envConfigGetFlags + args []string +} + +func newEnvConfigGetAction( + azdCtx *azdcontext.AzdContext, + envManager environment.Manager, + formatter output.Formatter, + writer io.Writer, + flags *envConfigGetFlags, + args []string, +) actions.Action { + return &envConfigGetAction{ + azdCtx: azdCtx, + envManager: envManager, + formatter: formatter, + writer: writer, + flags: flags, + args: args, + } +} + +func (a *envConfigGetAction) Run(ctx context.Context) (*actions.ActionResult, error) { + name, err := a.azdCtx.GetDefaultEnvironmentName() + if err != nil { + return nil, err + } + if a.flags.EnvironmentName != "" { + name = a.flags.EnvironmentName + } + + env, err := a.envManager.Get(ctx, name) + if errors.Is(err, environment.ErrNotFound) { + return nil, fmt.Errorf( + `environment '%s' does not exist. You can create it with "azd env new %s"`, + name, + name, + ) + } else if err != nil { + return nil, fmt.Errorf("getting environment: %w", err) + } + + key := a.args[0] + value, ok := env.Config.Get(key) + + if !ok { + return nil, fmt.Errorf("no value stored at path '%s'", key) + } + + if a.formatter.Kind() == output.JsonFormat { + err := a.formatter.Format(value, a.writer, nil) + if err != nil { + return nil, fmt.Errorf("failing formatting config values: %w", err) + } + } + + return nil, nil +} + +// azd env config set + +func newEnvConfigSetCmd() *cobra.Command { + return &cobra.Command{ + Use: "set ", + Short: "Sets a configuration value in the environment.", + Long: "Sets a configuration value in the environment's config.json file.", + Args: cobra.ExactArgs(2), + Example: `$ azd env config set myapp.endpoint https://example.com +$ azd env config set myapp.debug true`, + } +} + +type envConfigSetFlags struct { + internal.EnvFlag + global *internal.GlobalCommandOptions +} + +func newEnvConfigSetFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) *envConfigSetFlags { + flags := &envConfigSetFlags{} + flags.Bind(cmd.Flags(), global) + return flags +} + +func (f *envConfigSetFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandOptions) { + f.EnvFlag.Bind(local, global) + f.global = global +} + +type envConfigSetAction struct { + azdCtx *azdcontext.AzdContext + envManager environment.Manager + flags *envConfigSetFlags + args []string +} + +func newEnvConfigSetAction( + azdCtx *azdcontext.AzdContext, + envManager environment.Manager, + flags *envConfigSetFlags, + args []string, +) actions.Action { + return &envConfigSetAction{ + azdCtx: azdCtx, + envManager: envManager, + flags: flags, + args: args, + } +} + +func (a *envConfigSetAction) Run(ctx context.Context) (*actions.ActionResult, error) { + name, err := a.azdCtx.GetDefaultEnvironmentName() + if err != nil { + return nil, err + } + if a.flags.EnvironmentName != "" { + name = a.flags.EnvironmentName + } + + env, err := a.envManager.Get(ctx, name) + if errors.Is(err, environment.ErrNotFound) { + return nil, fmt.Errorf( + `environment '%s' does not exist. You can create it with "azd env new %s"`, + name, + name, + ) + } else if err != nil { + return nil, fmt.Errorf("getting environment: %w", err) + } + + path := a.args[0] + value := a.args[1] + + err = env.Config.Set(path, value) + if err != nil { + return nil, fmt.Errorf("failed setting configuration value '%s' to '%s'. %w", path, value, err) + } + + if err := a.envManager.Save(ctx, env); err != nil { + return nil, fmt.Errorf("saving environment: %w", err) + } + + return nil, nil +} + +// azd env config unset + +func newEnvConfigUnsetCmd() *cobra.Command { + return &cobra.Command{ + Use: "unset ", + Short: "Unsets a configuration value in the environment.", + Long: "Removes a configuration value from the environment's config.json file.", + Example: `$ azd env config unset myapp.endpoint`, + Args: cobra.ExactArgs(1), + } +} + +type envConfigUnsetFlags struct { + internal.EnvFlag + global *internal.GlobalCommandOptions +} + +func newEnvConfigUnsetFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) *envConfigUnsetFlags { + flags := &envConfigUnsetFlags{} + flags.Bind(cmd.Flags(), global) + return flags +} + +func (f *envConfigUnsetFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandOptions) { + f.EnvFlag.Bind(local, global) + f.global = global +} + +type envConfigUnsetAction struct { + azdCtx *azdcontext.AzdContext + envManager environment.Manager + flags *envConfigUnsetFlags + args []string +} + +func newEnvConfigUnsetAction( + azdCtx *azdcontext.AzdContext, + envManager environment.Manager, + flags *envConfigUnsetFlags, + args []string, +) actions.Action { + return &envConfigUnsetAction{ + azdCtx: azdCtx, + envManager: envManager, + flags: flags, + args: args, + } +} + +func (a *envConfigUnsetAction) Run(ctx context.Context) (*actions.ActionResult, error) { + name, err := a.azdCtx.GetDefaultEnvironmentName() + if err != nil { + return nil, err + } + if a.flags.EnvironmentName != "" { + name = a.flags.EnvironmentName + } + + env, err := a.envManager.Get(ctx, name) + if errors.Is(err, environment.ErrNotFound) { + return nil, fmt.Errorf( + `environment '%s' does not exist. You can create it with "azd env new %s"`, + name, + name, + ) + } else if err != nil { + return nil, fmt.Errorf("getting environment: %w", err) + } + + path := a.args[0] + + err = env.Config.Unset(path) + if err != nil { + return nil, fmt.Errorf("failed removing configuration with path '%s'. %w", path, err) + } + + if err := a.envManager.Save(ctx, env); err != nil { + return nil, fmt.Errorf("saving environment: %w", err) + } + + return nil, nil +} + +// Help functions for env config commands + +func getCmdEnvConfigHelpDescription(*cobra.Command) string { + return generateCmdHelpDescription( + "Manage environment-specific configuration stored in .azure//config.json.", + []string{ + formatHelpNote("Configuration values set with these commands are specific to the environment."), + formatHelpNote("These values are separate from environment variables (.env file)."), + formatHelpNote( + "Environment configuration is stored in .azure//config.json.", + ), + }) +} + +func getCmdEnvConfigHelpFooter(c *cobra.Command) string { + return generateCmdHelpSamplesBlock(map[string]string{ + "Get a configuration value": fmt.Sprintf("%s %s", + output.WithHighLightFormat("azd env config get"), + output.WithWarningFormat("myapp.endpoint")), + "Set a configuration value": fmt.Sprintf("%s %s %s", + output.WithHighLightFormat("azd env config set"), + output.WithWarningFormat("myapp.endpoint"), + output.WithWarningFormat("https://example.com")), + "Unset a configuration value": fmt.Sprintf("%s %s", + output.WithHighLightFormat("azd env config unset"), + output.WithWarningFormat("myapp.endpoint")), + }) +} + func getCmdEnvHelpDescription(*cobra.Command) string { return generateCmdHelpDescription( "Manage your application environments. With this command group, you can create a new environment or get, set,"+ diff --git a/cli/azd/cmd/env_config_test.go b/cli/azd/cmd/env_config_test.go new file mode 100644 index 00000000000..c259c110c27 --- /dev/null +++ b/cli/azd/cmd/env_config_test.go @@ -0,0 +1,588 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "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/output" + "github.com/azure/azure-dev/cli/azd/pkg/state" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/stretchr/testify/require" +) + +// setupTestEnvironment creates a test environment with config data +func setupTestEnvironment(t *testing.T, envName string, configData map[string]any) ( + *azdcontext.AzdContext, + environment.Manager, + string, +) { + tempDir := t.TempDir() + envDir := filepath.Join(tempDir, "project") + require.NoError(t, os.MkdirAll(filepath.Join(envDir, ".azure", envName), 0755)) + + azdCtx := azdcontext.NewAzdContextWithDirectory(envDir) + env := environment.New(envName) + env.Config = config.NewConfig(configData) + + // Create config manager + configManager := config.NewFileConfigManager(config.NewManager()) + localDataStore := environment.NewLocalFileDataStore(azdCtx, configManager) + + // Save environment + err := localDataStore.Save(context.Background(), env, &environment.SaveOptions{IsNew: true}) + require.NoError(t, err) + + // Create mock context and register environment manager + mockContext := mocks.NewMockContext(context.Background()) + mockContext.Container.MustRegisterSingleton(func() *azdcontext.AzdContext { + return azdCtx + }) + mockContext.Container.MustRegisterSingleton(func() environment.LocalDataStore { + return localDataStore + }) + mockContext.Container.MustRegisterSingleton(func() *state.RemoteConfig { + return nil + }) + mockContext.Container.MustRegisterSingleton(environment.NewManager) + + // Get environment manager from container + var envManager environment.Manager + err = mockContext.Container.Resolve(&envManager) + require.NoError(t, err) + + return azdCtx, envManager, envDir +} + +// TestEnvConfigGet tests the azd env config get command +func TestEnvConfigGet(t *testing.T) { + tests := []struct { + name string + configData map[string]any + path string + expectedValue any + expectError bool + errorContains string + }{ + { + name: "GetSimpleValue", + configData: map[string]any{ + "key": "value", + }, + path: "key", + expectedValue: "value", + expectError: false, + }, + { + name: "GetNestedValue", + configData: map[string]any{ + "app": map[string]any{ + "endpoint": "https://example.com", + }, + }, + path: "app.endpoint", + expectedValue: "https://example.com", + expectError: false, + }, + { + name: "GetNestedObject", + configData: map[string]any{ + "app": map[string]any{ + "endpoint": "https://example.com", + "port": "8080", + }, + }, + path: "app", + expectedValue: map[string]any{ + "endpoint": "https://example.com", + "port": "8080", + }, + expectError: false, + }, + { + name: "GetNonExistentKey", + configData: map[string]any{ + "key": "value", + }, + path: "nonexistent", + expectError: true, + errorContains: "no value stored at path", + }, + { + name: "GetDeeplyNestedValue", + configData: map[string]any{ + "level1": map[string]any{ + "level2": map[string]any{ + "level3": "deep-value", + }, + }, + }, + path: "level1.level2.level3", + expectedValue: "deep-value", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + envName := "test-env" + azdCtx, envManager, _ := setupTestEnvironment(t, envName, tt.configData) + + // Setup action + buf := &bytes.Buffer{} + flags := &envConfigGetFlags{} + flags.EnvironmentName = envName + action := newEnvConfigGetAction( + azdCtx, + envManager, + &output.JsonFormatter{}, + buf, + flags, + []string{tt.path}, + ) + + // Run action + _, err := action.Run(context.Background()) + + // Verify results + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + require.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + + var result any + err = json.Unmarshal(buf.Bytes(), &result) + require.NoError(t, err) + require.Equal(t, tt.expectedValue, result) + } + }) + } +} + +// TestEnvConfigSet tests the azd env config set command +func TestEnvConfigSet(t *testing.T) { + tests := []struct { + name string + initialConfig map[string]any + path string + value string + expectedConfig map[string]any + expectError bool + }{ + { + name: "SetSimpleValue", + initialConfig: map[string]any{}, + path: "key", + value: "value", + expectedConfig: map[string]any{ + "key": "value", + }, + expectError: false, + }, + { + name: "SetNestedValue", + initialConfig: map[string]any{}, + path: "app.endpoint", + value: "https://example.com", + expectedConfig: map[string]any{ + "app": map[string]any{ + "endpoint": "https://example.com", + }, + }, + expectError: false, + }, + { + name: "UpdateExistingValue", + initialConfig: map[string]any{ + "key": "old-value", + }, + path: "key", + value: "new-value", + expectedConfig: map[string]any{ + "key": "new-value", + }, + expectError: false, + }, + { + name: "AddToExistingObject", + initialConfig: map[string]any{ + "app": map[string]any{ + "endpoint": "https://example.com", + }, + }, + path: "app.port", + value: "8080", + expectedConfig: map[string]any{ + "app": map[string]any{ + "endpoint": "https://example.com", + "port": "8080", + }, + }, + expectError: false, + }, + { + name: "SetDeeplyNestedValue", + initialConfig: map[string]any{}, + path: "level1.level2.level3", + value: "deep-value", + expectedConfig: map[string]any{ + "level1": map[string]any{ + "level2": map[string]any{ + "level3": "deep-value", + }, + }, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + envName := "test-env" + azdCtx, envManager, _ := setupTestEnvironment(t, envName, tt.initialConfig) + + // Setup action + flags := &envConfigSetFlags{} + flags.EnvironmentName = envName + action := newEnvConfigSetAction( + azdCtx, + envManager, + flags, + []string{tt.path, tt.value}, + ) + + // Run action + _, err := action.Run(context.Background()) + + // Verify results + if tt.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + + // Reload environment and verify config + reloadedEnv, err := envManager.Get(context.Background(), envName) + require.NoError(t, err) + + require.Equal(t, tt.expectedConfig, reloadedEnv.Config.Raw()) + } + }) + } +} + +// TestEnvConfigUnset tests the azd env config unset command +func TestEnvConfigUnset(t *testing.T) { + tests := []struct { + name string + initialConfig map[string]any + path string + expectedConfig map[string]any + expectError bool + }{ + { + name: "UnsetSimpleValue", + initialConfig: map[string]any{ + "key": "value", + }, + path: "key", + expectedConfig: map[string]any{}, + expectError: false, + }, + { + name: "UnsetNestedValue", + initialConfig: map[string]any{ + "app": map[string]any{ + "endpoint": "https://example.com", + "port": "8080", + }, + }, + path: "app.endpoint", + expectedConfig: map[string]any{ + "app": map[string]any{ + "port": "8080", + }, + }, + expectError: false, + }, + { + name: "UnsetEntireObject", + initialConfig: map[string]any{ + "app": map[string]any{ + "endpoint": "https://example.com", + "port": "8080", + }, + "other": "value", + }, + path: "app", + expectedConfig: map[string]any{ + "other": "value", + }, + expectError: false, + }, + { + name: "UnsetNonExistentKey", + initialConfig: map[string]any{ + "key": "value", + }, + path: "nonexistent", + expectedConfig: map[string]any{ + "key": "value", + }, + expectError: false, // Unset is idempotent + }, + { + name: "UnsetDeeplyNestedValue", + initialConfig: map[string]any{ + "level1": map[string]any{ + "level2": map[string]any{ + "level3": "deep-value", + "other": "keep", + }, + }, + }, + path: "level1.level2.level3", + expectedConfig: map[string]any{ + "level1": map[string]any{ + "level2": map[string]any{ + "other": "keep", + }, + }, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + envName := "test-env" + azdCtx, envManager, _ := setupTestEnvironment(t, envName, tt.initialConfig) + + // Setup action + flags := &envConfigUnsetFlags{} + flags.EnvironmentName = envName + action := newEnvConfigUnsetAction( + azdCtx, + envManager, + flags, + []string{tt.path}, + ) + + // Run action + _, err := action.Run(context.Background()) + + // Verify results + if tt.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + + // Reload environment and verify config + reloadedEnv, err := envManager.Get(context.Background(), envName) + require.NoError(t, err) + + require.Equal(t, tt.expectedConfig, reloadedEnv.Config.Raw()) + } + }) + } +} + +// TestEnvConfigNonExistentEnvironment tests error handling when environment doesn't exist +func TestEnvConfigNonExistentEnvironment(t *testing.T) { + tempDir := t.TempDir() + azdCtx := azdcontext.NewAzdContextWithDirectory(tempDir) + + configManager := config.NewFileConfigManager(config.NewManager()) + localDataStore := environment.NewLocalFileDataStore(azdCtx, configManager) + + mockContext := mocks.NewMockContext(context.Background()) + mockContext.Container.MustRegisterSingleton(func() *azdcontext.AzdContext { + return azdCtx + }) + mockContext.Container.MustRegisterSingleton(func() environment.LocalDataStore { + return localDataStore + }) + mockContext.Container.MustRegisterSingleton(func() *state.RemoteConfig { + return nil + }) + mockContext.Container.MustRegisterSingleton(environment.NewManager) + + var envManager environment.Manager + err := mockContext.Container.Resolve(&envManager) + require.NoError(t, err) + + t.Run("GetWithNonExistentEnv", func(t *testing.T) { + buf := &bytes.Buffer{} + flags := &envConfigGetFlags{} + flags.EnvironmentName = "nonexistent" + action := newEnvConfigGetAction( + azdCtx, + envManager, + &output.JsonFormatter{}, + buf, + flags, + []string{"key"}, + ) + + _, err := action.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "does not exist") + }) + + t.Run("SetWithNonExistentEnv", func(t *testing.T) { + flags := &envConfigSetFlags{} + flags.EnvironmentName = "nonexistent" + action := newEnvConfigSetAction( + azdCtx, + envManager, + flags, + []string{"key", "value"}, + ) + + _, err := action.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "does not exist") + }) + + t.Run("UnsetWithNonExistentEnv", func(t *testing.T) { + flags := &envConfigUnsetFlags{} + flags.EnvironmentName = "nonexistent" + action := newEnvConfigUnsetAction( + azdCtx, + envManager, + flags, + []string{"key"}, + ) + + _, err := action.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "does not exist") + }) +} + +// TestEnvConfigWithDefaultEnvironment tests commands work with default environment +func TestEnvConfigWithDefaultEnvironment(t *testing.T) { + envName := "default-env" + azdCtx, envManager, _ := setupTestEnvironment(t, envName, map[string]any{ + "test": "value", + }) + + // Set default environment + err := azdCtx.SetProjectState(azdcontext.ProjectState{DefaultEnvironment: envName}) + require.NoError(t, err) + + t.Run("GetWithDefaultEnv", func(t *testing.T) { + buf := &bytes.Buffer{} + flags := &envConfigGetFlags{} + flags.EnvironmentName = "" // Use default + action := newEnvConfigGetAction( + azdCtx, + envManager, + &output.JsonFormatter{}, + buf, + flags, + []string{"test"}, + ) + + _, err := action.Run(context.Background()) + require.NoError(t, err) + + var result any + err = json.Unmarshal(buf.Bytes(), &result) + require.NoError(t, err) + require.Equal(t, "value", result) + }) +} + +// TestEnvConfigMultipleOperations tests multiple operations on the same environment +func TestEnvConfigMultipleOperations(t *testing.T) { + envName := "multi-op-env" + azdCtx, envManager, _ := setupTestEnvironment(t, envName, map[string]any{}) + + // Set multiple values + setFlags1 := &envConfigSetFlags{} + setFlags1.EnvironmentName = envName + setAction1 := newEnvConfigSetAction( + azdCtx, + envManager, + setFlags1, + []string{"app.endpoint", "https://example.com"}, + ) + _, err := setAction1.Run(context.Background()) + require.NoError(t, err) + + setFlags2 := &envConfigSetFlags{} + setFlags2.EnvironmentName = envName + setAction2 := newEnvConfigSetAction( + azdCtx, + envManager, + setFlags2, + []string{"app.port", "8080"}, + ) + _, err = setAction2.Run(context.Background()) + require.NoError(t, err) + + // Verify both values exist + buf := &bytes.Buffer{} + getFlags1 := &envConfigGetFlags{} + getFlags1.EnvironmentName = envName + getAction := newEnvConfigGetAction( + azdCtx, + envManager, + &output.JsonFormatter{}, + buf, + getFlags1, + []string{"app"}, + ) + _, err = getAction.Run(context.Background()) + require.NoError(t, err) + + var result map[string]any + err = json.Unmarshal(buf.Bytes(), &result) + require.NoError(t, err) + require.Equal(t, "https://example.com", result["endpoint"]) + require.Equal(t, "8080", result["port"]) + + // Unset one value + unsetFlags := &envConfigUnsetFlags{} + unsetFlags.EnvironmentName = envName + unsetAction := newEnvConfigUnsetAction( + azdCtx, + envManager, + unsetFlags, + []string{"app.endpoint"}, + ) + _, err = unsetAction.Run(context.Background()) + require.NoError(t, err) + + // Verify only port remains + buf = &bytes.Buffer{} + getFlags2 := &envConfigGetFlags{} + getFlags2.EnvironmentName = envName + getAction2 := newEnvConfigGetAction( + azdCtx, + envManager, + &output.JsonFormatter{}, + buf, + getFlags2, + []string{"app"}, + ) + _, err = getAction2.Run(context.Background()) + require.NoError(t, err) + + var result2 map[string]any + err = json.Unmarshal(buf.Bytes(), &result2) + require.NoError(t, err) + require.Equal(t, map[string]any{"port": "8080"}, result2) +} diff --git a/cli/azd/cmd/root.go b/cli/azd/cmd/root.go index 6cb96d4a1e0..2c214b6fbc3 100644 --- a/cli/azd/cmd/root.go +++ b/cli/azd/cmd/root.go @@ -203,25 +203,6 @@ func NewRootCmd( }). UseMiddleware("extensions", middleware.NewExtensionsMiddleware) - //deprecate:cmd hide login - login := newLoginCmd("") - login.Hidden = true - root.Add("login", &actions.ActionDescriptorOptions{ - Command: login, - FlagsResolver: newLoginFlags, - ActionResolver: newLoginAction, - OutputFormats: []output.Format{output.JsonFormat, output.NoneFormat}, - DefaultFormat: output.NoneFormat, - }) - - //deprecate:cmd hide logout - logout := newLogoutCmd("") - logout.Hidden = true - root.Add("logout", &actions.ActionDescriptorOptions{ - Command: logout, - ActionResolver: newLogoutAction, - }) - root.Add("init", &actions.ActionDescriptorOptions{ Command: newInitCmd(), FlagsResolver: newInitFlags, diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 11f41f1eb82..23adaa4b8a6 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -429,6 +429,71 @@ const completionSpec: Fig.Spec = { name: ['env'], description: 'Manage environments (ex: default environment, environment variables).', subcommands: [ + { + name: ['config'], + description: 'Manage environment configuration (ex: stored in .azure//config.json).', + subcommands: [ + { + name: ['get'], + description: 'Gets a configuration value from the environment.', + options: [ + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + ], + args: { + name: 'path', + }, + }, + { + name: ['set'], + description: 'Sets a configuration value in the environment.', + options: [ + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + ], + args: [ + { + name: 'path', + }, + { + name: 'value', + }, + ], + }, + { + name: ['unset'], + description: 'Unsets a configuration value in the environment.', + options: [ + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + ], + args: { + name: 'path', + }, + }, + ], + }, { name: ['get-value'], description: 'Get specific environment value.', @@ -1618,6 +1683,24 @@ const completionSpec: Fig.Spec = { name: ['env'], description: 'Manage environments (ex: default environment, environment variables).', subcommands: [ + { + name: ['config'], + description: 'Manage environment configuration (ex: stored in .azure//config.json).', + subcommands: [ + { + name: ['get'], + description: 'Gets a configuration value from the environment.', + }, + { + name: ['set'], + description: 'Sets a configuration value in the environment.', + }, + { + name: ['unset'], + description: 'Unsets a configuration value in the environment.', + }, + ], + }, { name: ['get-value'], description: 'Get specific environment value.', diff --git a/cli/azd/cmd/testdata/TestUsage-azd-env-config-get.snap b/cli/azd/cmd/testdata/TestUsage-azd-env-config-get.snap new file mode 100644 index 00000000000..e0f5e0b8ca3 --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-env-config-get.snap @@ -0,0 +1,19 @@ + +Gets a configuration value from the environment. + +Usage + azd env config get [flags] + +Flags + -e, --environment string : The name of the environment to use. + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd env config get in your web browser. + -h, --help : Gets help for get. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-env-config-set.snap b/cli/azd/cmd/testdata/TestUsage-azd-env-config-set.snap new file mode 100644 index 00000000000..1f9f91d4086 --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-env-config-set.snap @@ -0,0 +1,19 @@ + +Sets a configuration value in the environment. + +Usage + azd env config set [flags] + +Flags + -e, --environment string : The name of the environment to use. + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd env config set in your web browser. + -h, --help : Gets help for set. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-env-config-unset.snap b/cli/azd/cmd/testdata/TestUsage-azd-env-config-unset.snap new file mode 100644 index 00000000000..8d6934bfcef --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-env-config-unset.snap @@ -0,0 +1,19 @@ + +Unsets a configuration value in the environment. + +Usage + azd env config unset [flags] + +Flags + -e, --environment string : The name of the environment to use. + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd env config unset in your web browser. + -h, --help : Gets help for unset. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-env-config.snap b/cli/azd/cmd/testdata/TestUsage-azd-env-config.snap new file mode 100644 index 00000000000..4f54421f3ba --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-env-config.snap @@ -0,0 +1,35 @@ + +Manage environment-specific configuration stored in .azure//config.json. + + • Configuration values set with these commands are specific to the environment. + • These values are separate from environment variables (.env file). + • Environment configuration is stored in .azure//config.json. + +Usage + azd env config [command] + +Available Commands + get : Gets a configuration value from the environment. + set : Sets a configuration value in the environment. + unset : Unsets a configuration value in the environment. + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd env config in your web browser. + -h, --help : Gets help for config. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Use azd env config [command] --help to view examples and more information about a specific command. + +Examples + Get a configuration value + azd env config get myapp.endpoint + + Set a configuration value + azd env config set myapp.endpoint https://example.com + + Unset a configuration value + azd env config unset myapp.endpoint + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-env.snap b/cli/azd/cmd/testdata/TestUsage-azd-env.snap index 40f064e0689..d13f0a0a990 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-env.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-env.snap @@ -10,6 +10,7 @@ Usage azd env [command] Available Commands + config : Manage environment configuration (ex: stored in .azure//config.json). get-value : Get specific environment value. get-values : Get all environment values. list : List environments. diff --git a/cli/azd/docs/recording-functional-tests-guide.md b/cli/azd/docs/recording-functional-tests-guide.md new file mode 100644 index 00000000000..4557563e232 --- /dev/null +++ b/cli/azd/docs/recording-functional-tests-guide.md @@ -0,0 +1,1028 @@ +# Recording Framework for Azure Developer CLI Functional Tests + +## Table of Contents +- [Overview](#overview) +- [How Recording Works](#how-recording-works) +- [Recording Capabilities](#recording-capabilities) +- [Creating a New Functional Test with Recording](#creating-a-new-functional-test-with-recording) +- [Re-recording an Existing Test](#re-recording-an-existing-test) +- [Building AZD with Recording Support](#building-azd-with-recording-support) +- [Using Recordings in CI](#using-recordings-in-ci) +- [Recording for Extensions](#recording-for-extensions) +- [Best Practices and Tips](#best-practices-and-tips) +- [Troubleshooting](#troubleshooting) + +--- + +## Overview + +The Azure Developer CLI (azd) uses a sophisticated recording framework for functional tests that allows tests to: +- Run against live Azure resources and record HTTP interactions +- Replay recorded interactions for fast, deterministic testing +- Support both HTTP/HTTPS traffic and command-line tool invocations (like `docker`, `dotnet`) + +The recording framework is based on [go-vcr](https://github.com/dnaeon/go-vcr) but extends it with: +- HTTP proxy server support for recording/playback +- Command proxy support for tools like Docker and dotnet +- Variable storage across test runs +- Automatic sanitization of sensitive data +- Smart matching for dynamic Azure resources + +--- + +## How Recording Works + +### Architecture + +The recording system consists of several components: + +``` +┌─────────────────┐ +│ Functional │ +│ Test Code │ +└────────┬────────┘ + │ + ├──► recording.Start(t) + │ + ▼ +┌─────────────────────────────────────┐ +│ Recording Session │ +│ - ProxyUrl (HTTPS proxy) │ +│ - CmdProxyPaths (for docker, etc) │ +│ - Variables (env names, etc) │ +│ - ProxyClient (HTTP client) │ +└─────────────────────────────────────┘ + │ + ├──► HTTP Traffic ──► go-vcr recorder ──► YAML cassettes + │ + └──► Command Calls ──► cmdrecord proxies ──► YAML cassettes +``` + +### Recording Modes + +The framework supports four modes (controlled by `AZURE_RECORD_MODE` env var): + +1. **`live`** - No recording/playback, direct passthrough to Azure +2. **`record`** - Records all interactions, overwrites existing recordings +3. **`playback`** - Only replays from recordings, fails if not found +4. **`recordOnce`** (default for local dev) - Records if no cassette exists, otherwise plays back + +In CI environments, the default is determined by whether recordings exist. + +### What Gets Recorded + +#### HTTP/HTTPS Traffic +- All Azure Resource Manager API calls +- Azure Storage operations +- Container Registry interactions +- Most Azure service APIs + +#### Command Invocations +The framework can record specific command invocations: +- **Docker**: `docker login`, `docker push` +- **Dotnet**: `dotnet publish` with ContainerRegistry parameter + +#### Variables Stored +Session variables are stored separately from interactions: +- `env_name` - Azure environment name +- `subscription_id` - Azure subscription ID +- `time` - Unix timestamp of recording + +--- + +## Recording Capabilities + +### HTTP Recording Features + +#### Automatic Sanitization +The framework automatically sanitizes sensitive data: +- Authorization headers → `SANITIZED` +- Container registry tokens +- Storage account SAS signatures +- Key Vault secrets +- Container app secrets + +#### Smart Matching +Custom matchers handle dynamic Azure resources: +- Role assignment GUIDs are ignored in matching +- Container app operation result query parameters are ignored +- Host mapping for `httptest.NewServer` URLs + +#### Passthrough for Personal Data +Certain endpoints bypass recording to avoid storing personal information: +- `login.microsoftonline.com` +- `graph.microsoft.com` +- `applicationinsights.azure.com` +- AZD release/update endpoints + +#### Fast-Forward Polling +Long-running operations are automatically fast-forwarded during recording to avoid storing hundreds of polling requests. + +### Command Recording Features + +The `cmdrecord` package intercepts specific command invocations: + +```go +cmdrecord.NewWithOptions(cmdrecord.Options{ + CmdName: "docker", + CassetteName: name, + RecordMode: opt.mode, + Intercepts: []cmdrecord.Intercept{ + {ArgsMatch: "^login"}, + {ArgsMatch: "^push"}, + }, +}) +``` + +This creates a proxy executable that: +1. Intercepts matching command invocations +2. Records inputs/outputs to a separate YAML file +3. Replays during playback mode + +--- + +## Creating a New Functional Test with Recording + +### Step-by-Step Guide + +#### 1. Create Your Test Function + +```go +func Test_CLI_MyNewFeature(t *testing.T) { + t.Parallel() // Most tests run in parallel + + ctx, cancel := newTestContext(t) + defer cancel() + + // Create a temporary directory for test files + dir := tempDirWithDiagnostics(t) + t.Logf("DIR: %s", dir) + + // Start the recording session + session := recording.Start(t) + + // Generate or retrieve environment name + envName := randomOrStoredEnvName(session) + t.Logf("AZURE_ENV_NAME: %s", envName) + + // Create CLI with recording session + cli := azdcli.NewCLI(t, azdcli.WithSession(session)) + cli.WorkingDirectory = dir + cli.Env = append(cli.Env, os.Environ()...) + cli.Env = append(cli.Env, "AZURE_LOCATION=eastus2") + + // Setup cleanup (only runs in live mode, not playback) + defer cleanupDeployments(ctx, t, cli, session, envName) + + // ... rest of test logic +} +``` + +#### 2. Use Session-Aware HTTP Clients + +For Azure SDK clients, use the session's ProxyClient: + +```go +var client *http.Client +subscriptionId := cfg.SubscriptionID + +if session != nil { + client = session.ProxyClient + + if session.Playback { + // Use recorded subscription ID + subscriptionId = session.Variables[recording.SubscriptionIdKey] + } +} else { + client = http.DefaultClient +} + +// Create Azure SDK client with session transport +cred := azdcli.NewTestCredential(cli) +rgClient, err := armresources.NewResourceGroupsClient(subscriptionId, cred, &arm.ClientOptions{ + ClientOptions: azcore.ClientOptions{ + Transport: client, + }, +}) +``` + +#### 3. Store and Retrieve Variables + +For values that change between recording and playback: + +```go +// During test execution +if session != nil { + // This will be stored in the recording + session.Variables[recording.SubscriptionIdKey] = env[environment.SubscriptionIdEnvVarName] +} + +// When reading back +if session != nil && session.Playback { + subscriptionId = session.Variables[recording.SubscriptionIdKey] +} +``` + +Use `randomOrStoredEnvName()` helper for environment names: + +```go +// Automatically handles recording vs playback +envName := randomOrStoredEnvName(session) +``` + +#### 4. Handle Time-Dependent Operations + +For operations that validate timing or poll: + +```go +if session == nil { + // Live mode - use real delays + err = probeServiceHealth( + t, ctx, http.DefaultClient, + retry.NewConstant(5*time.Second), + url, expectedResponse) +} else { + // Recording/playback mode - use minimal delays + err = probeServiceHealth( + t, ctx, session.ProxyClient, + retry.NewConstant(1*time.Millisecond), + url, expectedResponse) +} +``` + +#### 5. Add Cleanup Logic + +```go +defer cleanupDeployments(ctx, t, cli, session, envName) +``` + +This helper (from `test/functional/aspire_test.go`) deletes subscription deployments only in live mode. + +--- + +## Re-recording an Existing Test + +### Local Development + +1. **Delete existing recording**: + ```bash + rm test/functional/testdata/recordings/Test_CLI_MyNewFeature.yaml + rm test/functional/testdata/recordings/Test_CLI_MyNewFeature.*.yaml # if command recordings exist + ``` + +2. **Set recording mode**: + ```bash + export AZURE_RECORD_MODE=record + ``` + +3. **Ensure you're authenticated**: + ```bash + azd auth login + ``` + +4. **Run the test**: + ```bash + cd cli/azd + go test -v -run ^Test_CLI_MyNewFeature$ ./test/functional -timeout 30m + ``` + +5. **Verify recordings were created**: + ```bash + ls -lh test/functional/testdata/recordings/Test_CLI_MyNewFeature* + ``` + +### What Happens During Recording + +1. **AZD Binary Built with Record Tag**: The test automatically builds `azd-record` with the `record` build tag +2. **Recording Proxy Starts**: An HTTPS proxy starts at a random port +3. **Command Proxies Start**: Proxy executables for `docker`, `dotnet` are placed in temporary directories +4. **Environment Variables Set**: + - `AZD_TEST_HTTPS_PROXY` → points to recording proxy + - `PATH` → prepended with command proxy paths + - `AZD_DEBUG_PROVISION_PROGRESS_DISABLE=true` +5. **Test Executes**: All HTTP calls and command invocations go through proxies +6. **Cassettes Saved**: On test success, interactions are saved to YAML files + +### Recording File Structure + +After recording, you'll see files like: + +``` +test/functional/testdata/recordings/ +├── Test_CLI_MyNewFeature.yaml # HTTP interactions +├── Test_CLI_MyNewFeature.docker.yaml # Docker command recordings (if used) +└── Test_CLI_MyNewFeature.dotnet.yaml # Dotnet command recordings (if used) +``` + +Each YAML file contains: +```yaml +--- +version: 2 +interactions: + - id: 0 + request: + method: PUT + url: https://management.azure.com/... + headers: + Authorization: SANITIZED + body: '...' + response: + status: 200 OK + headers: {...} + body: '...' + - id: 1 + # ... more interactions +--- +env_name: azdtest-w4c1619 +subscription_id: faa080af-c1d8-40ad-9cce-e1a450ca5b57 +time: "1744738873" +``` + +--- + +## Building AZD with Recording Support + +### Build Tags + +AZD has two build configurations relevant to testing: + +1. **Standard build** (no tags): + ```bash + go build -o azd + ``` + - No recording support + - Uses `deps.go` (standard HTTP client, real clock) + +2. **Recording build** (`-tags=record`): + ```bash + go build -tags=record -o azd-record + ``` + - Includes recording support + - Uses `deps_record.go` (accepts recording proxy settings, can use fixed clock) + - Required for recording new tests or re-recording + +### What the `record` Build Tag Enables + +When built with `-tags=record`, azd: + +1. **Accepts `AZD_TEST_HTTPS_PROXY`** environment variable: + ```go + // cmd/deps_record.go + if val, ok := os.LookupEnv("AZD_TEST_HTTPS_PROXY"); ok { + proxyUrl, err := url.Parse(val) + transport.Proxy = http.ProxyURL(proxyUrl) + } + ``` + +2. **Uses self-signed certificates**: + ```go + transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + ``` + +3. **Supports fixed clock** (via `AZD_TEST_FIXED_CLOCK_UNIX_TIME`): + ```go + func createClock() clock.Clock { + if fixed, ok := fixedClock(); ok { + return fixed + } + return clock.New() + } + ``` + +### Automatic Build Behavior + +The test framework automatically builds the correct binary: + +```go +// test/azdcli/cli.go +if opt.Session != nil { + buildRecordOnce.Do(func() { + build(t, sourceDir, "-tags=record", "-o=azd-record") + }) +} else { + buildOnce.Do(func() { + build(t, sourceDir) + }) +} +``` + +**Skip automatic build**: +```bash +export CLI_TEST_SKIP_BUILD=true +``` + +**Use custom binary path**: +```bash +export CLI_TEST_AZD_PATH=/path/to/my/azd-record +``` + +--- + +## Using Recordings in CI + +### CI Configuration + +In CI environments (detected via `CI` environment variable), the framework: + +1. **Uses `ModeRecordOnce` by default** (if `AZURE_RECORD_MODE` not set) +2. **Expects recordings to exist** +3. **Fails with helpful message if missing**: + ``` + failed to load recordings: file not found: + to record this test, re-run the test with AZURE_RECORD_MODE='record' + ``` + +### CI Build Process + +The `ci-build.ps1` script has a `-BuildRecordMode` flag: + +```powershell +# ci-build.ps1 +param( + [switch] $BuildRecordMode, + # ... +) + +if ($BuildRecordMode) { + $buildFlags += "-tags=record" + Write-Host "Building with record tag enabled" + & go build $buildFlags -o $outputPath +} else { + Write-Host "Building standard binary" + & go build -o azd +} +``` + +**Build both binaries in CI**: +```bash +# Standard build +./ci-build.ps1 + +# Recording build (for tests) +./ci-build.ps1 -BuildRecordMode +``` + +### CI Test Execution + +```powershell +# ci-test.ps1 runs tests in two phases: + +# 1. Unit tests (short mode) +gotestsum -- ./... -short -v -cover + +# 2. Integration/Functional tests +gotestsum -- ./... -v -timeout 120m +``` + +The functional tests automatically: +- Use recordings if available (fast, no Azure calls) +- Skip automatic build (binaries pre-built) +- Use `CLI_TEST_AZD_PATH` if set + +### Recommended CI Setup + +```yaml +# Pseudo CI configuration +steps: + - name: Build standard azd + run: ./ci-build.ps1 + + - name: Build azd-record for tests + run: ./ci-build.ps1 -BuildRecordMode + + - name: Run tests with recordings + env: + AZURE_RECORD_MODE: playback # Force playback mode + CLI_TEST_AZD_PATH: ./azd-record + run: ./ci-test.ps1 +``` + +--- + +## Recording for Extensions + +### Extension Testing with Recording + +Extensions present unique challenges for recording because: +1. Extensions make gRPC callbacks to the main azd process +2. Extension operations often trigger Azure API calls through azd +3. Extension installation/build happens outside the recording session + +### Example: `Test_CLI_Extension_Capabilities` + +```go +func Test_CLI_Extension_Capabilities(t *testing.T) { + // Skip in playback mode - extensions are too complex to record + session := recording.Start(t) + if session != nil && session.Playback { + t.Skip("Skipping test in playback mode. This test is live only.") + } + + // Generate env name before session for use in both phases + envName := randomOrStoredEnvName(session) + + // Phase 1: Extension setup (NOT recorded) + cliNoSession := azdcli.NewCLI(t) // No session + cliNoSession.WorkingDirectory = dir + + _, err := cliNoSession.RunCommand(ctx, "ext", "install", "microsoft.azd.extensions") + require.NoError(t, err) + + // Phase 2: Main test (CAN be recorded) + cli := azdcli.NewCLI(t, azdcli.WithSession(session)) + cli.WorkingDirectory = dir + + _, err = cli.RunCommandWithStdIn(ctx, stdinForInit(envName), "init") + require.NoError(t, err) +} +``` + +### Why Extensions Are Challenging + +1. **gRPC Callbacks**: Extensions communicate with azd via gRPC, which isn't captured by HTTP recording +2. **Nested Operations**: Extension calls trigger azd commands, creating complex recording chains +3. **Build Dependencies**: Extensions need to be built before tests, outside recording context + +### Strategies for Extension Testing + +#### Option 1: Live-Only Tests (Current Approach) +```go +if session != nil && session.Playback { + t.Skip("Skipping test in playback mode. This test is live only.") +} +``` + +**Pros**: Simple, reliable +**Cons**: Slow in CI, requires Azure credentials + +#### Option 2: Partial Recording +```go +// Install extension without recording +cliNoSession := azdcli.NewCLI(t) +_, err := cliNoSession.RunCommand(ctx, "ext", "install", "...") + +// Record the actual test operations +cli := azdcli.NewCLI(t, azdcli.WithSession(session)) +_, err = cli.RunCommand(ctx, "up") +``` + +**Pros**: Records Azure interactions +**Cons**: Still requires extension installation in CI + +#### Option 3: Mock Extension Services +For unit tests of extension framework: +```go +// Use mock gRPC services +mockExtension := &mockExtensionService{...} +// Test framework behavior without real extensions +``` + +**Pros**: Fast, deterministic +**Cons**: Doesn't test real extension integration + +### Extension CI Builds + +Extensions also use recording build tags: + +```powershell +# extensions/microsoft.azd.demo/ci-build.ps1 +if ($BuildRecordMode) { + $buildFlags += "-tags=record" +} +& go build $buildFlags -o azd-ext-microsoft-azd-demo +``` + +This allows extensions themselves to be tested with recordings if needed. + +--- + +## Best Practices and Tips + +### 1. Use `randomOrStoredEnvName()` Helper + +**DO**: +```go +envName := randomOrStoredEnvName(session) +``` + +**DON'T**: +```go +envName := randomEnvName() // Same name won't be used in playback! +``` + +### 2. Always Check Session Mode for Live Operations + +**DO**: +```go +if session != nil && session.Playback { + // Skip cleanup in playback + return +} +// Perform cleanup +client.DeleteResourceGroup(...) +``` + +**DON'T**: +```go +// Always try cleanup +client.DeleteResourceGroup(...) // Will fail in playback mode! +``` + +### 3. Use Session's ProxyClient for HTTP Operations + +**DO**: +```go +client := http.DefaultClient +if session != nil { + client = session.ProxyClient +} +azureClient, err := armresources.NewResourceGroupsClient(subId, cred, &arm.ClientOptions{ + ClientOptions: azcore.ClientOptions{ + Transport: client, + }, +}) +``` + +### 4. Minimize Live Test Dependencies + +**DO**: +```go +// Copy sample project to temp dir +err := copySample(dir, "webapp") + +// Run azd commands +_, err = cli.RunCommand(ctx, "init") +``` + +**DON'T**: +```go +// Clone from GitHub (not recorded!) +exec.Command("git", "clone", "https://github.com/...").Run() +``` + +### 5. Store Dynamic Values in Session Variables + +**DO**: +```go +if session != nil { + session.Variables[recording.SubscriptionIdKey] = actualSubId + session.Variables["custom_resource_id"] = resourceId +} +``` + +### 6. Use Appropriate Timeouts + +**DO**: +```go +if session == nil { + // Live - real delays + time.Sleep(5 * time.Second) +} else { + // Playback - minimal delays + time.Sleep(1 * time.Millisecond) +} +``` + +### 7. Add Debug Logging + +```go +t.Logf("Recording mode: playback=%v", session != nil && session.Playback) +t.Logf("Environment name: %s", envName) +t.Logf("Subscription ID: %s", subscriptionId) +``` + +### 8. Handle Skipped Tests Gracefully + +**DO**: +```go +func Test_CLI_ComplexFeature(t *testing.T) { + session := recording.Start(t) + + // Skip if specific conditions aren't met + if session != nil && session.Playback && someComplexCondition { + t.Skip("This test requires live mode for complex operations") + } + + // ... test continues +} +``` + +--- + +## Troubleshooting + +### Problem: Recording Not Found + +**Error**: +``` +failed to load recordings: file not found +to record this test, re-run the test with AZURE_RECORD_MODE='record' +``` + +**Solutions**: +1. Set `AZURE_RECORD_MODE=record` +2. Run test to create recording +3. Commit the recording file to git + +--- + +### Problem: Test Fails During Playback But Passes Live + +**Symptoms**: +- Test passes with `AZURE_RECORD_MODE=live` +- Test fails with `AZURE_RECORD_MODE=playback` + +**Common Causes**: + +1. **Using wrong HTTP client**: + ```go + // WRONG - bypasses proxy + client := http.DefaultClient + + // RIGHT - uses session client + client := http.DefaultClient + if session != nil { + client = session.ProxyClient + } + ``` + +2. **Hard-coded values instead of session variables**: + ```go + // WRONG - uses live subscription + subId := cfg.SubscriptionID + + // RIGHT - uses recorded subscription + subId := cfg.SubscriptionID + if session != nil && session.Playback { + subId = session.Variables[recording.SubscriptionIdKey] + } + ``` + +3. **Not storing subscription ID in session**: + ```go + // Add this after provision/deployment: + if session != nil { + session.Variables[recording.SubscriptionIdKey] = env.GetSubscriptionId() + } + ``` + +--- + +### Problem: Request Not Matching Recorded Interaction + +**Error**: +``` +could not find matching request +``` + +**Diagnosis**: +1. Enable debug logging: `export RECORDER_PROXY_DEBUG=1` +2. Run test and check logs for matching differences + +**Common Causes**: + +1. **Query parameter differences**: + - Check if query params are in different order + - Consider adding custom matcher + +2. **Dynamic request IDs or timestamps**: + - These should be sanitized + - Check sanitization code in `test/recording/sanitize.go` + +3. **Host differences**: + - Use `WithHostMapping` option if needed: + ```go + session := recording.Start(t, recording.WithHostMapping(server.URL, "localhost:8080")) + ``` + +--- + +### Problem: Sensitive Data in Recordings + +**Issue**: Authorization tokens, SAS signatures, or secrets appearing in recordings + +**Solutions**: + +1. **Check existing sanitization**: + Look at `test/recording/sanitize.go` for examples + +2. **Add custom sanitization**: + ```go + // In recording.go, add a new hook: + vcr.AddHook(func(i *cassette.Interaction) error { + if strings.Contains(i.Request.URL, "/myservice/") { + i.Request.Headers.Set("X-Custom-Token", "SANITIZED") + } + return nil + }, recorder.BeforeSaveHook) + ``` + +3. **Add passthrough for sensitive endpoints**: + ```go + vcr.AddPassthrough(func(req *http.Request) bool { + return strings.Contains(req.URL.Host, "sensitive-service.com") + }) + ``` + +--- + +### Problem: Command Not Being Recorded + +**Symptoms**: Docker or dotnet commands execute but aren't recorded + +**Diagnosis**: +1. Check if command proxy is in PATH: + ```go + t.Logf("PATH: %s", os.Getenv("PATH")) + ``` + +2. Check if command matches intercept pattern: + ```go + // Only these patterns are intercepted for docker: + Intercepts: []cmdrecord.Intercept{ + {ArgsMatch: "^login"}, + {ArgsMatch: "^push"}, + } + ``` + +**Solutions**: + +1. **Add new intercept pattern**: + ```go + // In recording.go + recorders = append(recorders, cmdrecord.NewWithOptions(cmdrecord.Options{ + CmdName: "docker", + CassetteName: name, + RecordMode: opt.mode, + Intercepts: []cmdrecord.Intercept{ + {ArgsMatch: "^login"}, + {ArgsMatch: "^push"}, + {ArgsMatch: "^build"}, // Add new pattern + }, + })) + ``` + +2. **Verify proxy is first in PATH**: + ```go + cli.Env = append(cli.Env, + "PATH="+strings.Join(session.CmdProxyPaths, string(os.PathListSeparator))+ + string(os.PathListSeparator)+os.Getenv("PATH")) + ``` + +--- + +### Problem: Recordings Are Too Large + +**Issue**: Recording files over 1MB, causing git/review issues + +**Solutions**: + +1. **Enable response trimming** (already enabled for deployments): + - Large deployment responses are automatically trimmed + - See `test/recording/trim_response.go` + +2. **Add polling fast-forward**: + - Polling operations are automatically discarded + - See `httpPollDiscarder` in `recording.go` + +3. **Split test into smaller tests**: + ```go + // Instead of one large Test_CLI_FullWorkflow + func Test_CLI_Provision(t *testing.T) { ... } + func Test_CLI_Deploy(t *testing.T) { ... } + func Test_CLI_Down(t *testing.T) { ... } + ``` + +--- + +### Problem: Test Timing Out + +**Symptoms**: Test runs forever in recording mode + +**Common Causes**: + +1. **Waiting for resources that never complete**: + - Check if provision is hanging + - Look for infinite retry loops + +2. **Not using session-aware delays**: + ```go + // Use conditional delays + backoff := retry.NewConstant(5*time.Second) + if session != nil { + backoff = retry.NewConstant(1*time.Millisecond) + } + ``` + +--- + +### Problem: Recording Proxy Fails to Start + +**Error**: +``` +failed to create proxy client: ... +``` + +**Solutions**: + +1. **Port already in use**: + - Proxy uses random port, should be rare + - Check for other tests running in parallel + +2. **Certificate issues**: + - Ensure `-tags=record` build accepts self-signed certs + - Check `cmd/deps_record.go` configuration + +--- + +## Advanced Topics + +### Custom Matchers + +Add custom request matching logic: + +```go +// In your test or in recording.go +vcr.SetMatcher(func(r *http.Request, i cassette.Request) bool { + // Custom matching logic + if strings.Contains(r.URL.Path, "/special-resource/") { + // Ignore resource ID in matching + return r.Method == i.Method && + matchesWithoutId(r.URL.Path, i.URL) + } + + return cassette.DefaultMatcher(r, i) +}) +``` + +### Host Mapping for Test Servers + +When using `httptest.NewServer`: + +```go +server := httptest.NewServer(handler) +defer server.Close() + +session := recording.Start(t, + recording.WithHostMapping( + strings.TrimPrefix(server.URL, "http://"), + "localhost:8080")) +``` + +### Fixed Clock for Time-Dependent Tests + +```go +// Set environment variable +os.Setenv("AZD_TEST_FIXED_CLOCK_UNIX_TIME", "1744738873") + +// Azd will use fixed time in recording mode +// Useful for deployment name generation +``` + +--- + +## Summary Checklist + +### Creating a New Test ✓ +- [ ] Start with `recording.Start(t)` +- [ ] Use `randomOrStoredEnvName(session)` +- [ ] Create CLI with `azdcli.WithSession(session)` +- [ ] Use `session.ProxyClient` for HTTP operations +- [ ] Store dynamic values in `session.Variables` +- [ ] Add cleanup with session checks +- [ ] Use appropriate timeouts for recording/playback + +### Recording a Test ✓ +- [ ] Set `AZURE_RECORD_MODE=record` +- [ ] Ensure Azure authentication is configured +- [ ] Run test: `go test -v -run ^TestName$ ./test/functional -timeout 30m` +- [ ] Verify recordings created in `testdata/recordings/` +- [ ] Commit recordings to git + +### Debugging Recording Issues ✓ +- [ ] Enable debug logging: `RECORDER_PROXY_DEBUG=1` +- [ ] Check session mode: `t.Logf("Playback: %v", session.Playback)` +- [ ] Verify HTTP client usage +- [ ] Check session variable storage +- [ ] Review sanitization for sensitive data + +### CI Integration ✓ +- [ ] Build with `-BuildRecordMode` for test binaries +- [ ] Commit recordings to repository +- [ ] Set `AZURE_RECORD_MODE=playback` in CI (optional) +- [ ] Ensure recordings are up to date + +--- + +## Resources + +- **Recording Package**: `cli/azd/test/recording/` +- **Command Recording**: `cli/azd/test/cmdrecord/` +- **Test Helpers**: `cli/azd/test/azdcli/` +- **Example Tests**: `cli/azd/test/functional/up_test.go` +- **go-vcr Documentation**: https://github.com/dnaeon/go-vcr + +--- + +**Last Updated**: December 2025 +**Maintainers**: Azure Developer CLI Team diff --git a/cli/azd/test/functional/login_test.go b/cli/azd/test/functional/login_test.go index 4cf687ca76b..9648c3710be 100644 --- a/cli/azd/test/functional/login_test.go +++ b/cli/azd/test/functional/login_test.go @@ -13,30 +13,6 @@ import ( "github.com/stretchr/testify/require" ) -// Verifies login status functionality (azd login). -// This is important to ensure we do not break editor integrations that consume azd CLI. -func Test_CLI_LoginStatus(t *testing.T) { - ctx, cancel := newTestContext(t) - defer cancel() - - cli := azdcli.NewCLI(t) - result, err := cli.RunCommand(ctx, "login", "--check-status", "--output", "json") - require.NoError(t, err) - - loginResult := contracts.LoginResult{} - err = json.Unmarshal([]byte(result.Stdout), &loginResult) - require.NoError(t, err, "failed to deserialize login result") - - switch loginResult.Status { - case contracts.LoginStatusUnauthenticated: - require.Fail(t, "User isn't currently logged in. Rerun this test with a logged in user to pass the test.") - case contracts.LoginStatusSuccess: - require.NotNil(t, loginResult.ExpiresOn) - default: - require.Fail(t, "Unexpected login status: %s", loginResult.Status) - } -} - // Verifies login status functionality (azd auth login). // This is important to ensure we do not break editor integrations that consume azd CLI. func Test_CLI_AuthLoginStatus(t *testing.T) {