cli/azd/pkg/environment/environment.go (167 lines of code) (raw):

// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. package environment import ( "context" "encoding/json" "fmt" "log" "os" "regexp" "strings" "maps" "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/joho/godotenv" ) // EnvNameEnvVarName is the name of the key used to store the envname property in the environment. const EnvNameEnvVarName = "AZURE_ENV_NAME" // LocationEnvVarName is the name of the key used to store the location property in the environment. const LocationEnvVarName = "AZURE_LOCATION" // SubscriptionIdEnvVarName is the name of they key used to store the subscription id property in the environment. const SubscriptionIdEnvVarName = "AZURE_SUBSCRIPTION_ID" // PrincipalIdEnvVarName is the name of they key used to store the id of a principal in the environment. const PrincipalIdEnvVarName = "AZURE_PRINCIPAL_ID" // TenantIdEnvVarName is the tenant that owns the subscription const TenantIdEnvVarName = "AZURE_TENANT_ID" // ContainerRegistryEndpointEnvVarName is the name of they key used to store the endpoint of the container registry to push // to. const ContainerRegistryEndpointEnvVarName = "AZURE_CONTAINER_REGISTRY_ENDPOINT" // ContainerEnvironmentEndpointEnvVarName is the name of the environment variable // that specifies the default domain for Azure Container Apps environments. const ContainerEnvironmentEndpointEnvVarName = "AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN" // AksClusterEnvVarName is the name of they key used to store the endpoint of the AKS cluster to push to. const AksClusterEnvVarName = "AZURE_AKS_CLUSTER_NAME" // ResourceGroupEnvVarName is the name of the azure resource group that should be used for deployments const ResourceGroupEnvVarName = "AZURE_RESOURCE_GROUP" // PlatformTypeEnvVarName is the name of the key used to store the current azd platform type const PlatformTypeEnvVarName = "AZD_PLATFORM_TYPE" // The zero value of an Environment is not valid. Use [New] to create one. When writing tests, // [Ephemeral] and [EphemeralWithValues] are useful to create environments which are not persisted to disk. type Environment struct { name string // 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 } const AzdInitialEnvironmentConfigName = "AZD_INITIAL_ENVIRONMENT_CONFIG" // New returns a new environment with the specified name. func New(name string) *Environment { env := &Environment{ name: name, dotenv: make(map[string]string), deletedKeys: make(map[string]struct{}), Config: getInitialConfig(), } env.DotenvSet(EnvNameEnvVarName, name) return env } func getInitialConfig() config.Config { initialConfig := os.Getenv(AzdInitialEnvironmentConfigName) if initialConfig == "" { return config.NewEmptyConfig() } var initConfig map[string]any if err := json.Unmarshal([]byte(initialConfig), &initConfig); err != nil { log.Println("Failed to unmarshal initial config", err, "Using empty config.") return config.NewEmptyConfig() } return config.NewConfig(initConfig) } // NewWithValues returns an ephemeral environment (i.e. not backed by a data store) with a set // of values. Useful for testing. The name parameter is added to the environment with the // AZURE_ENV_NAME key, replacing an existing value in the provided values map. A nil values is // treated the same way as an empty map. func NewWithValues(name string, values map[string]string) *Environment { env := New(name) if values != nil { env.dotenv = values } return env } type EnvironmentResolver func(ctx context.Context) (*Environment, error) // Same restrictions as a deployment name (ref: // https://docs.microsoft.com/azure/azure-resource-manager/management/resource-name-rules#microsoftresources) var EnvironmentNameRegexp = regexp.MustCompile(`^[a-zA-Z0-9-\(\)_\.]{1,64}$`) // The maximum length of an environment name. var EnvironmentNameMaxLength = 64 func IsValidEnvironmentName(name string) bool { return EnvironmentNameRegexp.MatchString(name) } // CleanName returns a version of [name] where all characters not allowed in an environment name have been replaced // with hyphens func CleanName(name string) string { result := strings.Builder{} for _, c := range name { if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '(' || c == ')' || c == '_' || c == '.' { result.WriteRune(c) } else { result.WriteRune('-') } } return result.String() } // Getenv behaves like os.Getenv, except that any keys in the `.env` file associated with this environment are considered // first. func (e *Environment) Getenv(key string) string { if v, has := e.dotenv[key]; has { return v } return os.Getenv(key) } // LookupEnv behaves like os.LookupEnv, except that any keys in the `.env` file associated with this environment are // considered first. func (e *Environment) LookupEnv(key string) (string, bool) { if v, has := e.dotenv[key]; has { return v, true } return os.LookupEnv(key) } // DotenvDelete removes the given key from the .env file in the environment, it is a no-op if the key // 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. func (e *Environment) Dotenv() map[string]string { return maps.Clone(e.dotenv) } // DotenvSet sets the value of [key] to [value] in the .env file associated with the environment. [Save] should be // called to ensure this change is persisted. func (e *Environment) DotenvSet(key string, value string) { e.dotenv[key] = value delete(e.deletedKeys, key) } // Name gets the name of the environment // If empty will fallback to the value of the AZURE_ENV_NAME environment variable func (e *Environment) Name() string { if e.name == "" { e.name = e.Getenv(EnvNameEnvVarName) } return e.name } // GetSubscriptionId is shorthand for Getenv(SubscriptionIdEnvVarName) func (e *Environment) GetSubscriptionId() string { return e.Getenv(SubscriptionIdEnvVarName) } // GetTenantId is shorthand for Getenv(TenantIdEnvVarName) func (e *Environment) GetTenantId() string { return e.Getenv(TenantIdEnvVarName) } // SetLocation is shorthand for DotenvSet(SubscriptionIdEnvVarName, location) func (e *Environment) SetSubscriptionId(id string) { e.DotenvSet(SubscriptionIdEnvVarName, id) } // GetLocation is shorthand for Getenv(LocationEnvVarName) func (e *Environment) GetLocation() string { return e.Getenv(LocationEnvVarName) } // SetLocation is shorthand for DotenvSet(LocationEnvVarName, location) func (e *Environment) SetLocation(location string) { e.DotenvSet(LocationEnvVarName, location) } // Key returns the environment key name for the given name. func Key(name string) string { return strings.ReplaceAll(strings.ToUpper(name), "-", "_") } // GetServiceProperty is shorthand for Getenv(SERVICE_$SERVICE_NAME_$PROPERTY_NAME) func (e *Environment) GetServiceProperty(serviceName string, propertyName string) string { return e.Getenv(fmt.Sprintf("SERVICE_%s_%s", Key(serviceName), propertyName)) } // Sets the value of a service-namespaced property in the environment. func (e *Environment) SetServiceProperty(serviceName string, propertyName string, value string) { e.DotenvSet(fmt.Sprintf("SERVICE_%s_%s", Key(serviceName), propertyName), value) } // Creates a slice of key value pairs, based on the entries in the `.env` file like `KEY=VALUE` that // can be used to pass into command runner or similar constructs. func (e *Environment) Environ() []string { envVars := []string{} for k, v := range e.dotenv { envVars = append(envVars, fmt.Sprintf("%s=%s", k, v)) } return envVars } // fixupUnquotedDotenv is a workaround for behavior in how godotenv.Marshal handles numeric like values. Marshaling // a map[string]string to a dotenv file, if a value can be successfully parsed with strconv.Atoi, it will be written in // the dotenv file without quotes and the value written will be the value returned by strconv.Atoi. This can lead to dropping // leading zeros from the value that we persist. // // For example, given a map with the key value pair ("FOO", "01"), the value returned by godotenv.Marshal will have a line // that looks like FOO=1 instead of FOO=01 or FOO="01". // // This function takes the value returned by godotenv.Marshal and for any unquoted value replaces it with the value from // the values map if they differ. This means that a key value pair ("FOO", "1") remains as FOO=1. // // When replacing a key in this manner, we ensure the value is wrapped in quotes, on the assumption that the leading zero // is of significance to the value and wrapping it quotes means it is more likely to be treated as a string instead of a // number by any downstream systems. We do not need to worry about escaping quotes in the value, because we know that // godotenv.Marshal only did this translation for numeric values and so we know the original value did not contain quotes. func fixupUnquotedDotenv(values map[string]string, dotenv string) string { entries := strings.Split(dotenv, "\n") for idx, line := range entries { parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { continue } envKey := parts[0] envValue := parts[1] if len(envValue) > 0 && envValue[0] != '"' { if values[envKey] != envValue { entries[idx] = fmt.Sprintf("%s=\"%s\"", envKey, values[envKey]) } } } return strings.Join(entries, "\n") } // Prepare dotenv for saving and returns a marshalled string that can be save to the underlying data store // Instead of calling `godotenv.Write` directly, we need to save the file ourselves, so we can fixup any numeric values // that were incorrectly unquoted. func marshallDotEnv(env *Environment) (string, error) { marshalled, err := godotenv.Marshal(env.dotenv) if err != nil { return "", fmt.Errorf("marshalling .env: %w", err) } return fixupUnquotedDotenv(env.dotenv, marshalled), nil }