diff --git a/cli/azd/internal/mapper/mapper.go b/cli/azd/internal/mapper/mapper.go index 053f9205780..242a4c6be94 100644 --- a/cli/azd/internal/mapper/mapper.go +++ b/cli/azd/internal/mapper/mapper.go @@ -99,8 +99,10 @@ type Resolver func(key string) string type MapperFunc func(ctx context.Context, src any, dst any) error type resolverKeyType struct{} +type envSubstKeyType struct{} var resolverKey = resolverKeyType{} +var envSubstKey = envSubstKeyType{} var ( registry = make(map[[2]reflect.Type]MapperFunc) @@ -127,6 +129,36 @@ type Mapper struct { // Default mapper instance for convenience functions var defaultMapper = &Mapper{ctx: context.Background()} +// WithContext sets the context that will be passed to converters (ie, registered with +// [MustRegister]). This allows you to set and pass converter-specific settings per mapping call. +func WithContext(ctx context.Context) *Mapper { + return &Mapper{ctx: ctx} +} + +// WithResolver returns a copy configured with an environment variable resolver. +func (m *Mapper) WithResolver(resolver Resolver) *Mapper { + if resolver == nil { + return &Mapper{ctx: m.ctx} + } + + ctx := context.WithValue(m.ctx, resolverKey, func(key string) string { + return resolver(key) + }) + return &Mapper{ctx: ctx} +} + +// WithEnvSubst returns a copy configured to enable or disable environment substitution. +// Environment substitution is enabled by default. +func (m *Mapper) WithEnvSubst(enabled bool) *Mapper { + return &Mapper{ctx: context.WithValue(m.ctx, envSubstKey, enabled)} +} + +// EnvSubstEnabled reports whether environment substitution is enabled for a conversion. +func EnvSubstEnabled(ctx context.Context) bool { + enabled, configured := ctx.Value(envSubstKey).(bool) + return !configured || enabled +} + // Register a type converter function that transforms type S to type T. // // The mapper framework uses reflection to automatically route conversion requests @@ -280,14 +312,7 @@ func Convert(src any, dst any) error { // return &azdext.Service{Image: expandedImage}, nil // } func WithResolver(resolver Resolver) *Mapper { - if resolver == nil { - return &Mapper{ctx: context.Background()} - } - - ctx := context.WithValue(context.Background(), resolverKey, func(key string) string { - return resolver(key) - }) - return &Mapper{ctx: ctx} + return WithContext(context.Background()).WithResolver(resolver) } // Convert performs type conversion using this mapper's context. diff --git a/cli/azd/internal/mapper/mapper_test.go b/cli/azd/internal/mapper/mapper_test.go index b9d9421fa8b..6e85e7aec86 100644 --- a/cli/azd/internal/mapper/mapper_test.go +++ b/cli/azd/internal/mapper/mapper_test.go @@ -194,6 +194,36 @@ func TestMappingWithoutResolver(t *testing.T) { assert.True(t, result.Ready) } +func TestMappingWithContext(t *testing.T) { + clearRegistry() + + type conversionModeKey struct{} + Register(func(ctx context.Context, src string) (string, error) { + mode, _ := ctx.Value(conversionModeKey{}).(string) + return mode + ":" + src, nil + }) + + ctx := context.WithValue(t.Context(), conversionModeKey{}, "template") + var result string + err := WithContext(ctx).Convert("value", &result) + + require.NoError(t, err) + assert.Equal(t, "template:value", result) +} + +func TestMapperWithEnvSubst(t *testing.T) { + base := WithContext(t.Context()) + require.True(t, EnvSubstEnabled(base.ctx)) + + disabled := base.WithResolver(func(key string) string { + return "resolved-" + key + }).WithEnvSubst(false) + require.False(t, EnvSubstEnabled(disabled.ctx)) + require.Equal(t, "resolved-value", GetResolver(disabled.ctx)("value")) + require.True(t, EnvSubstEnabled(base.ctx)) + require.Nil(t, GetResolver(base.ctx)) +} + func TestMappingWithEnvironmentResolver(t *testing.T) { clearRegistry() diff --git a/cli/azd/pkg/osutil/expandable_map.go b/cli/azd/pkg/osutil/expandable_map.go index df22ad12f7f..fefb6e1a3c6 100644 --- a/cli/azd/pkg/osutil/expandable_map.go +++ b/cli/azd/pkg/osutil/expandable_map.go @@ -9,6 +9,15 @@ import "fmt" // It provides convenient methods for expanding all values in the map. type ExpandableMap map[string]ExpandableString +// Raw returns all values without environment substitution. +func (em ExpandableMap) Raw() map[string]string { + result := make(map[string]string, len(em)) + for key, value := range em { + result[key] = value.Raw() + } + return result +} + // Expand evaluates all ExpandableString values in the map, substituting variables as [ExpandableString.Envsubst] would. // Returns a map[string]string with all values expanded, or an error if any expansion fails. // The mapping parameter is a function that returns the value for a given variable name. diff --git a/cli/azd/pkg/osutil/expandable_map_test.go b/cli/azd/pkg/osutil/expandable_map_test.go index 0d0485d27d4..b9a1045fbcc 100644 --- a/cli/azd/pkg/osutil/expandable_map_test.go +++ b/cli/azd/pkg/osutil/expandable_map_test.go @@ -12,6 +12,29 @@ import ( "github.com/stretchr/testify/require" ) +func TestExpandableMap_Raw(t *testing.T) { + values := ExpandableMap{ + "TEMPLATE": NewExpandableString("${VALUE}"), + "STATIC": NewExpandableString("static-value"), + } + + assert.Equal(t, map[string]string{ + "TEMPLATE": "${VALUE}", + "STATIC": "static-value", + }, values.Raw()) + + // expand it, and you can still get the raw values back + _, err := values.Expand(func(s string) string { + return s + " EXPANDED" + }) + require.NoError(t, err) + + assert.Equal(t, map[string]string{ + "TEMPLATE": "${VALUE}", + "STATIC": "static-value", + }, values.Raw()) +} + func TestExpandableMap_Expand(t *testing.T) { t.Run("EmptyMap", func(t *testing.T) { em := ExpandableMap{} diff --git a/cli/azd/pkg/osutil/expandable_string.go b/cli/azd/pkg/osutil/expandable_string.go index 4fdcfbbf1ee..b96fdc63987 100644 --- a/cli/azd/pkg/osutil/expandable_string.go +++ b/cli/azd/pkg/osutil/expandable_string.go @@ -44,6 +44,11 @@ func (e ExpandableString) IsZero() bool { return e.Empty() } +// Raw returns the unexpanded template. +func (e ExpandableString) Raw() string { + return e.template +} + // Envsubst evaluates the template, substituting values as [envsubst.Eval] would. func (e ExpandableString) Envsubst(mapping func(string) string) (string, error) { return envsubst.Eval(e.template, mapping) diff --git a/cli/azd/pkg/project/mapper_registry.go b/cli/azd/pkg/project/mapper_registry.go index cfbe4e410be..9b33fdd4ac6 100644 --- a/cli/azd/pkg/project/mapper_registry.go +++ b/cli/azd/pkg/project/mapper_registry.go @@ -103,32 +103,36 @@ func registerProjectMappings() { resolver := mapper.GetResolver(ctx) envResolver := getEnvResolver(resolver) - resourceGroupName, err := src.ResourceGroupName.Envsubst(envResolver) + resourceGroupName, err := envsubstIfEnabled(ctx, src.ResourceGroupName, envResolver) if err != nil { return nil, fmt.Errorf("envsubst service resource group name: %w", err) } - resourceName, err := src.ResourceName.Envsubst(envResolver) + resourceName, err := envsubstIfEnabled(ctx, src.ResourceName, envResolver) if err != nil { return nil, fmt.Errorf("envsubst service resource name: %w", err) } - image, err := src.Image.Envsubst(envResolver) + image, err := envsubstIfEnabled(ctx, src.Image, envResolver) if err != nil { return nil, fmt.Errorf("envsubst image: %w", err) } var serviceEnv map[string]string if len(src.Environment) > 0 { - serviceEnv, err = src.Environment.Expand(envResolver) - if err != nil { - return nil, fmt.Errorf("envsubst service environment: %w", err) + if mapper.EnvSubstEnabled(ctx) { + serviceEnv, err = src.Environment.Expand(envResolver) + if err != nil { + return nil, fmt.Errorf("envsubst service environment: %w", err) + } + } else { + serviceEnv = src.Environment.Raw() } } // Convert Docker options var docker *azdext.DockerProjectOptions - err = mapper.WithResolver(resolver).Convert(src.Docker, &docker) + err = mapper.WithContext(ctx).Convert(src.Docker, &docker) if err != nil { return nil, fmt.Errorf("convert docker options: %w", err) } @@ -176,24 +180,24 @@ func registerProjectMappings() { resolver := mapper.GetResolver(ctx) envResolver := getEnvResolver(resolver) - registry, err := src.Registry.Envsubst(envResolver) + registry, err := envsubstIfEnabled(ctx, src.Registry, envResolver) if err != nil { return nil, fmt.Errorf("envsubst docker registry: %w", err) } - image, err := src.Image.Envsubst(envResolver) + image, err := envsubstIfEnabled(ctx, src.Image, envResolver) if err != nil { return nil, fmt.Errorf("envsubst docker image: %w", err) } - tag, err := src.Tag.Envsubst(envResolver) + tag, err := envsubstIfEnabled(ctx, src.Tag, envResolver) if err != nil { return nil, fmt.Errorf("envsubst docker tag: %w", err) } buildArgs := []string{} for _, arg := range src.BuildArgs { - resolvedArg, err := arg.Envsubst(envResolver) + resolvedArg, err := envsubstIfEnabled(ctx, arg, envResolver) if err != nil { return nil, fmt.Errorf("envsubst docker build arg '%s': %w", arg, err) } @@ -393,7 +397,7 @@ func registerProjectMappings() { // Convert Docker options if present if src.Docker != nil { var dockerOptions DockerProjectOptions - err := mapper.Convert(src.Docker, &dockerOptions) + err := mapper.WithContext(ctx).Convert(src.Docker, &dockerOptions) if err != nil { return nil, fmt.Errorf("convert docker options: %w", err) } @@ -410,11 +414,12 @@ func registerProjectMappings() { if len(src.Environment) > 0 { result.Environment = make(osutil.ExpandableMap, len(src.Environment)) + newEnvironmentValue := osutil.NewLiteralExpandableString + if !mapper.EnvSubstEnabled(ctx) { + newEnvironmentValue = osutil.NewExpandableString + } for key, value := range src.Environment { - // Incoming values are expanded literals, not templates: escape them so a - // later expansion (or a round trip back into azure.yaml) cannot reinterpret - // or corrupt values containing `$`. - result.Environment[key] = osutil.NewLiteralExpandableString(value) + result.Environment[key] = newEnvironmentValue(value) } } @@ -714,7 +719,7 @@ func registerProjectMappings() { resolver := mapper.GetResolver(ctx) envResolver := getEnvResolver(resolver) - resourceGroupName, err := src.ResourceGroupName.Envsubst(envResolver) + resourceGroupName, err := envsubstIfEnabled(ctx, src.ResourceGroupName, envResolver) if err != nil { return nil, fmt.Errorf("failed resolving ResourceGroupName, %w", err) } @@ -722,7 +727,7 @@ func registerProjectMappings() { services := make(map[string]*azdext.ServiceConfig, len(src.Services)) for i, svc := range src.Services { var serviceConfig *azdext.ServiceConfig - if err := mapper.WithResolver(resolver).Convert(svc, &serviceConfig); err != nil { + if err := mapper.WithContext(ctx).Convert(svc, &serviceConfig); err != nil { return nil, fmt.Errorf("converting service %q: %w", i, err) } @@ -770,7 +775,7 @@ func registerProjectMappings() { services := make(map[string]*ServiceConfig, len(src.Services)) for name, protoSvc := range src.Services { var serviceConfig *ServiceConfig - if err := mapper.Convert(protoSvc, &serviceConfig); err != nil { + if err := mapper.WithContext(ctx).Convert(protoSvc, &serviceConfig); err != nil { return nil, fmt.Errorf("converting service %s: %w", name, err) } services[name] = serviceConfig @@ -817,6 +822,17 @@ func getEnvResolver(resolver mapper.Resolver) func(string) string { return func(string) string { return "" } } +func envsubstIfEnabled( + ctx context.Context, + value osutil.ExpandableString, + resolver func(string) string, +) (string, error) { + if !mapper.EnvSubstEnabled(ctx) { + return value.Raw(), nil + } + return value.Envsubst(resolver) +} + // getResourceTypeKinds returns the kinds for a given resource type. // This corresponds to the addListResourcesKind function in grpcserver/compose_service.go. func getResourceTypeKinds(resourceType ResourceType) []string { diff --git a/cli/azd/pkg/project/mapper_registry_test.go b/cli/azd/pkg/project/mapper_registry_test.go index 26c741d502d..9ee85252e57 100644 --- a/cli/azd/pkg/project/mapper_registry_test.go +++ b/cli/azd/pkg/project/mapper_registry_test.go @@ -110,6 +110,36 @@ func TestServiceConfigMappingWithResolver(t *testing.T) { }, protoConfig.Environment) } +func TestServiceConfigMappingWithoutEnvSubst(t *testing.T) { + serviceConfig := &ServiceConfig{ + ResourceGroupName: osutil.NewExpandableString("rg-${ENV}"), + ResourceName: osutil.NewExpandableString("app-${ENV}"), + Image: osutil.NewExpandableString("${REGISTRY}/app:${TAG}"), + Environment: osutil.ExpandableMap{ + "ENDPOINT": osutil.NewExpandableString("${API_ENDPOINT}"), + }, + Docker: DockerProjectOptions{ + Registry: osutil.NewExpandableString("${REGISTRY}"), + Image: osutil.NewExpandableString("app-${ENV}"), + Tag: osutil.NewExpandableString("${TAG}"), + BuildArgs: []osutil.ExpandableString{osutil.NewExpandableString("ENV=${ENV}")}, + }, + } + + var protoConfig *azdext.ServiceConfig + err := mapper.WithContext(t.Context()).WithEnvSubst(false).Convert(serviceConfig, &protoConfig) + require.NoError(t, err) + + require.Equal(t, "rg-${ENV}", protoConfig.ResourceGroupName) + require.Equal(t, "app-${ENV}", protoConfig.ResourceName) + require.Equal(t, "${REGISTRY}/app:${TAG}", protoConfig.Image) + require.Equal(t, map[string]string{"ENDPOINT": "${API_ENDPOINT}"}, protoConfig.Environment) + require.Equal(t, "${REGISTRY}", protoConfig.Docker.Registry) + require.Equal(t, "app-${ENV}", protoConfig.Docker.Image) + require.Equal(t, "${TAG}", protoConfig.Docker.Tag) + require.Equal(t, []string{"ENV=${ENV}"}, protoConfig.Docker.BuildArgs) +} + func TestServiceConfigMappingWithConfig(t *testing.T) { // Test ServiceConfig with various Config field scenarios tests := []struct { @@ -353,6 +383,56 @@ func TestServiceConfigReverseMapping(t *testing.T) { } } +func TestServiceConfigReverseMappingWithEnvironmentTemplates(t *testing.T) { + protoConfig := &azdext.ServiceConfig{ + ResourceGroupName: "rg-${ENV}", + ResourceName: "app-${ENV}", + Image: "${REGISTRY}/app:${TAG}", + Environment: map[string]string{ + "FROM_ENV": "${ENV_VALUE}", + "LITERAL_DOLLAR": "cost: $$5", + }, + Docker: &azdext.DockerProjectOptions{ + Registry: "${REGISTRY}", + Image: "app-${ENV}", + Tag: "${TAG}", + BuildArgs: []string{"ENV=${ENV}"}, + }, + } + + var serviceConfig *ServiceConfig + err := mapper.WithContext(t.Context()).WithEnvSubst(false).Convert(protoConfig, &serviceConfig) + require.NoError(t, err) + + resolver := func(key string) string { + switch key { + case "ENV": + return "dev" + case "REGISTRY": + return "registry.example" + case "TAG": + return "latest" + case "ENV_VALUE": + return "resolved" + } + return "" + } + require.Equal(t, "rg-dev", serviceConfig.ResourceGroupName.MustEnvsubst(resolver)) + require.Equal(t, "app-dev", serviceConfig.ResourceName.MustEnvsubst(resolver)) + require.Equal(t, "registry.example/app:latest", serviceConfig.Image.MustEnvsubst(resolver)) + require.Equal(t, "registry.example", serviceConfig.Docker.Registry.MustEnvsubst(resolver)) + require.Equal(t, "app-dev", serviceConfig.Docker.Image.MustEnvsubst(resolver)) + require.Equal(t, "latest", serviceConfig.Docker.Tag.MustEnvsubst(resolver)) + require.Equal(t, "ENV=dev", serviceConfig.Docker.BuildArgs[0].MustEnvsubst(resolver)) + + expanded, err := serviceConfig.Environment.Expand(resolver) + require.NoError(t, err) + require.Equal(t, map[string]string{ + "FROM_ENV": "resolved", + "LITERAL_DOLLAR": "cost: $5", + }, expanded) +} + func TestServiceConfigRoundTripMapping(t *testing.T) { // Test that ServiceConfig -> proto -> ServiceConfig preserves Config data originalConfig := map[string]any{ @@ -1089,6 +1169,28 @@ func TestProjectConfigMapping(t *testing.T) { }, protoConfig.Services["web"].Environment) }) + t.Run("without envsubst", func(t *testing.T) { + projectConfig := &ProjectConfig{ + ResourceGroupName: osutil.NewExpandableString("rg-${ENV}"), + Services: map[string]*ServiceConfig{ + "api": { + Image: osutil.NewExpandableString("${REGISTRY}/api"), + }, + }, + } + + var protoConfig *azdext.ProjectConfig + err := mapper.WithContext(t.Context()).WithEnvSubst(false).Convert(projectConfig, &protoConfig) + require.NoError(t, err) + require.Equal(t, "rg-${ENV}", protoConfig.ResourceGroupName) + require.Equal(t, "${REGISTRY}/api", protoConfig.Services["api"].Image) + + var roundTrip *ProjectConfig + err = mapper.WithContext(t.Context()).WithEnvSubst(false).Convert(protoConfig, &roundTrip) + require.NoError(t, err) + require.Equal(t, "rg-dev", roundTrip.ResourceGroupName.MustEnvsubst(func(string) string { return "dev" })) + }) + t.Run("proto ProjectConfig -> ProjectConfig", func(t *testing.T) { additionalPropsData := map[string]any{ "reverseExtension": "reverseValue",