Add ActionReason to ResourceChange Terraform emits an action_reason on each resource change in the JSON plan format, explaining why the planned actions were chosen — most notably "replace_because_tainted" when a resource is being replaced because its prior object was tainted. The library did not expose this field, so consumers had no way to distinguish, for example, a tainted replacement from any other replacement. This adds a typed ActionReason field (with constants for the documented values) and a test fixture covering a tainted replacement.
diff --git a/plan.go b/plan.go index 8875200..3b036c1 100644 --- a/plan.go +++ b/plan.go
@@ -208,8 +208,81 @@ // The data describing the change that will be made to this object. Change *Change `json:"change,omitempty"` + + // ActionReason is an optional keyword providing extra context for why the + // actions in Change.Actions were chosen, for example that a resource must + // be replaced because its existing object was tainted. It is empty when + // Terraform did not report a reason. This detail is for display purposes + // only and should not be used to make decisions. + ActionReason ActionReason `json:"action_reason,omitempty"` } +// ActionReason is a keyword representing the optional reason Terraform reports +// for the actions proposed in a ResourceChange. The set of possible values may +// grow in future Terraform versions, so consumers should treat unrecognized +// values as equivalent to ActionReasonNone. +type ActionReason string + +const ( + // ActionReasonNone is the absence of any specific reason; Terraform omits + // the action_reason property in this case. + ActionReasonNone ActionReason = "" + + // ActionReasonReplaceBecauseCannotUpdate indicates a resource must be + // replaced because the requested change is not possible without doing so. + ActionReasonReplaceBecauseCannotUpdate ActionReason = "replace_because_cannot_update" + + // ActionReasonReplaceBecauseTainted indicates a resource must be replaced + // because its existing object was marked as tainted. + ActionReasonReplaceBecauseTainted ActionReason = "replace_because_tainted" + + // ActionReasonReplaceByRequest indicates a resource must be replaced because + // the user explicitly requested it (e.g. "terraform plan -replace=..."). + ActionReasonReplaceByRequest ActionReason = "replace_by_request" + + // ActionReasonReplaceByTriggers indicates a resource must be replaced + // because of a change in a value referenced by its replace_triggered_by. + ActionReasonReplaceByTriggers ActionReason = "replace_by_triggers" + + // ActionReasonDeleteBecauseNoResourceConfig indicates a resource will be + // destroyed because it has no matching configuration block. + ActionReasonDeleteBecauseNoResourceConfig ActionReason = "delete_because_no_resource_config" + + // ActionReasonDeleteBecauseWrongRepetition indicates a resource instance + // will be destroyed because its instance key does not match the resource's + // count/for_each repetition mode. + ActionReasonDeleteBecauseWrongRepetition ActionReason = "delete_because_wrong_repetition" + + // ActionReasonDeleteBecauseCountIndex indicates a resource instance will be + // destroyed because its index is out of range for the count value. + ActionReasonDeleteBecauseCountIndex ActionReason = "delete_because_count_index" + + // ActionReasonDeleteBecauseEachKey indicates a resource instance will be + // destroyed because its key is not present in the for_each value. + ActionReasonDeleteBecauseEachKey ActionReason = "delete_because_each_key" + + // ActionReasonDeleteBecauseNoModule indicates a resource instance will be + // destroyed because its containing module instance is no longer declared. + ActionReasonDeleteBecauseNoModule ActionReason = "delete_because_no_module" + + // ActionReasonDeleteBecauseNoMoveTarget indicates a resource was moved to an + // address that has no configuration, so it will be destroyed. + ActionReasonDeleteBecauseNoMoveTarget ActionReason = "delete_because_no_move_target" + + // ActionReasonReadBecauseConfigUnknown indicates a data resource will be + // read during apply because its configuration contains unknown values. + ActionReasonReadBecauseConfigUnknown ActionReason = "read_because_config_unknown" + + // ActionReasonReadBecauseDependencyPending indicates a data resource will be + // read during apply because it depends on a resource or module with changes + // pending. + ActionReasonReadBecauseDependencyPending ActionReason = "read_because_dependency_pending" + + // ActionReasonReadBecauseCheckNested indicates a data resource nested within + // a check block will be read during apply. + ActionReasonReadBecauseCheckNested ActionReason = "read_because_check_nested" +) + // Change is the representation of a proposed change for an object. type Change struct { // The action to be carried out by this change.
diff --git a/plan_test.go b/plan_test.go index 2235678..e38b6d6 100644 --- a/plan_test.go +++ b/plan_test.go
@@ -137,6 +137,27 @@ } } +func TestPlan_actionReason(t *testing.T) { + f, err := os.Open("testdata/action_reason/plan.json") + if err != nil { + t.Fatal(err) + } + defer f.Close() + + var plan *Plan + if err := json.NewDecoder(f).Decode(&plan); err != nil { + t.Fatal(err) + } + + if err := plan.Validate(); err != nil { + t.Fatal(err) + } + + if got, want := plan.ResourceChanges[0].ActionReason, ActionReasonReplaceBecauseTainted; got != want { + t.Fatalf("unexpected action reason: got %q, want %q", got, want) + } +} + func TestPlan_actionInvocations(t *testing.T) { f, err := os.Open("testdata/actions/plan.json") if err != nil {
diff --git a/testdata/action_reason/plan.json b/testdata/action_reason/plan.json new file mode 100644 index 0000000..1b1ad14 --- /dev/null +++ b/testdata/action_reason/plan.json
@@ -0,0 +1 @@ +{"format_version":"1.2","terraform_version":"1.15.0","planned_values":{"root_module":{"resources":[{"address":"null_resource.example","mode":"managed","type":"null_resource","name":"example","provider_name":"registry.terraform.io/hashicorp/null","schema_version":0,"values":{"triggers":{"name":"demo"}},"sensitive_values":{"triggers":{}}}]}},"resource_changes":[{"address":"null_resource.example","mode":"managed","type":"null_resource","name":"example","provider_name":"registry.terraform.io/hashicorp/null","change":{"actions":["delete","create"],"before":{"id":"8868159889619753631","triggers":{"name":"demo"}},"after":{"triggers":{"name":"demo"}},"after_unknown":{"id":true,"triggers":{}},"before_sensitive":{"triggers":{}},"after_sensitive":{"triggers":{}}},"action_reason":"replace_because_tainted"}],"complete":true,"prior_state":{"format_version":"1.0","terraform_version":"1.15.0","values":{"root_module":{"resources":[{"address":"null_resource.example","mode":"managed","type":"null_resource","name":"example","provider_name":"registry.terraform.io/hashicorp/null","schema_version":0,"values":{"id":"8868159889619753631","triggers":{"name":"demo"}},"sensitive_values":{"triggers":{}},"tainted":true}]}}},"configuration":{"provider_config":{"null":{"name":"null","full_name":"registry.terraform.io/hashicorp/null","version_constraint":"~\u003e 3.0"}},"root_module":{"resources":[{"address":"null_resource.example","mode":"managed","type":"null_resource","name":"example","provider_config_key":"null","expressions":{"triggers":{"constant_value":{"name":"demo"}}},"schema_version":0}]}},"timestamp":"2026-06-16T10:21:47Z"}