Skip to content

Empty service env: {} is dropped on save, so an explicit empty environment scope cannot be expressed #9401

Description

@huimiu

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

  1. 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.
  2. Give ServiceConfig custom marshalling that tracks whether env: was present in the source document.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions