Files
coder/provisioner/terraform/resource_replacements.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

87 lines
2.7 KiB
Go

package terraform
import (
"fmt"
"strings"
tfjson "github.com/hashicorp/terraform-json"
)
type resourceReplacements map[string][]string
// resourceReplacements finds all resources which would be replaced by the current plan, and the attribute paths which
// caused the replacement.
//
// NOTE: "replacement" in terraform terms means that a resource will have to be destroyed and replaced with a new resource
// since one of its immutable attributes was modified, which cannot be updated in-place.
func findResourceReplacements(plan *tfjson.Plan) resourceReplacements {
if plan == nil {
return nil
}
// No changes, no problem!
if len(plan.ResourceChanges) == 0 {
return nil
}
replacements := make(resourceReplacements, len(plan.ResourceChanges))
for _, ch := range plan.ResourceChanges {
// No change, no problem!
if ch.Change == nil {
continue
}
// No-op change, no problem!
if ch.Change.Actions.NoOp() {
continue
}
// No replacements, no problem!
if len(ch.Change.ReplacePaths) == 0 {
continue
}
// Replacing our resources: could be a problem - but we ignore since they're "virtual" resources. If any of these
// resources' attributes are referenced by non-coder resources, those will show up as transitive changes there.
// i.e. if the coder_agent.id attribute is used in docker_container.env
//
// Replacing our resources is not strictly a problem in and of itself.
//
// NOTE:
// We may need to special-case coder_agent in the future. Currently, coder_agent is replaced on every build
// because it only supports Create but not Update: https://github.com/coder/terraform-provider-coder/blob/5648efb/provider/agent.go#L28
// When we can modify an agent's attributes, some of which may be immutable (like "arch") and some may not (like "env"),
// then we'll have to handle this specifically.
// This will only become relevant once we support multiple agents: https://github.com/coder/coder/issues/17388
if strings.Index(ch.Type, "coder_") == 0 {
continue
}
// Replacements found, problem!
for _, val := range ch.Change.ReplacePaths {
var pathStr string
// Each path needs to be coerced into a string. All types except []interface{} can be coerced using fmt.Sprintf.
switch path := val.(type) {
case []interface{}:
// Found a slice of paths; coerce to string and join by ".".
segments := make([]string, 0, len(path))
for _, seg := range path {
segments = append(segments, fmt.Sprintf("%v", seg))
}
pathStr = strings.Join(segments, ".")
default:
pathStr = fmt.Sprintf("%v", path)
}
replacements[ch.Address] = append(replacements[ch.Address], pathStr)
}
}
if len(replacements) == 0 {
return nil
}
return replacements
}