mirror of
https://github.com/opentofu/opentofu.git
synced 2025-02-25 18:45:20 -06:00
configs+tofu: resource prevent_destroy can be dynamic
Previously we always evaluated prevent_destroy for a managed resource at config load time, requiring it to be static. However, we don't actually need the value until runtime so we can instead defer evaluation of the expression and populate it with a dynamic expression result, allowing modules to populate it based on input variables and other external data. Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
This commit is contained in:
parent
27ab52fd03
commit
ac59337bab
@ -248,9 +248,8 @@ func (r *Resource) merge(or *Resource, rps map[string]*RequiredProvider) hcl.Dia
|
||||
if or.Managed.IgnoreAllChanges {
|
||||
r.Managed.IgnoreAllChanges = true
|
||||
}
|
||||
if or.Managed.PreventDestroySet {
|
||||
if or.Managed.PreventDestroy != nil {
|
||||
r.Managed.PreventDestroy = or.Managed.PreventDestroy
|
||||
r.Managed.PreventDestroySet = or.Managed.PreventDestroySet
|
||||
}
|
||||
if len(or.Managed.Provisioners) != 0 {
|
||||
r.Managed.Provisioners = or.Managed.Provisioners
|
||||
|
@ -69,12 +69,11 @@ type ManagedResource struct {
|
||||
Provisioners []*Provisioner
|
||||
|
||||
CreateBeforeDestroy bool
|
||||
PreventDestroy bool
|
||||
PreventDestroy hcl.Expression
|
||||
IgnoreChanges []hcl.Traversal
|
||||
IgnoreAllChanges bool
|
||||
|
||||
CreateBeforeDestroySet bool
|
||||
PreventDestroySet bool
|
||||
}
|
||||
|
||||
func (r *Resource) moduleUniqueKey() string {
|
||||
@ -205,9 +204,7 @@ func decodeResourceBlock(block *hcl.Block, override bool) (*Resource, hcl.Diagno
|
||||
}
|
||||
|
||||
if attr, exists := lcContent.Attributes["prevent_destroy"]; exists {
|
||||
valDiags := gohcl.DecodeExpression(attr.Expr, nil, &r.Managed.PreventDestroy)
|
||||
diags = append(diags, valDiags...)
|
||||
r.Managed.PreventDestroySet = true
|
||||
r.Managed.PreventDestroy = attr.Expr
|
||||
}
|
||||
|
||||
if attr, exists := lcContent.Attributes["replace_triggered_by"]; exists {
|
||||
|
@ -1442,6 +1442,93 @@ func TestContext2Plan_preventDestroy_good(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext2Plan_preventDestroy_dynamic(t *testing.T) {
|
||||
m := testModule(t, "plan-prevent-destroy-dynamic")
|
||||
p := &MockProvider{}
|
||||
p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{
|
||||
ResourceTypes: map[string]providers.Schema{
|
||||
"test": {
|
||||
Block: &configschema.Block{},
|
||||
},
|
||||
},
|
||||
}
|
||||
p.PlanResourceChangeFn = func(prcr providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse {
|
||||
return providers.PlanResourceChangeResponse{
|
||||
PlannedState: prcr.ProposedNewState,
|
||||
}
|
||||
}
|
||||
p.ApplyResourceChangeFn = func(arcr providers.ApplyResourceChangeRequest) providers.ApplyResourceChangeResponse {
|
||||
return providers.ApplyResourceChangeResponse{
|
||||
NewState: arcr.PlannedState,
|
||||
}
|
||||
}
|
||||
providerAddr := addrs.NewBuiltInProvider("test")
|
||||
resourceInstAddr := mustResourceInstanceAddr("test.test[0]")
|
||||
|
||||
// Our prior state has test.test[0], but the test fixture configuration
|
||||
// uses count = 0 so this instance is not in the desired state and so
|
||||
// should be planned for deletion.
|
||||
state := states.NewState()
|
||||
root := state.EnsureModule(addrs.RootModuleInstance)
|
||||
root.SetResourceInstanceCurrent(
|
||||
resourceInstAddr.Resource,
|
||||
&states.ResourceInstanceObjectSrc{
|
||||
Status: states.ObjectReady,
|
||||
AttrsJSON: []byte(`{}`),
|
||||
},
|
||||
addrs.AbsProviderConfig{
|
||||
Provider: providerAddr,
|
||||
},
|
||||
addrs.NoKey,
|
||||
)
|
||||
|
||||
ctx := testContext2(t, &ContextOpts{
|
||||
Providers: map[addrs.Provider]providers.Factory{
|
||||
providerAddr: testProviderFuncFixed(p),
|
||||
},
|
||||
})
|
||||
|
||||
{
|
||||
t.Log("Plan 1: prevent_destroy = true, so should fail")
|
||||
|
||||
_, diags := ctx.Plan(context.Background(), m, state, &PlanOpts{
|
||||
SetVariables: InputValues{
|
||||
"prevent_destroy": {
|
||||
Value: cty.True,
|
||||
},
|
||||
},
|
||||
})
|
||||
if !diags.HasErrors() {
|
||||
t.Fatal("unexpected success; want error about prevent_destroy")
|
||||
}
|
||||
got := diags.Err().Error()
|
||||
wantSubstring := "The resource instance test.test[0] has lifecycle.prevent_destroy set, but the plan calls for this resource to be destroyed."
|
||||
if !strings.Contains(got, wantSubstring) {
|
||||
t.Fatalf("missing expected error\ngot: %s\nwant substring: %s", got, wantSubstring)
|
||||
}
|
||||
}
|
||||
{
|
||||
t.Log("Plan 2: prevent_destroy = false, so should succeed")
|
||||
|
||||
plan, diags := ctx.Plan(context.Background(), m, state, &PlanOpts{
|
||||
SetVariables: InputValues{
|
||||
"prevent_destroy": {
|
||||
Value: cty.False,
|
||||
},
|
||||
},
|
||||
})
|
||||
assertNoErrors(t, diags)
|
||||
|
||||
change := plan.Changes.ResourceInstance(resourceInstAddr)
|
||||
if change == nil {
|
||||
t.Fatalf("no planned change for %s", resourceInstAddr)
|
||||
}
|
||||
if got, want := change.Action, plans.Delete; got != want {
|
||||
t.Errorf("wrong planned action for %s\ngot: %s\nwant: %s", resourceInstAddr, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContext2Plan_preventDestroy_countBad(t *testing.T) {
|
||||
m := testModule(t, "plan-prevent-destroy-count-bad")
|
||||
p := testProvider("aws")
|
||||
|
@ -19,6 +19,8 @@ import (
|
||||
"github.com/opentofu/opentofu/internal/configs/configschema"
|
||||
"github.com/opentofu/opentofu/internal/encryption"
|
||||
"github.com/opentofu/opentofu/internal/instances"
|
||||
"github.com/opentofu/opentofu/internal/lang/evalchecks"
|
||||
"github.com/opentofu/opentofu/internal/lang/marks"
|
||||
"github.com/opentofu/opentofu/internal/plans"
|
||||
"github.com/opentofu/opentofu/internal/plans/objchange"
|
||||
"github.com/opentofu/opentofu/internal/providers"
|
||||
@ -285,28 +287,90 @@ func (n *NodeAbstractResourceInstance) readDiff(ctx EvalContext, providerSchema
|
||||
return change, nil
|
||||
}
|
||||
|
||||
func (n *NodeAbstractResourceInstance) checkPreventDestroy(change *plans.ResourceInstanceChange) error {
|
||||
if change == nil || n.Config == nil || n.Config.Managed == nil {
|
||||
func (n *NodeAbstractResourceInstance) checkPreventDestroy(evalCtx EvalContext, change *plans.ResourceInstanceChange) tfdiags.Diagnostics {
|
||||
if change == nil || n.Config == nil || n.Config.Managed == nil || n.Config.Managed.PreventDestroy == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
preventDestroy := n.Config.Managed.PreventDestroy
|
||||
var diags tfdiags.Diagnostics
|
||||
|
||||
if (change.Action == plans.Delete || change.Action.IsReplace()) && preventDestroy {
|
||||
var diags tfdiags.Diagnostics
|
||||
// We intentionally don't support referring to repetition symbols like
|
||||
// each.key/etc here because we must be able to evaluate prevent_destroy
|
||||
// for a resource instance that has already been removed from the
|
||||
// desired state (and is therefore being proposed to destroy) and
|
||||
// there would be no instance-related data to use in that acse.
|
||||
scope := evalCtx.EvaluationScope(nil, nil, EvalDataForNoInstanceKey)
|
||||
|
||||
preventDestroyExpr := n.Config.Managed.PreventDestroy
|
||||
preventDestroyVal, evalDiags := scope.EvalExpr(preventDestroyExpr, cty.Bool)
|
||||
diags = diags.Append(evalDiags)
|
||||
if evalDiags.HasErrors() {
|
||||
return diags
|
||||
}
|
||||
if preventDestroyVal.IsNull() {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Instance cannot be destroyed",
|
||||
Detail: fmt.Sprintf(
|
||||
"Resource %s has lifecycle.prevent_destroy set, but the plan calls for this resource to be destroyed. To avoid this error and continue with the plan, either disable lifecycle.prevent_destroy or reduce the scope of the plan using the -target flag.",
|
||||
n.Addr.String(),
|
||||
),
|
||||
Subject: &n.Config.DeclRange,
|
||||
Summary: "Invalid prevent_destroy value",
|
||||
Detail: "Unsuitable value for prevent_destroy argument: must not be null.",
|
||||
Subject: preventDestroyExpr.StartRange().Ptr(),
|
||||
})
|
||||
return diags.Err()
|
||||
}
|
||||
if preventDestroyVal.HasMark(marks.Sensitive) {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid prevent_destroy value",
|
||||
Detail: "Unsuitable value for prevent_destroy argument: derived from a sensitive value, so the prevent_destroy outcome could disclose the sensitive result.",
|
||||
Subject: preventDestroyExpr.StartRange().Ptr(),
|
||||
Extra: evalchecks.DiagnosticCausedBySensitive(true),
|
||||
})
|
||||
}
|
||||
if diags.HasErrors() {
|
||||
return diags
|
||||
}
|
||||
|
||||
return nil
|
||||
// We'll discard any other marks, since we don't know what they mean and so
|
||||
// can't report an error describing them.
|
||||
// FIXME: Should this return a generic "this is marked in a way we don't support"
|
||||
// error so that we fail closed rather than open?
|
||||
preventDestroyVal, _ = preventDestroyVal.Unmark()
|
||||
|
||||
if change.Action == plans.Delete || change.Action.IsReplace() {
|
||||
// The remaining checks apply only if we're actually planning to delete/replace this object.
|
||||
if !preventDestroyVal.IsKnown() {
|
||||
// TODO: Should this actually be a warning, so the operator can decide to proceed if they
|
||||
// know that prevent_destroy will turn out to be false? Perhaps we could re-check
|
||||
// during the apply phase and fail then if the final known value is true, at the
|
||||
// expense of potentially failing partway through the overall apply operation.
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Invalid prevent_destroy value",
|
||||
Detail: fmt.Sprintf(
|
||||
"The resource instance %s is planned for deletion but its prevent_destroy argument is derived from a value that won't be decided until the apply phase, so OpenTofu can't determine whether this action is acceptable.",
|
||||
n.Addr.String(),
|
||||
),
|
||||
Subject: preventDestroyExpr.StartRange().Ptr(),
|
||||
Extra: evalchecks.DiagnosticCausedByUnknown(true),
|
||||
})
|
||||
return diags
|
||||
}
|
||||
|
||||
// In the above checks we've eliminated the possibilities that
|
||||
// preventDestroyVal is null, unknown, or marked, and so we
|
||||
// are now safe to ask for its actual boolean value.
|
||||
if preventDestroyVal.True() {
|
||||
diags = diags.Append(&hcl.Diagnostic{
|
||||
Severity: hcl.DiagError,
|
||||
Summary: "Instance cannot be destroyed",
|
||||
Detail: fmt.Sprintf(
|
||||
"The resource instance %s has lifecycle.prevent_destroy set, but the plan calls for this resource to be destroyed. To avoid this error and continue with the plan, either disable lifecycle.prevent_destroy or reduce the scope of the plan using the -target option.",
|
||||
n.Addr.String(),
|
||||
),
|
||||
Subject: preventDestroyExpr.StartRange().Ptr(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return diags
|
||||
}
|
||||
|
||||
// preApplyHook calls the pre-Apply hook
|
||||
|
@ -108,7 +108,7 @@ func (n *NodePlanDestroyableResourceInstance) managedResourceExecute(ctx EvalCon
|
||||
return diags
|
||||
}
|
||||
|
||||
diags = diags.Append(n.checkPreventDestroy(change))
|
||||
diags = diags.Append(n.checkPreventDestroy(ctx, change))
|
||||
return diags
|
||||
}
|
||||
|
||||
|
@ -343,7 +343,7 @@ func (n *NodePlannableResourceInstance) managedResourceExecute(ctx EvalContext)
|
||||
if diags.HasErrors() {
|
||||
return diags
|
||||
}
|
||||
diags = diags.Append(n.checkPreventDestroy(change))
|
||||
diags = diags.Append(n.checkPreventDestroy(ctx, change))
|
||||
if diags.HasErrors() {
|
||||
return diags
|
||||
}
|
||||
|
@ -176,7 +176,7 @@ func (n *NodePlannableResourceInstanceOrphan) managedResourceExecute(ctx EvalCon
|
||||
return diags
|
||||
}
|
||||
|
||||
diags = diags.Append(n.checkPreventDestroy(change))
|
||||
diags = diags.Append(n.checkPreventDestroy(ctx, change))
|
||||
if diags.HasErrors() {
|
||||
return diags
|
||||
}
|
||||
|
19
internal/tofu/testdata/plan-prevent-destroy-dynamic/plan-prevent-destroy-dynamic.tf
vendored
Normal file
19
internal/tofu/testdata/plan-prevent-destroy-dynamic/plan-prevent-destroy-dynamic.tf
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
test = {
|
||||
source = "terraform.io/builtin/test"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "prevent_destroy" {
|
||||
type = bool
|
||||
}
|
||||
|
||||
resource "test" "test" {
|
||||
count = 0
|
||||
|
||||
lifecycle {
|
||||
prevent_destroy = var.prevent_destroy
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user