Empty service `env: {}` is dropped on save, so an explicit empty environment scope cannot be expressed
- Dominant language
- Go
- Stars
- 569
- Forks
- 364
- Avg merge
- 2d 19h
- Merged PRs (30d)
- 136
Description
## 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:
```go
// 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`:
```go
// 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.
Contributor guide
Research direction
Start with cli/azd/pkg/project/project.go SaveConfig and cli/azd/pkg/project/service_config.go, then trace the Environment usages in pkg/project, internal/cmd, and the agents extension. Add or run a pkg/project round-trip test using real LoadConfig/SaveConfig and verify empty env survives while absent env remains omitted; confirm the written azure.yaml and follow-up raw Get.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- azure, go
- Domain
- cli, developer-experience
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100