Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Backport of Apply optimizations for handling of condition checks into v1.3 #32134

Merged
9 changes: 6 additions & 3 deletions internal/states/statefile/version4.go
Expand Up @@ -414,9 +414,7 @@ func writeStateV4(file *File, w io.Writer) tfdiags.Diagnostics {
}
}

if file.State.CheckResults != nil {
sV4.CheckResults = encodeCheckResultsV4(file.State.CheckResults)
}
sV4.CheckResults = encodeCheckResultsV4(file.State.CheckResults)

sV4.normalize()

Expand Down Expand Up @@ -576,6 +574,11 @@ func decodeCheckResultsV4(in []checkResultsV4) (*states.CheckResults, tfdiags.Di
}

func encodeCheckResultsV4(in *states.CheckResults) []checkResultsV4 {
// normalize empty and nil sets in the serialized state
if in == nil || in.ConfigResults.Len() == 0 {
return nil
}

ret := make([]checkResultsV4, 0, in.ConfigResults.Len())

for _, configElem := range in.ConfigResults.Elems {
Expand Down
16 changes: 16 additions & 0 deletions internal/terraform/context_apply.go
Expand Up @@ -25,6 +25,10 @@ func (c *Context) Apply(plan *plans.Plan, config *configs.Config) (*states.State

log.Printf("[DEBUG] Building and walking apply graph for %s plan", plan.UIMode)

// FIXME: refresh plans still store outputs as changes, so we can't use
// Empty()
possibleRefresh := len(plan.Changes.Resources) == 0

graph, operation, diags := c.applyGraph(plan, config, true)
if diags.HasErrors() {
return nil, diags
Expand Down Expand Up @@ -69,6 +73,18 @@ Note that the -target option is not suitable for routine use, and is provided on
))
}

// FIXME: we cannot check for an empty plan for refresh-only, because root
// outputs are always stored as changes. The final condition of the state
// also depends on some cleanup which happens during the apply walk. It
// would probably make more sense if applying a refresh-only plan were
// simply just returning the planned state and checks, but some extra
// cleanup is going to be needed to make the plan state match what apply
// would do. For now we can copy the checks over which were overwritten
// during the apply walk.
if possibleRefresh {
newState.CheckResults = plan.Checks.DeepCopy()
}

return newState, diags
}

Expand Down
143 changes: 143 additions & 0 deletions internal/terraform/context_apply2_test.go
Expand Up @@ -1641,3 +1641,146 @@ output "from_resource" {
_, diags = ctx.Apply(plan, m)
assertNoErrors(t, diags)
}

// -refresh-only should update checks
func TestContext2Apply_refreshApplyUpdatesChecks(t *testing.T) {
m := testModuleInline(t, map[string]string{
"main.tf": `
resource "test_object" "x" {
test_string = "ok"
lifecycle {
postcondition {
condition = self.test_string == "ok"
error_message = "wrong val"
}
}
}

output "from_resource" {
value = test_object.x.test_string
precondition {
condition = test_object.x.test_string == "ok"
error_message = "wrong val"
}
}
`})

p := simpleMockProvider()
p.ReadResourceResponse = &providers.ReadResourceResponse{
NewState: cty.ObjectVal(map[string]cty.Value{
"test_string": cty.StringVal("ok"),
}),
}

state := states.NewState()
mod := state.EnsureModule(addrs.RootModuleInstance)
mod.SetResourceInstanceCurrent(
mustResourceInstanceAddr("test_object.x").Resource,
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
AttrsJSON: []byte(`{"test_string":"wrong val"}`),
},
mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`),
)
mod.SetOutputValue("from_resource", cty.StringVal("wrong val"), false)

ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
})

opts := SimplePlanOpts(plans.RefreshOnlyMode, nil)
plan, diags := ctx.Plan(m, state, opts)
assertNoErrors(t, diags)

state, diags = ctx.Apply(plan, m)
assertNoErrors(t, diags)

resCheck := state.CheckResults.GetObjectResult(mustResourceInstanceAddr("test_object.x"))
if resCheck.Status != checks.StatusPass {
t.Fatalf("unexpected check %s: %s\n", resCheck.Status, resCheck.FailureMessages)
}

outAddr := addrs.AbsOutputValue{
Module: addrs.RootModuleInstance,
OutputValue: addrs.OutputValue{
Name: "from_resource",
},
}
outCheck := state.CheckResults.GetObjectResult(outAddr)
if outCheck.Status != checks.StatusPass {
t.Fatalf("unexpected check %s: %s\n", outCheck.Status, outCheck.FailureMessages)
}
}

// NoOp changes may have conditions to evaluate, but should not re-plan and
// apply the entire resource.
func TestContext2Apply_noRePlanNoOp(t *testing.T) {
m := testModuleInline(t, map[string]string{
"main.tf": `
resource "test_object" "x" {
}

resource "test_object" "y" {
# test_object.w is being re-created, so this precondition must be evaluated
# during apply, however this resource should otherwise be a NoOp.
lifecycle {
precondition {
condition = test_object.x.test_string == null
error_message = "test_object.x.test_string should be null"
}
}
}
`})

p := simpleMockProvider()
// make sure we can compute the attr
testString := p.GetProviderSchemaResponse.ResourceTypes["test_object"].Block.Attributes["test_string"]
testString.Computed = true
testString.Optional = false

yAddr := mustResourceInstanceAddr("test_object.y")

state := states.NewState()
mod := state.RootModule()
mod.SetResourceInstanceCurrent(
yAddr.Resource,
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
AttrsJSON: []byte(`{"test_string":"y"}`),
},
mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`),
)

ctx := testContext2(t, &ContextOpts{
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
})

opts := SimplePlanOpts(plans.NormalMode, nil)
plan, diags := ctx.Plan(m, state, opts)
assertNoErrors(t, diags)

for _, c := range plan.Changes.Resources {
if c.Addr.Equal(yAddr) && c.Action != plans.NoOp {
t.Fatalf("unexpected %s change for test_object.y", c.Action)
}
}

// test_object.y is a NoOp change from the plan, but is included in the
// graph due to the conditions which must be evaluated. This however should
// not cause the resource to be re-planned.
p.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) (resp providers.PlanResourceChangeResponse) {
testString := req.ProposedNewState.GetAttr("test_string")
if !testString.IsNull() && testString.AsString() == "y" {
resp.Diagnostics = resp.Diagnostics.Append(errors.New("Unexpected apply-time plan for test_object.y. Original plan was a NoOp"))
}
resp.PlannedState = req.ProposedNewState
return resp
}

_, diags = ctx.Apply(plan, m)
assertNoErrors(t, diags)
}
1 change: 1 addition & 0 deletions internal/terraform/graph_builder_apply.go
Expand Up @@ -107,6 +107,7 @@ func (b *ApplyGraphBuilder) Steps() []GraphTransformer {
Concrete: concreteResourceInstance,
State: b.State,
Changes: b.Changes,
Config: b.Config,
},

// Attach the state
Expand Down
45 changes: 23 additions & 22 deletions internal/terraform/node_resource_abstract_instance.go
Expand Up @@ -715,6 +715,13 @@ func (n *NodeAbstractResourceInstance) plan(
return plan, state, keyData, diags // failed preconditions prevent further evaluation
}

// If we have a previous plan and the action was a noop, then the only
// reason we're in this method was to evaluate the preconditions. There's
// no need to re-plan this resource.
if plannedChange != nil && plannedChange.Action == plans.NoOp {
return plannedChange, currentState.DeepCopy(), keyData, diags
}

origConfigVal, _, configDiags := ctx.EvaluateBlock(config.Config, schema, nil, keyData)
diags = diags.Append(configDiags)
if configDiags.HasErrors() {
Expand Down Expand Up @@ -2059,44 +2066,38 @@ func (n *NodeAbstractResourceInstance) evalDestroyProvisionerConfig(ctx EvalCont
}

// apply accepts an applyConfig, instead of using n.Config, so destroy plans can
// send a nil config. Most of the errors generated in apply are returned as
// diagnostics, but if provider.ApplyResourceChange itself fails, that error is
// returned as an error and nil diags are returned.
// send a nil config. The keyData information can be empty if the config is
// nil, since it is only used to evaluate the configuration.
func (n *NodeAbstractResourceInstance) apply(
ctx EvalContext,
state *states.ResourceInstanceObject,
change *plans.ResourceInstanceChange,
applyConfig *configs.Resource,
createBeforeDestroy bool) (*states.ResourceInstanceObject, instances.RepetitionData, tfdiags.Diagnostics) {
keyData instances.RepetitionData,
createBeforeDestroy bool) (*states.ResourceInstanceObject, tfdiags.Diagnostics) {

var diags tfdiags.Diagnostics
var keyData instances.RepetitionData
if state == nil {
state = &states.ResourceInstanceObject{}
}

if applyConfig != nil {
forEach, _ := evaluateForEachExpression(applyConfig.ForEach, ctx)
keyData = EvalDataForInstanceKey(n.ResourceInstanceAddr().Resource.Key, forEach)
}

if change.Action == plans.NoOp {
// If this is a no-op change then we don't want to actually change
// anything, so we'll just echo back the state we were given and
// let our internal checks and updates proceed.
log.Printf("[TRACE] NodeAbstractResourceInstance.apply: skipping %s because it has no planned action", n.Addr)
return state, keyData, diags
return state, diags
}

provider, providerSchema, err := getProvider(ctx, n.ResolvedProvider)
if err != nil {
return nil, keyData, diags.Append(err)
return nil, diags.Append(err)
}
schema, _ := providerSchema.SchemaForResourceType(n.Addr.Resource.Resource.Mode, n.Addr.Resource.Resource.Type)
if schema == nil {
// Should be caught during validation, so we don't bother with a pretty error here
diags = diags.Append(fmt.Errorf("provider does not support resource type %q", n.Addr.Resource.Resource.Type))
return nil, keyData, diags
return nil, diags
}

log.Printf("[INFO] Starting apply for %s", n.Addr)
Expand All @@ -2107,7 +2108,7 @@ func (n *NodeAbstractResourceInstance) apply(
configVal, _, configDiags = ctx.EvaluateBlock(applyConfig.Config, schema, nil, keyData)
diags = diags.Append(configDiags)
if configDiags.HasErrors() {
return nil, keyData, diags
return nil, diags
}
}

Expand All @@ -2132,13 +2133,13 @@ func (n *NodeAbstractResourceInstance) apply(
strings.Join(unknownPaths, "\n"),
),
))
return nil, keyData, diags
return nil, diags
}

metaConfigVal, metaDiags := n.providerMetas(ctx)
diags = diags.Append(metaDiags)
if diags.HasErrors() {
return nil, keyData, diags
return nil, diags
}

log.Printf("[DEBUG] %s: applying the planned %s change", n.Addr, change.Action)
Expand Down Expand Up @@ -2166,7 +2167,7 @@ func (n *NodeAbstractResourceInstance) apply(
Status: state.Status,
Value: change.After,
}
return newState, keyData, diags
return newState, diags
}

resp := provider.ApplyResourceChange(providers.ApplyResourceChangeRequest{
Expand Down Expand Up @@ -2237,7 +2238,7 @@ func (n *NodeAbstractResourceInstance) apply(
// Bail early in this particular case, because an object that doesn't
// conform to the schema can't be saved in the state anyway -- the
// serializer will reject it.
return nil, keyData, diags
return nil, diags
}

// After this point we have a type-conforming result object and so we
Expand Down Expand Up @@ -2363,7 +2364,7 @@ func (n *NodeAbstractResourceInstance) apply(
// prior state as the new value, making this effectively a no-op. If
// the item really _has_ been deleted then our next refresh will detect
// that and fix it up.
return state.DeepCopy(), keyData, diags
return state.DeepCopy(), diags

case diags.HasErrors() && !newVal.IsNull():
// if we have an error, make sure we restore the object status in the new state
Expand All @@ -2380,7 +2381,7 @@ func (n *NodeAbstractResourceInstance) apply(
newState.Dependencies = state.Dependencies
}

return newState, keyData, diags
return newState, diags

case !newVal.IsNull():
// Non error case with a new state
Expand All @@ -2390,11 +2391,11 @@ func (n *NodeAbstractResourceInstance) apply(
Private: resp.Private,
CreateBeforeDestroy: createBeforeDestroy,
}
return newState, keyData, diags
return newState, diags

default:
// Non error case, were the object was deleted
return nil, keyData, diags
return nil, diags
}
}

Expand Down