feat: add merge_strategy support for coder_env resources (#23107)

## Description

Implements the server-side merge logic for the `merge_strategy`
attribute added to `coder_env` in [terraform-provider-coder
v2.15.0](https://github.com/coder/terraform-provider-coder/pull/489).
This allows template authors to control how duplicate environment
variable names are combined across multiple `coder_env` resources.

Relates to https://github.com/coder/coder/issues/21885

## Supported strategies

| Strategy | Behavior |
|----------|----------|
| `replace` (default) | Last value wins — backward compatible |
| `append` | Joins values with `:` separator (e.g. PATH additions) |
| `prepend` | Prepends value with `:` separator |
| `error` | Fails the build if the variable is already defined |

## Example

```hcl
resource "coder_env" "path_tools" {
  agent_id       = coder_agent.dev.id
  name           = "PATH"
  value          = "/home/coder/tools/bin"
  merge_strategy = "append"
}
```

## Changes

- **Proto**: Added `merge_strategy` field to `Env` message in
`provisioner.proto`
- **State reader**: Updated `agentEnvAttributes` struct and proto
construction in `resources.go`
- **Merge logic**: Added `mergeExtraEnvs()` function in
`provisionerdserver.go` with strategy-aware merging for both agent envs
and devcontainer subagent envs
- **Tests**: 15 unit tests covering all strategies, edge cases (empty
values, mixed strategies, multiple appends)
- **Dependency**: Bumped `terraform-provider-coder` v2.14.0 → v2.15.0
- **Fixtures**: Updated `duplicate-env-keys` test fixtures and golden
files

## Ordering

When multiple resources `append` or `prepend` to the same key, they are
processed in alphabetical order by Terraform resource address (per the
determinism fix in #22706).
This commit is contained in:
Kacper Sawicki
2026-03-18 15:43:28 +01:00
committed by GitHub
parent 84de391f26
commit 1e07ec49a6
18 changed files with 912 additions and 530 deletions
@@ -2834,12 +2834,11 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
}
env := make(map[string]string)
// For now, we only support adding extra envs, not overriding
// existing ones or performing other manipulations. In future
// we may write these to a separate table so we can perform
// conditional logic on the agent.
for _, e := range prAgent.ExtraEnvs {
env[e.Name] = e.Value
// Apply extra envs with merge strategy support.
// When multiple coder_env resources define the same name,
// the merge_strategy controls how values are combined.
if err := MergeExtraEnvs(env, prAgent.ExtraEnvs); err != nil {
return err
}
// Allow the agent defined envs to override extra envs.
for k, v := range prAgent.Env {
@@ -3435,14 +3434,54 @@ func insertDevcontainerSubagent(
return subAgentID, nil
}
// MergeExtraEnvs applies extra environment variables to the given map,
// respecting the merge_strategy field on each env. When merge_strategy
// is empty or "replace", the value overwrites any existing entry.
// "append" and "prepend" join values with a ":" separator (PATH-style).
// "error" causes a failure if the key already exists.
func MergeExtraEnvs(env map[string]string, extraEnvs []*sdkproto.Env) error {
for _, e := range extraEnvs {
strategy := e.GetMergeStrategy()
if strategy == "" {
strategy = "replace"
}
existing, exists := env[e.GetName()]
switch strategy {
case "error":
if exists {
return xerrors.Errorf(
"duplicate env var %q: merge_strategy is %q but variable is already defined",
e.GetName(), strategy,
)
}
env[e.GetName()] = e.GetValue()
case "append":
if exists && existing != "" {
env[e.GetName()] = existing + ":" + e.GetValue()
} else {
env[e.GetName()] = e.GetValue()
}
case "prepend":
if exists && existing != "" {
env[e.GetName()] = e.GetValue() + ":" + existing
} else {
env[e.GetName()] = e.GetValue()
}
default: // "replace"
env[e.GetName()] = e.GetValue()
}
}
return nil
}
func encodeSubagentEnvs(envs []*sdkproto.Env) (pqtype.NullRawMessage, error) {
if len(envs) == 0 {
return pqtype.NullRawMessage{}, nil
}
subAgentEnvs := make(map[string]string, len(envs))
for _, env := range envs {
subAgentEnvs[env.GetName()] = env.GetValue()
if err := MergeExtraEnvs(subAgentEnvs, envs); err != nil {
return pqtype.NullRawMessage{}, err
}
data, err := json.Marshal(subAgentEnvs)