diff --git a/cli/azd/cmd/down.go b/cli/azd/cmd/down.go index 8fa61a40866..c4d7301a941 100644 --- a/cli/azd/cmd/down.go +++ b/cli/azd/cmd/down.go @@ -114,21 +114,10 @@ func (a *downAction) Run(ctx context.Context) (*actions.ActionResult, error) { startTime := time.Now() destroyOptions := provisioning.NewDestroyOptions(a.flags.forceDelete, a.flags.purgeDelete) - destroyResult, err := infraManager.Destroy(ctx, destroyOptions) - if err != nil { + if _, err = infraManager.Destroy(ctx, destroyOptions); err != nil { return nil, fmt.Errorf("deleting infrastructure: %w", err) } - // Remove any outputs from the template from the environment since destroying the infrastructure - // invalidated them all. - for outputName := range destroyResult.Outputs { - a.env.DotenvDelete(outputName) - } - - if err := a.env.Save(); err != nil { - return nil, fmt.Errorf("saving environment: %w", err) - } - return &actions.ActionResult{ Message: &actions.ResultMessage{ Header: fmt.Sprintf("Your application was removed from Azure in %s.", ux.DurationAsText(time.Since(startTime))), diff --git a/cli/azd/pkg/environment/environment.go b/cli/azd/pkg/environment/environment.go index 6db41a51bf4..5898eb11028 100644 --- a/cli/azd/pkg/environment/environment.go +++ b/cli/azd/pkg/environment/environment.go @@ -51,6 +51,10 @@ type Environment struct { // dotenv is a map of keys to values, persisted to the `.env` file stored in this environment's [Root]. dotenv map[string]string + // deletedKeys keeps track of deleted keys from the `.env` to be reapplied before a merge operation + // happens in Save + deletedKeys map[string]struct{} + // Config is environment specific config Config config.Config @@ -123,17 +127,19 @@ func GetEnvironment(azdContext *azdcontext.AzdContext, name string) (*Environmen // to a given directory when saved. func EmptyWithRoot(root string) *Environment { return &Environment{ - Root: root, - dotenv: make(map[string]string), - Config: config.NewEmptyConfig(), + Root: root, + dotenv: make(map[string]string), + deletedKeys: make(map[string]struct{}), + Config: config.NewEmptyConfig(), } } // Ephemeral returns returns an empty ephemeral environment (i.e. not backed by a file) with a set func Ephemeral() *Environment { return &Environment{ - dotenv: make(map[string]string), - Config: config.NewEmptyConfig(), + dotenv: make(map[string]string), + deletedKeys: make(map[string]struct{}), + Config: config.NewEmptyConfig(), } } @@ -177,6 +183,7 @@ func (e *Environment) LookupEnv(key string) (string, bool) { // does not exist. [Save] should be called to ensure this change is persisted. func (e *Environment) DotenvDelete(key string) { delete(e.dotenv, key) + e.deletedKeys[key] = struct{}{} } // Dotenv returns a copy of the key value pairs from the .env file in the environment. @@ -188,6 +195,7 @@ func (e *Environment) Dotenv() map[string]string { // called to ensure this change is persisted. func (e *Environment) DotenvSet(key string, value string) { e.dotenv[key] = value + delete(e.deletedKeys, key) } // Reloads environment variables and configuration @@ -196,10 +204,12 @@ func (e *Environment) Reload() error { envPath := filepath.Join(e.Root, azdcontext.DotEnvFileName) if envMap, err := godotenv.Read(envPath); errors.Is(err, os.ErrNotExist) { e.dotenv = make(map[string]string) + e.deletedKeys = make(map[string]struct{}) } else if err != nil { return fmt.Errorf("loading .env: %w", err) } else { e.dotenv = envMap + e.deletedKeys = make(map[string]struct{}) } // Reload env config @@ -239,6 +249,7 @@ func (e *Environment) Save() error { // Cache current values & reload to get any new env vars currentValues := e.dotenv + deletedValues := e.deletedKeys if err := e.Reload(); err != nil { return fmt.Errorf("failed reloading env vars, %w", err) } @@ -248,6 +259,11 @@ func (e *Environment) Save() error { e.dotenv[key] = value } + // Replay deletion + for key := range deletedValues { + delete(e.dotenv, key) + } + err := os.MkdirAll(e.Root, osutil.PermissionDirectory) if err != nil { return fmt.Errorf("failed to create a directory: %w", err) @@ -269,7 +285,7 @@ func (e *Environment) GetEnvName() string { // SetEnvName is shorthand for DotenvSet(EnvNameEnvVarName, envname) func (e *Environment) SetEnvName(envname string) { - e.dotenv[EnvNameEnvVarName] = envname + e.DotenvSet(EnvNameEnvVarName, envname) } // GetSubscriptionId is shorthand for Getenv(SubscriptionIdEnvVarName) @@ -284,7 +300,7 @@ func (e *Environment) GetTenantId() string { // SetLocation is shorthand for DotenvSet(SubscriptionIdEnvVarName, location) func (e *Environment) SetSubscriptionId(id string) { - e.dotenv[SubscriptionIdEnvVarName] = id + e.DotenvSet(SubscriptionIdEnvVarName, id) } // GetLocation is shorthand for Getenv(LocationEnvVarName) @@ -294,7 +310,7 @@ func (e *Environment) GetLocation() string { // SetLocation is shorthand for DotenvSet(LocationEnvVarName, location) func (e *Environment) SetLocation(location string) { - e.dotenv[LocationEnvVarName] = location + e.DotenvSet(LocationEnvVarName, location) } func normalize(key string) string { @@ -308,7 +324,7 @@ func (e *Environment) GetServiceProperty(serviceName string, propertyName string // Sets the value of a service-namespaced property in the environment. func (e *Environment) SetServiceProperty(serviceName string, propertyName string, value string) { - e.dotenv[fmt.Sprintf("SERVICE_%s_%s", normalize(serviceName), propertyName)] = value + e.DotenvSet(fmt.Sprintf("SERVICE_%s_%s", normalize(serviceName), propertyName), value) } // Creates a slice of key value pairs, based on the entries in the `.env` file like `KEY=VALUE` that diff --git a/cli/azd/pkg/environment/environment_test.go b/cli/azd/pkg/environment/environment_test.go index c6c001d36a4..90c00a88e10 100644 --- a/cli/azd/pkg/environment/environment_test.go +++ b/cli/azd/pkg/environment/environment_test.go @@ -148,6 +148,28 @@ func Test_SaveAndReload(t *testing.T) { require.Equal(t, env.dotenv["SERVICE_API_ENDPOINT_URL"], "http://api.example.com") require.Equal(t, "SUBSCRIPTION_ID", env.GetSubscriptionId()) require.Equal(t, "eastus2", env.GetLocation()) + + // Delete the newly added property + env.DotenvDelete("SERVICE_WEB_ENDPOINT_URL") + err = env.Save() + require.NoError(t, err) + + // Verify the property is deleted + _, ok := env.LookupEnv("SERVICE_WEB_ENDPOINT_URL") + require.False(t, ok) + + // Delete an existing key, then add it with a different value and save the environment, to ensure we + // don't drop the existing key even though it was deleted in an earlier operation. + env.DotenvDelete("SERVICE_API_ENDPOINT_URL") + env.DotenvSet("SERVICE_API_ENDPOINT_URL", "http://api.example.com/updated") + + err = env.Save() + require.NoError(t, err) + + // Verify the property still exists, and has the updated value. + value, ok := env.LookupEnv("SERVICE_API_ENDPOINT_URL") + require.True(t, ok) + require.Equal(t, "http://api.example.com/updated", value) } func TestCleanName(t *testing.T) { diff --git a/cli/azd/test/functional/up_test.go b/cli/azd/test/functional/up_test.go index 92275303760..01b04a19d2f 100644 --- a/cli/azd/test/functional/up_test.go +++ b/cli/azd/test/functional/up_test.go @@ -260,6 +260,14 @@ func Test_CLI_Up_Down_ContainerApp(t *testing.T) { _, err = cli.RunCommand(ctx, "infra", "delete", "--force", "--purge") require.NoError(t, err) + + // As part of deleting the infrastructure, outputs of the infrastructure such as "WEBSITE_URL" should + // have been removed from the environment. + env, err = godotenv.Read(filepath.Join(dir, azdcontext.EnvironmentDirectoryName, envName, ".env")) + require.NoError(t, err) + + _, has = env["WEBSITE_URL"] + require.False(t, has, "WEBSITE_URL should have been removed from the environment as part of infrastructure removal") } func Test_CLI_Up_ResourceGroupScope(t *testing.T) {