From f8d6ac35921281c44ae19466e192d5838320dfe7 Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Thu, 26 Mar 2026 21:25:52 -0700 Subject: [PATCH] feat: support custom tags in azure.yaml Add a `tags:` section to azure.yaml that allows users to specify custom Azure resource tags. Tags are merged with the default azd-env-name tag and passed to Bicep provisioning. Fixes #4479 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../provisioning/bicep/bicep_provider.go | 8 ++ .../provisioning/bicep/bicep_provider_test.go | 37 ++++++++++ cli/azd/pkg/infra/provisioning/provider.go | 4 + cli/azd/pkg/project/project.go | 27 +++++++ cli/azd/pkg/project/project_config.go | 5 ++ cli/azd/pkg/project/project_config_test.go | 66 +++++++++++++++++ cli/azd/pkg/project/project_test.go | 74 +++++++++++++++++++ schemas/v1.0/azure.yaml.json | 8 ++ 8 files changed, 229 insertions(+) diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index 8f00c63a7ca..0fcc2f7952c 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -678,6 +678,14 @@ func (p *BicepProvider) Deploy(ctx context.Context) (*provisioning.DeployResult, deploymentTags[azure.TagKeyAzdDeploymentStateParamHashName] = new(currentParamsHash) } + // Merge user-specified custom tags from azure.yaml. + // Built-in azd tags (set above) take precedence over user tags. + for k, v := range p.options.Tags { + if _, exists := deploymentTags[k]; !exists { + deploymentTags[k] = new(v) + } + } + optionsMap, err := convert.ToMap(p.options) if err != nil { return nil, err diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go index 1bb52ef0a11..b309dbbe9ac 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go @@ -1910,3 +1910,40 @@ func TestHelperEvalParamEnvSubst(t *testing.T) { require.Contains(t, substResult.mappedEnvVars, "VAR2") require.False(t, substResult.hasUnsetEnvVar) } + +func TestDeploymentTagMergePrecedence(t *testing.T) { + // Simulate the tag-merge logic from Deploy (lines 673-687 of + // bicep_provider.go). Custom user tags should be included, but + // must never override the built-in azd tags. + envName := "my-env" + layerName := "" + + // Built-in tags that azd always sets. + deploymentTags := map[string]*string{ + azure.TagKeyAzdEnvName: new(envName), + azure.TagKeyAzdLayerName: &layerName, + } + + // User-specified tags: one non-conflicting, one colliding with + // the built-in azd-env-name tag. + customTags := map[string]string{ + "team": "platform", + azure.TagKeyAzdEnvName: "should-not-override", + } + + // Merge — same logic as Deploy(). + for k, v := range customTags { + if _, exists := deploymentTags[k]; !exists { + deploymentTags[k] = new(v) + } + } + + // Non-conflicting custom tag should be present. + require.NotNil(t, deploymentTags["team"]) + require.Equal(t, "platform", *deploymentTags["team"]) + + // Built-in azd-env-name must NOT be overridden by user tag. + require.NotNil(t, deploymentTags[azure.TagKeyAzdEnvName]) + require.Equal(t, envName, *deploymentTags[azure.TagKeyAzdEnvName], + "built-in azd-env-name tag must not be overridden by user-specified tags") +} diff --git a/cli/azd/pkg/infra/provisioning/provider.go b/cli/azd/pkg/infra/provisioning/provider.go index 4da7dfbe1a8..d30a24b46a8 100644 --- a/cli/azd/pkg/infra/provisioning/provider.go +++ b/cli/azd/pkg/infra/provisioning/provider.go @@ -43,6 +43,10 @@ type Options struct { // Provisioning options for each individually defined layer. Layers []Options `yaml:"layers,omitempty"` + // Tags specifies custom Azure resource tags to apply to deployments. + // These are merged with built-in azd tags; built-in tags take precedence. + Tags map[string]string `yaml:"-"` + // Runtime options // IgnoreDeploymentState when true, skips the deployment state check. diff --git a/cli/azd/pkg/project/project.go b/cli/azd/pkg/project/project.go index 40b89953f8d..3dc0b74e2c5 100644 --- a/cli/azd/pkg/project/project.go +++ b/cli/azd/pkg/project/project.go @@ -168,6 +168,33 @@ func Load(ctx context.Context, projectFilePath string) (*ProjectConfig, error) { projectConfig.Path = filepath.Dir(projectFilePath) + // Propagate project-level tags into the infra options so that + // provisioning providers can apply them to deployments. + if len(projectConfig.Tags) > 0 { + if projectConfig.Infra.Tags == nil { + projectConfig.Infra.Tags = make(map[string]string) + } + for k, v := range projectConfig.Tags { + // Only set if not already specified at the infra level + if _, exists := projectConfig.Infra.Tags[k]; !exists { + projectConfig.Infra.Tags[k] = v + } + } + + // Also propagate into each layer's options so layered + // deployments receive the same custom tags. + for i := range projectConfig.Infra.Layers { + if projectConfig.Infra.Layers[i].Tags == nil { + projectConfig.Infra.Layers[i].Tags = make(map[string]string) + } + for k, v := range projectConfig.Tags { + if _, exists := projectConfig.Infra.Layers[i].Tags[k]; !exists { + projectConfig.Infra.Layers[i].Tags[k] = v + } + } + } + } + provisioningOptions := provisioning.Options{} mergo.Merge(&provisioningOptions, projectConfig.Infra) mergo.Merge(&provisioningOptions, DefaultProvisioningOptions) diff --git a/cli/azd/pkg/project/project_config.go b/cli/azd/pkg/project/project_config.go index 2e5a378e187..957b755390b 100644 --- a/cli/azd/pkg/project/project_config.go +++ b/cli/azd/pkg/project/project_config.go @@ -41,6 +41,11 @@ type ProjectConfig struct { Cloud *cloud.Config `yaml:"cloud,omitempty"` Resources map[string]*ResourceConfig `yaml:"resources,omitempty"` + // Tags specifies custom Azure resource tags to apply to deployments. + // These tags are merged with the default azd tags (e.g. azd-env-name). + // User-specified tags cannot override built-in azd tags. + Tags map[string]string `yaml:"tags,omitempty"` + // AdditionalProperties captures any unknown YAML fields for extension support AdditionalProperties map[string]any `yaml:",inline"` diff --git a/cli/azd/pkg/project/project_config_test.go b/cli/azd/pkg/project/project_config_test.go index ca6688abf9d..a4688b93d9f 100644 --- a/cli/azd/pkg/project/project_config_test.go +++ b/cli/azd/pkg/project/project_config_test.go @@ -592,3 +592,69 @@ resources: require.Equal(t, "FOO", cap.Env[0].Name) require.Equal(t, "BAR", cap.Env[0].Value) } + +func TestProjectConfigTags(t *testing.T) { + tests := []struct { + name string + yaml string + expectedTags map[string]string + }{ + { + name: "WithTags", + yaml: heredoc.Doc(` + name: test-proj + tags: + environment: production + team: platform + cost-center: "12345" + services: + web: + project: src/web + language: js + host: appservice + `), + expectedTags: map[string]string{ + "environment": "production", + "team": "platform", + "cost-center": "12345", + }, + }, + { + name: "WithoutTags", + yaml: heredoc.Doc(` + name: test-proj + services: + web: + project: src/web + language: js + host: appservice + `), + expectedTags: nil, + }, + { + name: "EmptyTags", + yaml: heredoc.Doc(` + name: test-proj + tags: {} + services: + web: + project: src/web + language: js + host: appservice + `), + expectedTags: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + projectConfig, err := Parse( + *mockContext.Context, tt.yaml, + ) + require.NoError(t, err) + require.NotNil(t, projectConfig) + require.Equal(t, tt.expectedTags, projectConfig.Tags) + }) + } +} diff --git a/cli/azd/pkg/project/project_test.go b/cli/azd/pkg/project/project_test.go index 727951aa205..901a807f9c5 100644 --- a/cli/azd/pkg/project/project_test.go +++ b/cli/azd/pkg/project/project_test.go @@ -1010,3 +1010,77 @@ func TestAdditionalPropertiesExtraction(t *testing.T) { assert.Equal(t, 3, finalExtensionConfig.Retries) // Unchanged }) } + +func TestLoadProjectTagsPropagateToInfra(t *testing.T) { + const yamlContent = ` +name: test-proj +tags: + environment: production + team: platform +services: + web: + project: src/web + language: js + host: appservice +` + dir := t.TempDir() + projectFile := filepath.Join(dir, "azure.yaml") + err := os.WriteFile(projectFile, []byte(yamlContent), osutil.PermissionFile) + require.NoError(t, err) + + // Create the src/web directory so service resolution doesn't fail + require.NoError(t, os.MkdirAll(filepath.Join(dir, "src/web"), osutil.PermissionDirectory)) + + cfg, err := Load(t.Context(), projectFile) + require.NoError(t, err) + + // Project-level tags should be set + require.Equal(t, "production", cfg.Tags["environment"]) + require.Equal(t, "platform", cfg.Tags["team"]) + + // Tags should be propagated into the infra options + require.Equal(t, "production", cfg.Infra.Tags["environment"]) + require.Equal(t, "platform", cfg.Infra.Tags["team"]) +} + +func TestLoadProjectTagsPropagateToLayers(t *testing.T) { + const yamlContent = ` +name: test-proj +tags: + environment: production + team: platform +infra: + provider: bicep + layers: + - name: network + path: infra/network + module: main + - name: compute + path: infra/compute + module: main +services: + web: + project: src/web + language: js + host: appservice +` + dir := t.TempDir() + projectFile := filepath.Join(dir, "azure.yaml") + err := os.WriteFile(projectFile, []byte(yamlContent), osutil.PermissionFile) + require.NoError(t, err) + + // Create required directories + require.NoError(t, os.MkdirAll(filepath.Join(dir, "src/web"), osutil.PermissionDirectory)) + + cfg, err := Load(t.Context(), projectFile) + require.NoError(t, err) + + // Each layer should have the project-level tags propagated + require.Len(t, cfg.Infra.Layers, 2) + for _, layer := range cfg.Infra.Layers { + require.Equal(t, "production", layer.Tags["environment"], + "layer %q should have project-level tag 'environment'", layer.Name) + require.Equal(t, "platform", layer.Tags["team"], + "layer %q should have project-level tag 'team'", layer.Name) + } +} diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index ccc1e3a877a..6adcab0bf9b 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -35,6 +35,14 @@ } } }, + "tags": { + "type": "object", + "title": "Custom Azure resource tags", + "description": "Optional. Custom Azure resource tags to apply to deployments. These tags are merged with the default azd tags (e.g. azd-env-name). Built-in azd tags take precedence and cannot be overridden.", + "additionalProperties": { + "type": "string" + } + }, "infra": { "type": "object", "title": "The infrastructure configuration used for the application",