Problem
azure.yaml cannot express "this service declares an empty environment scope." An explicitly empty env: {} is silently dropped when the project is saved, so it is indistinguishable from a service that never declared env: at all.
Root cause
project.SaveConfig re-parses the raw config map into a typed ProjectConfig and re-marshals that typed value:
// cli/azd/pkg/project/project.go:272
func SaveConfig(ctx context.Context, config config.Config, projectFilePath string) error {
projectBytes, err := yaml.Marshal(config.Raw())
...
projectConfig, err := Parse(ctx, string(projectBytes))
...
return Save(ctx, projectConfig, projectFilePath)
}
ServiceConfig.Environment is a plain map with omitempty:
// cli/azd/pkg/project/service_config.go:68
Environment osutil.ExpandableMap `yaml:"env,omitempty"`
osutil.ExpandableMap is map[string]ExpandableString with no custom MarshalYAML, so omitempty drops a zero-length map along with a nil one.
Reproduction
Against the real LoadConfig / SaveConfig:
| Input |
Written azure.yaml |
Raw Get("services.myagent.env") |
cfg.Set("services.myagent.env", map[string]any{}) |
no env: key at all |
found = false |
cfg.Set("services.myagent.env", map[string]any{"FOO": "${BAR}"}) |
env: with the value |
found = true |
Why it matters
Extensions decide environment scoping by probing whether env: is present. In the Foundry extensions, serviceEnvDeclared reads GetServiceConfigValue(Path: "env") and feeds ServiceRunContext.HasServiceEnvironment. When that is false, the run and deploy paths fall back to forwarding the entire azd environment into the child process.
Because an explicit empty scope cannot survive a save, any generated service with no variables of its own reads as legacy and inherits everything, while a service that happens to declare one variable is fully isolated. Scoping ends up determined by an incidental template detail rather than by intent.
This blocked the empty-scope work in #9079: the call sites were changed to always write the section, then reverted in 88a6b76be because the write never reached the file. The tests there passed only because the recording stub captured the RPC in memory and never round-tripped through SaveConfig.
Possible directions
- Change
ServiceConfig.Environment to *osutil.ExpandableMap. With a pointer, omitempty drops only nil, so absent stays absent and explicitly empty round-trips. There are roughly nine field usages across pkg/project, internal/cmd, and the agents extension.
- Give
ServiceConfig custom marshalling that tracks whether env: was present in the source document.
- Drop
omitempty. Smallest change, but every service that never declared env: starts emitting env: {} on save, which is a visible and unwanted diff for existing projects.
Option 1 looks like the smallest change that preserves both behaviors exactly.
Acceptance
- A raw config
Set of an empty env section survives SaveConfig and is visible to a follow-up raw Get.
- A service that never declared
env: still saves without an env: key.
- A test in
pkg/project asserts against the written azure.yaml rather than a stub, since that is the layer that decides this.
- Foundry extension call sites can then declare an explicit empty scope again.
Problem
azure.yamlcannot express "this service declares an empty environment scope." An explicitly emptyenv: {}is silently dropped when the project is saved, so it is indistinguishable from a service that never declaredenv:at all.Root cause
project.SaveConfigre-parses the raw config map into a typedProjectConfigand re-marshals that typed value:ServiceConfig.Environmentis a plain map withomitempty:osutil.ExpandableMapismap[string]ExpandableStringwith no customMarshalYAML, soomitemptydrops a zero-length map along with a nil one.Reproduction
Against the real
LoadConfig/SaveConfig:azure.yamlGet("services.myagent.env")cfg.Set("services.myagent.env", map[string]any{})env:key at allfound = falsecfg.Set("services.myagent.env", map[string]any{"FOO": "${BAR}"})env:with the valuefound = trueWhy it matters
Extensions decide environment scoping by probing whether
env:is present. In the Foundry extensions,serviceEnvDeclaredreadsGetServiceConfigValue(Path: "env")and feedsServiceRunContext.HasServiceEnvironment. When that is false, the run and deploy paths fall back to forwarding the entire azd environment into the child process.Because an explicit empty scope cannot survive a save, any generated service with no variables of its own reads as legacy and inherits everything, while a service that happens to declare one variable is fully isolated. Scoping ends up determined by an incidental template detail rather than by intent.
This blocked the empty-scope work in #9079: the call sites were changed to always write the section, then reverted in
88a6b76bebecause the write never reached the file. The tests there passed only because the recording stub captured the RPC in memory and never round-tripped throughSaveConfig.Possible directions
ServiceConfig.Environmentto*osutil.ExpandableMap. With a pointer,omitemptydrops only nil, so absent stays absent and explicitly empty round-trips. There are roughly nine field usages acrosspkg/project,internal/cmd, and the agents extension.ServiceConfigcustom marshalling that tracks whetherenv:was present in the source document.omitempty. Smallest change, but every service that never declaredenv:starts emittingenv: {}on save, which is a visible and unwanted diff for existing projects.Option 1 looks like the smallest change that preserves both behaviors exactly.
Acceptance
Setof an emptyenvsection survivesSaveConfigand is visible to a follow-up rawGet.env:still saves without anenv:key.pkg/projectasserts against the writtenazure.yamlrather than a stub, since that is the layer that decides this.