Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is what #4479 is asking for @spboyer

This PR is adding tags to the deployment object, not to specific resources within the deployment (like a rg , or some storage account).

I mentioned in the issue that this is more a candidate for an azd extension that runs post-provision and update the resources manually.
The configuration can live in azure.yaml or maybe also in azd.config so you can use the extension for any template.

if _, exists := deploymentTags[k]; !exists {
deploymentTags[k] = new(v)
}
}

optionsMap, err := convert.ToMap(p.options)
if err != nil {
return nil, err
Expand Down
37 changes: 37 additions & 0 deletions cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
4 changes: 4 additions & 0 deletions cli/azd/pkg/infra/provisioning/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions cli/azd/pkg/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions cli/azd/pkg/project/project_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down
66 changes: 66 additions & 0 deletions cli/azd/pkg/project/project_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}
74 changes: 74 additions & 0 deletions cli/azd/pkg/project/project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
8 changes: 8 additions & 0 deletions schemas/v1.0/azure.yaml.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading