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

Apply optimizations for handling of condition checks #32123

Merged
merged 7 commits into from Nov 1, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically anything which has access to the EvalContext can get the "repetition data" like this:

    exp := ctx.InstanceExpander()
    keyData := exp.GetResourceInstanceRepetitionData(n.Addr())

In the original design for this "instance expander" thing it was intended to act as the source of record for all of this expansion-related information specifically so that we wouldn't need to explicitly pass all of this contextual data around.

I do feel in two minds about it because it feels like a violation of Law of Demeter, but we already widely use ctx as a big bag of shared "global" context, including in this very function where it uses ctx to do various other things that aren't strictly this function's business to do.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I left this as close to the current code as possible. I did forget that the rep data was directly available though, because the existing apply code already re-evaluated it anyway. Switching out that code however runs into a no expansion has been registered panic, so I think there's something else going on here too.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm yes I suppose the fact that we've been passing it around through arguments meant that there was not previously any need to actually register it at the source of record. Unfortunate but not unexpected.

Hopefully in a future change we can find a suitable central point to evaluate this just once and register it in the expander, so it's clearer exactly whose responsibility it is to evaluate the for_each or count expressions.

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