Files
coder/coderd/provisionerdserver/mergeenvs_test.go
T
Kacper Sawicki 1e07ec49a6 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).
2026-03-18 15:43:28 +01:00

167 lines
4.3 KiB
Go

package provisionerdserver_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/provisionerdserver"
sdkproto "github.com/coder/coder/v2/provisionersdk/proto"
)
func TestMergeExtraEnvs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
initial map[string]string
envs []*sdkproto.Env
expected map[string]string
expectErr string
}{
{
name: "empty",
initial: map[string]string{},
envs: nil,
expected: map[string]string{},
},
{
name: "default_replace",
initial: map[string]string{},
envs: []*sdkproto.Env{
{Name: "FOO", Value: "bar"},
},
expected: map[string]string{"FOO": "bar"},
},
{
name: "explicit_replace",
initial: map[string]string{"FOO": "old"},
envs: []*sdkproto.Env{
{Name: "FOO", Value: "new", MergeStrategy: "replace"},
},
expected: map[string]string{"FOO": "new"},
},
{
name: "empty_strategy_defaults_to_replace",
initial: map[string]string{"FOO": "old"},
envs: []*sdkproto.Env{
{Name: "FOO", Value: "new", MergeStrategy: ""},
},
expected: map[string]string{"FOO": "new"},
},
{
name: "append_to_existing",
initial: map[string]string{"PATH": "/usr/bin"},
envs: []*sdkproto.Env{
{Name: "PATH", Value: "/custom/bin", MergeStrategy: "append"},
},
expected: map[string]string{"PATH": "/usr/bin:/custom/bin"},
},
{
name: "append_no_existing",
initial: map[string]string{},
envs: []*sdkproto.Env{
{Name: "PATH", Value: "/custom/bin", MergeStrategy: "append"},
},
expected: map[string]string{"PATH": "/custom/bin"},
},
{
name: "append_to_empty_value",
initial: map[string]string{"PATH": ""},
envs: []*sdkproto.Env{
{Name: "PATH", Value: "/custom/bin", MergeStrategy: "append"},
},
expected: map[string]string{"PATH": "/custom/bin"},
},
{
name: "prepend_to_existing",
initial: map[string]string{"PATH": "/usr/bin"},
envs: []*sdkproto.Env{
{Name: "PATH", Value: "/custom/bin", MergeStrategy: "prepend"},
},
expected: map[string]string{"PATH": "/custom/bin:/usr/bin"},
},
{
name: "prepend_no_existing",
initial: map[string]string{},
envs: []*sdkproto.Env{
{Name: "PATH", Value: "/custom/bin", MergeStrategy: "prepend"},
},
expected: map[string]string{"PATH": "/custom/bin"},
},
{
name: "error_no_duplicate",
initial: map[string]string{},
envs: []*sdkproto.Env{
{Name: "FOO", Value: "bar", MergeStrategy: "error"},
},
expected: map[string]string{"FOO": "bar"},
},
{
name: "error_with_duplicate",
initial: map[string]string{"FOO": "existing"},
envs: []*sdkproto.Env{
{Name: "FOO", Value: "new", MergeStrategy: "error"},
},
expectErr: "duplicate env var",
},
{
name: "multiple_appends_same_key",
initial: map[string]string{},
envs: []*sdkproto.Env{
{Name: "PATH", Value: "/a/bin", MergeStrategy: "append"},
{Name: "PATH", Value: "/b/bin", MergeStrategy: "append"},
},
expected: map[string]string{"PATH": "/a/bin:/b/bin"},
},
{
name: "multiple_prepends_same_key",
initial: map[string]string{},
envs: []*sdkproto.Env{
{Name: "PATH", Value: "/a/bin", MergeStrategy: "prepend"},
{Name: "PATH", Value: "/b/bin", MergeStrategy: "prepend"},
},
expected: map[string]string{"PATH": "/b/bin:/a/bin"},
},
{
name: "mixed_strategies",
initial: map[string]string{},
envs: []*sdkproto.Env{
{Name: "PATH", Value: "/first", MergeStrategy: "append"},
{Name: "PATH", Value: "/override", MergeStrategy: "replace"},
},
expected: map[string]string{"PATH": "/override"},
},
{
name: "mixed_keys",
initial: map[string]string{},
envs: []*sdkproto.Env{
{Name: "PATH", Value: "/a", MergeStrategy: "append"},
{Name: "HOME", Value: "/home/user"},
{Name: "PATH", Value: "/b", MergeStrategy: "append"},
},
expected: map[string]string{
"PATH": "/a:/b",
"HOME": "/home/user",
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
env := make(map[string]string)
for k, v := range tc.initial {
env[k] = v
}
err := provisionerdserver.MergeExtraEnvs(env, tc.envs)
if tc.expectErr != "" {
require.ErrorContains(t, err, tc.expectErr)
return
}
require.NoError(t, err)
require.Equal(t, tc.expected, env)
})
}
}