Files
coder/provisioner/terraform/resource_replacements_internal_test.go
Danny Kopping 6e967780c9 feat: track resource replacements when claiming a prebuilt workspace (#17571)
Closes https://github.com/coder/internal/issues/369

We can't know whether a replacement (i.e. drift of terraform state
leading to a resource needing to be deleted/recreated) will take place
apriori; we can only detect it at `plan` time, because the provider
decides whether a resource must be replaced and it cannot be inferred
through static analysis of the template.

**This is likely to be the most common gotcha with using prebuilds,
since it requires a slight template modification to use prebuilds
effectively**, so let's head this off before it's an issue for
customers.

Drift details will now be logged in the workspace build logs:


![image](https://github.com/user-attachments/assets/da1988b6-2cbe-4a79-a3c5-ea29891f3d6f)

Plus a notification will be sent to template admins when this situation
arises:


![image](https://github.com/user-attachments/assets/39d555b1-a262-4a3e-b529-03b9f23bf66a)

A new metric - `coderd_prebuilt_workspaces_resource_replacements_total`
- will also increment each time a workspace encounters replacements.

We only track _that_ a resource replacement occurred, not how many. Just
one is enough to ruin a prebuild, but we can't know apriori which
replacement would cause this.
For example, say we have 2 replacements: a `docker_container` and a
`null_resource`; we don't know which one might
cause an issue (or indeed if either would), so we just track the
replacement.

---------

Signed-off-by: Danny Kopping <dannykopping@gmail.com>
2025-05-14 14:52:22 +02:00

177 lines
3.9 KiB
Go

package terraform
import (
"testing"
tfjson "github.com/hashicorp/terraform-json"
"github.com/stretchr/testify/require"
)
func TestFindResourceReplacements(t *testing.T) {
t.Parallel()
cases := []struct {
name string
plan *tfjson.Plan
expected resourceReplacements
}{
{
name: "nil plan",
},
{
name: "no resource changes",
plan: &tfjson.Plan{},
},
{
name: "resource change with nil change",
plan: &tfjson.Plan{
ResourceChanges: []*tfjson.ResourceChange{
{
Address: "resource1",
},
},
},
},
{
name: "no-op action",
plan: &tfjson.Plan{
ResourceChanges: []*tfjson.ResourceChange{
{
Address: "resource1",
Change: &tfjson.Change{
Actions: tfjson.Actions{tfjson.ActionNoop},
},
},
},
},
},
{
name: "empty replace paths",
plan: &tfjson.Plan{
ResourceChanges: []*tfjson.ResourceChange{
{
Address: "resource1",
Change: &tfjson.Change{
Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate},
},
},
},
},
},
{
name: "coder_* types are ignored",
plan: &tfjson.Plan{
ResourceChanges: []*tfjson.ResourceChange{
{
Address: "resource1",
Type: "coder_resource",
Change: &tfjson.Change{
Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate},
ReplacePaths: []interface{}{"path1"},
},
},
},
},
},
{
name: "valid replacements - single path",
plan: &tfjson.Plan{
ResourceChanges: []*tfjson.ResourceChange{
{
Address: "resource1",
Type: "example_resource",
Change: &tfjson.Change{
Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate},
ReplacePaths: []interface{}{"path1"},
},
},
},
},
expected: resourceReplacements{
"resource1": {"path1"},
},
},
{
name: "valid replacements - multiple paths",
plan: &tfjson.Plan{
ResourceChanges: []*tfjson.ResourceChange{
{
Address: "resource1",
Type: "example_resource",
Change: &tfjson.Change{
Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate},
ReplacePaths: []interface{}{"path1", "path2"},
},
},
},
},
expected: resourceReplacements{
"resource1": {"path1", "path2"},
},
},
{
name: "complex replace path",
plan: &tfjson.Plan{
ResourceChanges: []*tfjson.ResourceChange{
{
Address: "resource1",
Type: "example_resource",
Change: &tfjson.Change{
Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate},
ReplacePaths: []interface{}{
[]interface{}{"path", "to", "key"},
},
},
},
},
},
expected: resourceReplacements{
"resource1": {"path.to.key"},
},
},
{
name: "multiple changes",
plan: &tfjson.Plan{
ResourceChanges: []*tfjson.ResourceChange{
{
Address: "resource1",
Type: "example_resource",
Change: &tfjson.Change{
Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate},
ReplacePaths: []interface{}{"path1"},
},
},
{
Address: "resource2",
Type: "example_resource",
Change: &tfjson.Change{
Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate},
ReplacePaths: []interface{}{"path2", "path3"},
},
},
{
Address: "resource3",
Type: "coder_example",
Change: &tfjson.Change{
Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate},
ReplacePaths: []interface{}{"ignored_path"},
},
},
},
},
expected: resourceReplacements{
"resource1": {"path1"},
"resource2": {"path2", "path3"},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
require.EqualValues(t, tc.expected, findResourceReplacements(tc.plan))
})
}
}