Skip to content

Commit f8d6ac3

Browse files
spboyerCopilot
andcommitted
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>
1 parent f8fdf47 commit f8d6ac3

8 files changed

Lines changed: 229 additions & 0 deletions

File tree

cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,14 @@ func (p *BicepProvider) Deploy(ctx context.Context) (*provisioning.DeployResult,
678678
deploymentTags[azure.TagKeyAzdDeploymentStateParamHashName] = new(currentParamsHash)
679679
}
680680

681+
// Merge user-specified custom tags from azure.yaml.
682+
// Built-in azd tags (set above) take precedence over user tags.
683+
for k, v := range p.options.Tags {
684+
if _, exists := deploymentTags[k]; !exists {
685+
deploymentTags[k] = new(v)
686+
}
687+
}
688+
681689
optionsMap, err := convert.ToMap(p.options)
682690
if err != nil {
683691
return nil, err

cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1910,3 +1910,40 @@ func TestHelperEvalParamEnvSubst(t *testing.T) {
19101910
require.Contains(t, substResult.mappedEnvVars, "VAR2")
19111911
require.False(t, substResult.hasUnsetEnvVar)
19121912
}
1913+
1914+
func TestDeploymentTagMergePrecedence(t *testing.T) {
1915+
// Simulate the tag-merge logic from Deploy (lines 673-687 of
1916+
// bicep_provider.go). Custom user tags should be included, but
1917+
// must never override the built-in azd tags.
1918+
envName := "my-env"
1919+
layerName := ""
1920+
1921+
// Built-in tags that azd always sets.
1922+
deploymentTags := map[string]*string{
1923+
azure.TagKeyAzdEnvName: new(envName),
1924+
azure.TagKeyAzdLayerName: &layerName,
1925+
}
1926+
1927+
// User-specified tags: one non-conflicting, one colliding with
1928+
// the built-in azd-env-name tag.
1929+
customTags := map[string]string{
1930+
"team": "platform",
1931+
azure.TagKeyAzdEnvName: "should-not-override",
1932+
}
1933+
1934+
// Merge — same logic as Deploy().
1935+
for k, v := range customTags {
1936+
if _, exists := deploymentTags[k]; !exists {
1937+
deploymentTags[k] = new(v)
1938+
}
1939+
}
1940+
1941+
// Non-conflicting custom tag should be present.
1942+
require.NotNil(t, deploymentTags["team"])
1943+
require.Equal(t, "platform", *deploymentTags["team"])
1944+
1945+
// Built-in azd-env-name must NOT be overridden by user tag.
1946+
require.NotNil(t, deploymentTags[azure.TagKeyAzdEnvName])
1947+
require.Equal(t, envName, *deploymentTags[azure.TagKeyAzdEnvName],
1948+
"built-in azd-env-name tag must not be overridden by user-specified tags")
1949+
}

cli/azd/pkg/infra/provisioning/provider.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ type Options struct {
4343
// Provisioning options for each individually defined layer.
4444
Layers []Options `yaml:"layers,omitempty"`
4545

46+
// Tags specifies custom Azure resource tags to apply to deployments.
47+
// These are merged with built-in azd tags; built-in tags take precedence.
48+
Tags map[string]string `yaml:"-"`
49+
4650
// Runtime options
4751

4852
// IgnoreDeploymentState when true, skips the deployment state check.

cli/azd/pkg/project/project.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,33 @@ func Load(ctx context.Context, projectFilePath string) (*ProjectConfig, error) {
168168

169169
projectConfig.Path = filepath.Dir(projectFilePath)
170170

171+
// Propagate project-level tags into the infra options so that
172+
// provisioning providers can apply them to deployments.
173+
if len(projectConfig.Tags) > 0 {
174+
if projectConfig.Infra.Tags == nil {
175+
projectConfig.Infra.Tags = make(map[string]string)
176+
}
177+
for k, v := range projectConfig.Tags {
178+
// Only set if not already specified at the infra level
179+
if _, exists := projectConfig.Infra.Tags[k]; !exists {
180+
projectConfig.Infra.Tags[k] = v
181+
}
182+
}
183+
184+
// Also propagate into each layer's options so layered
185+
// deployments receive the same custom tags.
186+
for i := range projectConfig.Infra.Layers {
187+
if projectConfig.Infra.Layers[i].Tags == nil {
188+
projectConfig.Infra.Layers[i].Tags = make(map[string]string)
189+
}
190+
for k, v := range projectConfig.Tags {
191+
if _, exists := projectConfig.Infra.Layers[i].Tags[k]; !exists {
192+
projectConfig.Infra.Layers[i].Tags[k] = v
193+
}
194+
}
195+
}
196+
}
197+
171198
provisioningOptions := provisioning.Options{}
172199
mergo.Merge(&provisioningOptions, projectConfig.Infra)
173200
mergo.Merge(&provisioningOptions, DefaultProvisioningOptions)

cli/azd/pkg/project/project_config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ type ProjectConfig struct {
4141
Cloud *cloud.Config `yaml:"cloud,omitempty"`
4242
Resources map[string]*ResourceConfig `yaml:"resources,omitempty"`
4343

44+
// Tags specifies custom Azure resource tags to apply to deployments.
45+
// These tags are merged with the default azd tags (e.g. azd-env-name).
46+
// User-specified tags cannot override built-in azd tags.
47+
Tags map[string]string `yaml:"tags,omitempty"`
48+
4449
// AdditionalProperties captures any unknown YAML fields for extension support
4550
AdditionalProperties map[string]any `yaml:",inline"`
4651

cli/azd/pkg/project/project_config_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,3 +592,69 @@ resources:
592592
require.Equal(t, "FOO", cap.Env[0].Name)
593593
require.Equal(t, "BAR", cap.Env[0].Value)
594594
}
595+
596+
func TestProjectConfigTags(t *testing.T) {
597+
tests := []struct {
598+
name string
599+
yaml string
600+
expectedTags map[string]string
601+
}{
602+
{
603+
name: "WithTags",
604+
yaml: heredoc.Doc(`
605+
name: test-proj
606+
tags:
607+
environment: production
608+
team: platform
609+
cost-center: "12345"
610+
services:
611+
web:
612+
project: src/web
613+
language: js
614+
host: appservice
615+
`),
616+
expectedTags: map[string]string{
617+
"environment": "production",
618+
"team": "platform",
619+
"cost-center": "12345",
620+
},
621+
},
622+
{
623+
name: "WithoutTags",
624+
yaml: heredoc.Doc(`
625+
name: test-proj
626+
services:
627+
web:
628+
project: src/web
629+
language: js
630+
host: appservice
631+
`),
632+
expectedTags: nil,
633+
},
634+
{
635+
name: "EmptyTags",
636+
yaml: heredoc.Doc(`
637+
name: test-proj
638+
tags: {}
639+
services:
640+
web:
641+
project: src/web
642+
language: js
643+
host: appservice
644+
`),
645+
expectedTags: map[string]string{},
646+
},
647+
}
648+
649+
for _, tt := range tests {
650+
t.Run(tt.name, func(t *testing.T) {
651+
mockContext := mocks.NewMockContext(context.Background())
652+
projectConfig, err := Parse(
653+
*mockContext.Context, tt.yaml,
654+
)
655+
require.NoError(t, err)
656+
require.NotNil(t, projectConfig)
657+
require.Equal(t, tt.expectedTags, projectConfig.Tags)
658+
})
659+
}
660+
}

cli/azd/pkg/project/project_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,3 +1010,77 @@ func TestAdditionalPropertiesExtraction(t *testing.T) {
10101010
assert.Equal(t, 3, finalExtensionConfig.Retries) // Unchanged
10111011
})
10121012
}
1013+
1014+
func TestLoadProjectTagsPropagateToInfra(t *testing.T) {
1015+
const yamlContent = `
1016+
name: test-proj
1017+
tags:
1018+
environment: production
1019+
team: platform
1020+
services:
1021+
web:
1022+
project: src/web
1023+
language: js
1024+
host: appservice
1025+
`
1026+
dir := t.TempDir()
1027+
projectFile := filepath.Join(dir, "azure.yaml")
1028+
err := os.WriteFile(projectFile, []byte(yamlContent), osutil.PermissionFile)
1029+
require.NoError(t, err)
1030+
1031+
// Create the src/web directory so service resolution doesn't fail
1032+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "src/web"), osutil.PermissionDirectory))
1033+
1034+
cfg, err := Load(t.Context(), projectFile)
1035+
require.NoError(t, err)
1036+
1037+
// Project-level tags should be set
1038+
require.Equal(t, "production", cfg.Tags["environment"])
1039+
require.Equal(t, "platform", cfg.Tags["team"])
1040+
1041+
// Tags should be propagated into the infra options
1042+
require.Equal(t, "production", cfg.Infra.Tags["environment"])
1043+
require.Equal(t, "platform", cfg.Infra.Tags["team"])
1044+
}
1045+
1046+
func TestLoadProjectTagsPropagateToLayers(t *testing.T) {
1047+
const yamlContent = `
1048+
name: test-proj
1049+
tags:
1050+
environment: production
1051+
team: platform
1052+
infra:
1053+
provider: bicep
1054+
layers:
1055+
- name: network
1056+
path: infra/network
1057+
module: main
1058+
- name: compute
1059+
path: infra/compute
1060+
module: main
1061+
services:
1062+
web:
1063+
project: src/web
1064+
language: js
1065+
host: appservice
1066+
`
1067+
dir := t.TempDir()
1068+
projectFile := filepath.Join(dir, "azure.yaml")
1069+
err := os.WriteFile(projectFile, []byte(yamlContent), osutil.PermissionFile)
1070+
require.NoError(t, err)
1071+
1072+
// Create required directories
1073+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "src/web"), osutil.PermissionDirectory))
1074+
1075+
cfg, err := Load(t.Context(), projectFile)
1076+
require.NoError(t, err)
1077+
1078+
// Each layer should have the project-level tags propagated
1079+
require.Len(t, cfg.Infra.Layers, 2)
1080+
for _, layer := range cfg.Infra.Layers {
1081+
require.Equal(t, "production", layer.Tags["environment"],
1082+
"layer %q should have project-level tag 'environment'", layer.Name)
1083+
require.Equal(t, "platform", layer.Tags["team"],
1084+
"layer %q should have project-level tag 'team'", layer.Name)
1085+
}
1086+
}

schemas/v1.0/azure.yaml.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@
3535
}
3636
}
3737
},
38+
"tags": {
39+
"type": "object",
40+
"title": "Custom Azure resource tags",
41+
"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.",
42+
"additionalProperties": {
43+
"type": "string"
44+
}
45+
},
3846
"infra": {
3947
"type": "object",
4048
"title": "The infrastructure configuration used for the application",

0 commit comments

Comments
 (0)