diff --git a/cli/azd/pkg/project/dotnet_importer.go b/cli/azd/pkg/project/dotnet_importer.go index 524072debd9..1c3cdba9269 100644 --- a/cli/azd/pkg/project/dotnet_importer.go +++ b/cli/azd/pkg/project/dotnet_importer.go @@ -177,6 +177,23 @@ func mapToStringSlice(m map[string]string, separator string) []string { return result } +// mapToExpandableStringSlice converts a map of strings to a slice of expandable strings. +// Each key-value pair in the map is converted to a string in the format "key:value", +// where the separator is specified by the `separator` parameter. +// If the value is an empty string, only the key is included in the resulting slice. +// The resulting slice is returned without any string interpolation performed. +func mapToExpandableStringSlice(m map[string]string, separator string) []osutil.ExpandableString { + var result []osutil.ExpandableString + for key, value := range m { + if value == "" { + result = append(result, osutil.NewExpandableString(key)) + } else { + result = append(result, osutil.NewExpandableString(key+separator+value)) + } + } + return result +} + func (ai *DotNetImporter) Services( ctx context.Context, p *ProjectConfig, svcConfig *ServiceConfig, ) (map[string]*ServiceConfig, error) { @@ -234,7 +251,7 @@ func (ai *DotNetImporter) Services( Docker: DockerProjectOptions{ Path: dockerfile.Path, Context: dockerfile.Context, - BuildArgs: mapToStringSlice(dockerfile.BuildArgs, "="), + BuildArgs: mapToExpandableStringSlice(dockerfile.BuildArgs, "="), }, } @@ -314,7 +331,7 @@ func (ai *DotNetImporter) Services( dOptions = DockerProjectOptions{ Path: bContainer.Build.Dockerfile, Context: bContainer.Build.Context, - BuildArgs: mapToStringSlice(bArgs, "="), + BuildArgs: mapToExpandableStringSlice(bArgs, "="), BuildSecrets: bArgsArray, BuildEnv: reqEnv, } diff --git a/cli/azd/pkg/project/framework_service_docker.go b/cli/azd/pkg/project/framework_service_docker.go index bd1d6a3ad1d..944f442107a 100644 --- a/cli/azd/pkg/project/framework_service_docker.go +++ b/cli/azd/pkg/project/framework_service_docker.go @@ -34,15 +34,15 @@ import ( ) type DockerProjectOptions struct { - Path string `yaml:"path,omitempty" json:"path,omitempty"` - Context string `yaml:"context,omitempty" json:"context,omitempty"` - Platform string `yaml:"platform,omitempty" json:"platform,omitempty"` - Target string `yaml:"target,omitempty" json:"target,omitempty"` - Registry osutil.ExpandableString `yaml:"registry,omitempty" json:"registry,omitempty"` - Image osutil.ExpandableString `yaml:"image,omitempty" json:"image,omitempty"` - Tag osutil.ExpandableString `yaml:"tag,omitempty" json:"tag,omitempty"` - RemoteBuild bool `yaml:"remoteBuild,omitempty" json:"remoteBuild,omitempty"` - BuildArgs []string `yaml:"buildArgs,omitempty" json:"buildArgs,omitempty"` + Path string `yaml:"path,omitempty" json:"path,omitempty"` + Context string `yaml:"context,omitempty" json:"context,omitempty"` + Platform string `yaml:"platform,omitempty" json:"platform,omitempty"` + Target string `yaml:"target,omitempty" json:"target,omitempty"` + Registry osutil.ExpandableString `yaml:"registry,omitempty" json:"registry,omitempty"` + Image osutil.ExpandableString `yaml:"image,omitempty" json:"image,omitempty"` + Tag osutil.ExpandableString `yaml:"tag,omitempty" json:"tag,omitempty"` + RemoteBuild bool `yaml:"remoteBuild,omitempty" json:"remoteBuild,omitempty"` + BuildArgs []osutil.ExpandableString `yaml:"buildArgs,omitempty" json:"buildArgs,omitempty"` // not supported from azure.yaml directly yet. Adding it for Aspire to use it, initially. // Aspire would pass the secret keys, which are env vars that azd will set just to run docker build. BuildSecrets []string `yaml:"-" json:"-"` @@ -223,14 +223,23 @@ func (p *dockerProject) Build( } return result, nil } + + dockerBuildArgs := []string{} + for _, arg := range dockerOptions.BuildArgs { + buildArgValue, err := arg.Envsubst(p.env.Getenv) + if err != nil { + return nil, fmt.Errorf("substituting environment variables in build args: %w", err) + } + + dockerBuildArgs = append(dockerBuildArgs, buildArgValue) + } + // resolve parameters for build args and secrets - resolvedBuildArgs, err := resolveParameters(dockerOptions.BuildArgs) + resolvedBuildArgs, err := resolveParameters(dockerBuildArgs) if err != nil { return nil, err } - dockerOptions.BuildArgs = resolvedBuildArgs - resolvedBuildEnv, err := resolveParameters(dockerOptions.BuildEnv) if err != nil { return nil, err @@ -247,7 +256,7 @@ func (p *dockerProject) Build( } buildArgs := []string{} - for _, arg := range dockerOptions.BuildArgs { + for _, arg := range resolvedBuildArgs { buildArgs = append(buildArgs, exec.RedactSensitiveData(arg)) } @@ -286,6 +295,15 @@ func (p *dockerProject) Build( return res, nil } + // Include full environment variables for the docker build including: + // 1. Environment variables from the host + // 2. Environment variables from the service configuration + // 3. Environment variables from the docker configuration + dockerEnv := []string{} + dockerEnv = append(dockerEnv, os.Environ()...) + dockerEnv = append(dockerEnv, p.env.Environ()...) + dockerEnv = append(dockerEnv, dockerOptions.BuildEnv...) + // Build the container progress.SetProgress(NewServiceProgress("Building Docker image")) previewerWriter := p.console.ShowPreviewer(ctx, @@ -302,9 +320,9 @@ func (p *dockerProject) Build( dockerOptions.Target, dockerOptions.Context, imageName, - dockerOptions.BuildArgs, + resolvedBuildArgs, dockerOptions.BuildSecrets, - dockerOptions.BuildEnv, + dockerEnv, previewerWriter, ) p.console.StopPreviewer(ctx, false) diff --git a/cli/azd/pkg/project/framework_service_docker_test.go b/cli/azd/pkg/project/framework_service_docker_test.go index 3ed765d7cd3..8ed8d39fd1b 100644 --- a/cli/azd/pkg/project/framework_service_docker_test.go +++ b/cli/azd/pkg/project/framework_service_docker_test.go @@ -233,7 +233,12 @@ services: func Test_DockerProject_Build(t *testing.T) { tests := []struct { + // Optional - use for custom initialization + init func(t *testing.T) error + // Optional - use for custom validation + validate func(t *testing.T, result *ServiceBuildResult, dockerBuildArgs *exec.RunArgs) error name string + env *environment.Environment project string language ServiceLanguageKind dockerOptions DockerProjectOptions @@ -342,6 +347,47 @@ func Test_DockerProject_Build(t *testing.T) { }, expectedDockerBuildArgs: nil, }, + { + name: "With custom environment variables", + project: "./src/api", + language: ServiceLanguageJavaScript, + hasDockerFile: true, + init: func(t *testing.T) error { + os.Setenv("AZD_CUSTOM_OS_VAR", "os-value") + return nil + }, + env: environment.NewWithValues("test", map[string]string{ + "AZD_CUSTOM_ENV_VAR": "env-value", + }), + dockerOptions: DockerProjectOptions{ + BuildEnv: []string{ + "AZD_CUSTOM_BUILD_VAR=build-value", + }, + BuildArgs: []osutil.ExpandableString{ + osutil.NewExpandableString("AZD_CUSTOM_OS_VAR"), + osutil.NewExpandableString("AZD_CUSTOM_ENV_VAR"), + osutil.NewExpandableString("AZD_CUSTOM_BUILD_VAR"), + osutil.NewExpandableString("AZD_CUSTOM_EXPANDED_VAR=${AZD_CUSTOM_ENV_VAR}"), + }, + }, + validate: func(t *testing.T, result *ServiceBuildResult, dockerBuildArgs *exec.RunArgs) error { + require.NotNil(t, result) + require.NotNil(t, dockerBuildArgs) + + // Contains OS, azd env & docker env vars + require.Contains(t, dockerBuildArgs.Env, "AZD_CUSTOM_OS_VAR=os-value") + require.Contains(t, dockerBuildArgs.Env, "AZD_CUSTOM_ENV_VAR=env-value") + require.Contains(t, dockerBuildArgs.Env, "AZD_CUSTOM_BUILD_VAR=build-value") + + // Contains docker build args + require.Contains(t, dockerBuildArgs.Args, "AZD_CUSTOM_OS_VAR") + require.Contains(t, dockerBuildArgs.Args, "AZD_CUSTOM_ENV_VAR") + require.Contains(t, dockerBuildArgs.Args, "AZD_CUSTOM_BUILD_VAR") + require.Contains(t, dockerBuildArgs.Args, "AZD_CUSTOM_EXPANDED_VAR=env-value") + + return nil + }, + }, } for _, tt := range tests { @@ -385,9 +431,18 @@ func Test_DockerProject_Build(t *testing.T) { return exec.NewRunResult(0, "3.0.0", ""), nil }) + if tt.init != nil { + err := tt.init(t) + require.NoError(t, err) + } + temp := t.TempDir() - env := environment.New("test") + env := tt.env + if env == nil { + env = environment.New("test") + } + dockerCli := docker.NewCli(mockContext.CommandRunner) serviceConfig := createTestServiceConfig(tt.project, ContainerAppTarget, tt.language) serviceConfig.Project.Path = temp @@ -427,10 +482,15 @@ func Test_DockerProject_Build(t *testing.T) { }, ) - require.NoError(t, err) - require.NotNil(t, result) - require.Equal(t, tt.expectedBuildResult, result) - require.Equal(t, tt.expectedDockerBuildArgs, dockerBuildArgs.Args) + if tt.validate != nil { + err := tt.validate(t, result, &dockerBuildArgs) + require.NoError(t, err) + } else { + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, tt.expectedBuildResult, result) + require.Equal(t, tt.expectedDockerBuildArgs, dockerBuildArgs.Args) + } }) } } diff --git a/cli/azd/pkg/project/project_config_test.go b/cli/azd/pkg/project/project_config_test.go index 9886d3c1ed2..3b0b1af2e4c 100644 --- a/cli/azd/pkg/project/project_config_test.go +++ b/cli/azd/pkg/project/project_config_test.go @@ -10,6 +10,7 @@ import ( "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/ext" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/azure/azure-dev/cli/azd/test/snapshot" "github.com/stretchr/testify/require" @@ -126,7 +127,38 @@ services: require.Equal(t, "./Dockerfile.dev", service.Docker.Path) require.Equal(t, "../", service.Docker.Context) - require.Equal(t, []string{"foo", "bar"}, service.Docker.BuildArgs) + require.Equal(t, []osutil.ExpandableString{ + osutil.NewExpandableString("foo"), + osutil.NewExpandableString("bar"), + }, service.Docker.BuildArgs) +} + +func TestProjectWithExpandableDockerArgs(t *testing.T) { + env := environment.NewWithValues("test", map[string]string{ + "REGISTRY": "myregistry", + "IMAGE": "myimage", + "TAG": "mytag", + "KEY1": "val1", + "KEY2": "val2", + }) + + serviceConfig := &ServiceConfig{ + Docker: DockerProjectOptions{ + Registry: osutil.NewExpandableString("${REGISTRY}"), + Image: osutil.NewExpandableString("${IMAGE}"), + Tag: osutil.NewExpandableString("${TAG}"), + BuildArgs: []osutil.ExpandableString{ + osutil.NewExpandableString("key1=${KEY1}"), + osutil.NewExpandableString("key2=${KEY2}"), + }, + }, + } + + require.Equal(t, env.Getenv("REGISTRY"), serviceConfig.Docker.Registry.MustEnvsubst(env.Getenv)) + require.Equal(t, env.Getenv("IMAGE"), serviceConfig.Docker.Image.MustEnvsubst(env.Getenv)) + require.Equal(t, env.Getenv("TAG"), serviceConfig.Docker.Tag.MustEnvsubst(env.Getenv)) + require.Equal(t, fmt.Sprintf("key1=%s", env.Getenv("KEY1")), serviceConfig.Docker.BuildArgs[0].MustEnvsubst(env.Getenv)) + require.Equal(t, fmt.Sprintf("key2=%s", env.Getenv("KEY2")), serviceConfig.Docker.BuildArgs[1].MustEnvsubst(env.Getenv)) } func TestProjectConfigAddHandler(t *testing.T) {