core: Minimal initial implementation of "refresh only" planning mode

This only includes the core mechanisms to make it work. There's not yet
any way to turn this mode on as an end-user, because we have to do some
more work at the UI layer to present this well before we could include it
as an end-user-visible feature in a release.

At the lowest level of abstraction inside the graph nodes themselves, this
effectively mirrors the existing option to disable refreshing with a new
option to disable change-planning, so that either "half" of the process
can be disabled. As far as the nodes are concerned it would be possible
in principle to disable _both_, but the higher-level representation of
these modes prevents that combination from reaching Terraform Core in
practice, because we block using -refresh-only and -refresh=false at the
same time.
This commit is contained in:
Martin Atkins 2021-04-05 17:05:57 -07:00
parent 5d04c4ea27
commit 1b464e1e9a
9 changed files with 249 additions and 59 deletions

View File

@ -21,6 +21,14 @@ import (
// since the plan does not itself include all of the information required to
// make the changes indicated.
type Plan struct {
// Mode is the mode under which this plan was created.
//
// This is only recorded to allow for UI differences when presenting plans
// to the end-user, and so it must not be used to influence apply-time
// behavior. The actions during apply must be described entirely by
// the Changes field, regardless of how the plan was created.
Mode Mode
VariableValues map[string]DynamicValue
Changes *Changes
TargetAddrs []addrs.Targetable

View File

@ -256,6 +256,17 @@ func NewContext(opts *ContextOpts) (*Context, tfdiags.Diagnostics) {
switch opts.PlanMode {
case plans.NormalMode, plans.DestroyMode:
// OK
case plans.RefreshOnlyMode:
if opts.SkipRefresh {
// The CLI layer (and other similar callers) should prevent this
// combination of options.
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Incompatible plan options",
"Cannot skip refreshing in refresh-only mode. This is a bug in Terraform.",
))
return nil, diags
}
default:
// The CLI layer (and other similar callers) should not try to
// create a context for a mode that Terraform Core doesn't support.
@ -370,6 +381,20 @@ func (c *Context) Graph(typ GraphType, opts *ContextGraphOpts) (*Graph, tfdiags.
Validate: opts.Validate,
}).Build(addrs.RootModuleInstance)
case GraphTypePlanRefreshOnly:
// Create the plan graph builder, with skipPlanChanges set to
// activate the "refresh only" mode.
return (&PlanGraphBuilder{
Config: c.config,
State: c.state,
Components: c.components,
Schemas: c.schemas,
Targets: c.targets,
Validate: opts.Validate,
skipRefresh: c.skipRefresh,
skipPlanChanges: true, // this activates "refresh only" mode.
}).Build(addrs.RootModuleInstance)
case GraphTypeEval:
return (&EvalGraphBuilder{
Config: c.config,
@ -551,6 +576,8 @@ The -target option is not for routine use, and is provided only for exceptional
plan, planDiags = c.plan()
case plans.DestroyMode:
plan, planDiags = c.destroyPlan()
case plans.RefreshOnlyMode:
plan, planDiags = c.refreshOnlyPlan()
default:
panic(fmt.Sprintf("unsupported plan mode %s", c.planMode))
}
@ -603,6 +630,7 @@ func (c *Context) plan() (*plans.Plan, tfdiags.Diagnostics) {
return nil, diags
}
plan := &plans.Plan{
Mode: plans.NormalMode,
Changes: c.changes,
}
@ -656,10 +684,56 @@ func (c *Context) destroyPlan() (*plans.Plan, tfdiags.Diagnostics) {
return nil, diags
}
destroyPlan.Mode = plans.DestroyMode
destroyPlan.Changes = c.changes
return destroyPlan, diags
}
func (c *Context) refreshOnlyPlan() (*plans.Plan, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
graph, graphDiags := c.Graph(GraphTypePlanRefreshOnly, nil)
diags = diags.Append(graphDiags)
if graphDiags.HasErrors() {
return nil, diags
}
// Do the walk
walker, walkDiags := c.walk(graph, walkPlan)
diags = diags.Append(walker.NonFatalDiagnostics)
diags = diags.Append(walkDiags)
if walkDiags.HasErrors() {
return nil, diags
}
plan := &plans.Plan{
Mode: plans.RefreshOnlyMode,
Changes: c.changes,
}
// If the graph builder and graph nodes correctly obeyed our directive
// to refresh only, the set of resource changes should always be empty.
// We'll safety-check that here so we can return a clear message about it,
// rather than probably just generating confusing output at the UI layer.
if len(plan.Changes.Resources) != 0 {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Invalid refresh-only plan",
"Terraform generated planned resource changes in a refresh-only plan. This is a bug in Terraform.",
))
}
c.refreshState.SyncWrapper().RemovePlannedResourceInstanceObjects()
refreshedState := c.refreshState.DeepCopy()
plan.State = refreshedState
// replace the working state with the updated state, so that immediate calls
// to Apply work as expected.
c.state = refreshedState
return plan, diags
}
// Refresh goes through all the resources in the state and refreshes them
// to their latest state. This is done by executing a plan, and retaining the
// state while discarding the change set.

View File

@ -11,6 +11,7 @@ const (
GraphTypeInvalid GraphType = iota
GraphTypePlan
GraphTypePlanDestroy
GraphTypePlanRefreshOnly
GraphTypeApply
GraphTypeValidate
GraphTypeEval // only visits in-memory elements such as variables, locals, and outputs.
@ -20,9 +21,10 @@ const (
// is useful to use as the mechanism for human input for configurable
// graph types.
var GraphTypeMap = map[string]GraphType{
"apply": GraphTypeApply,
"plan": GraphTypePlan,
"plan-destroy": GraphTypePlanDestroy,
"validate": GraphTypeValidate,
"eval": GraphTypeEval,
"apply": GraphTypeApply,
"plan": GraphTypePlan,
"plan-destroy": GraphTypePlanDestroy,
"plan-refresh-only": GraphTypePlanRefreshOnly,
"validate": GraphTypeValidate,
"eval": GraphTypeEval,
}

View File

@ -1,10 +1,12 @@
package terraform
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/davecgh/go-spew/spew"
"github.com/hashicorp/terraform/addrs"
"github.com/hashicorp/terraform/configs/configschema"
"github.com/hashicorp/terraform/plans"
@ -487,6 +489,75 @@ provider "test" {
}
}
func TestContext2Plan_refreshOnlyMode(t *testing.T) {
addr := mustResourceInstanceAddr("test_object.a")
p := simpleMockProvider()
// The configuration, the prior state, and the refresh result intentionally
// have different values for "test_string" so we can observe that the
// refresh took effect but the configuration change wasn't considered.
m := testModuleInline(t, map[string]string{
"main.tf": `
resource "test_object" "a" {
test_string = "after"
}
`,
})
state := states.BuildState(func(s *states.SyncState) {
s.SetResourceInstanceCurrent(addr, &states.ResourceInstanceObjectSrc{
AttrsJSON: []byte(`{"test_string":"before"}`),
Status: states.ObjectReady,
}, mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`))
})
p.ReadResourceFn = func(req providers.ReadResourceRequest) providers.ReadResourceResponse {
newVal, err := cty.Transform(req.PriorState, func(path cty.Path, v cty.Value) (cty.Value, error) {
if len(path) == 1 && path[0] == (cty.GetAttrStep{Name: "test_string"}) {
return cty.StringVal("current"), nil
}
return v, nil
})
if err != nil {
// shouldn't get here
t.Fatalf("ReadResourceFn transform failed")
return providers.ReadResourceResponse{}
}
return providers.ReadResourceResponse{
NewState: newVal,
}
}
ctx := testContext2(t, &ContextOpts{
Config: m,
State: state,
Providers: map[addrs.Provider]providers.Factory{
addrs.NewDefaultProvider("test"): testProviderFuncFixed(p),
},
PlanMode: plans.RefreshOnlyMode,
})
plan, diags := ctx.Plan()
if diags.HasErrors() {
t.Fatalf("unexpected errors\n%s", diags.Err().Error())
}
if got, want := len(plan.Changes.Resources), 0; got != want {
t.Fatalf("plan contains resource changes; want none\n%s", spew.Sdump(plan.Changes.Resources))
}
state = plan.State
instState := state.ResourceInstance(addr)
if instState == nil {
t.Fatalf("%s has no state at all after plan", addr)
}
if instState.Current == nil {
t.Fatalf("%s has no current object after plan", addr)
}
if got, want := instState.Current.AttrsJSON, `"current"`; !bytes.Contains(got, []byte(want)) {
t.Fatalf("%s has wrong prior state after plan\ngot:\n%s\n\nwant substring: %s", addr, got, want)
}
}
func TestContext2Plan_invalidSensitiveModuleOutput(t *testing.T) {
m := testModuleInline(t, map[string]string{
"child/main.tf": `

View File

@ -45,6 +45,12 @@ type PlanGraphBuilder struct {
// skipRefresh indicates that we should skip refreshing managed resources
skipRefresh bool
// skipPlanChanges indicates that we should skip the step of comparing
// prior state with configuration and generating planned changes to
// resource instances. (This is for the "refresh only" planning mode,
// where we _only_ do the refresh step.)
skipPlanChanges bool
// CustomConcrete can be set to customize the node types created
// for various parts of the plan. This is useful in order to customize
// the plan behavior.
@ -181,6 +187,7 @@ func (b *PlanGraphBuilder) init() {
return &nodeExpandPlannableResource{
NodeAbstractResource: a,
skipRefresh: b.skipRefresh,
skipPlanChanges: b.skipPlanChanges,
}
}
@ -188,6 +195,7 @@ func (b *PlanGraphBuilder) init() {
return &NodePlannableResourceInstanceOrphan{
NodeAbstractResourceInstance: a,
skipRefresh: b.skipRefresh,
skipPlanChanges: b.skipPlanChanges,
}
}
}

View File

@ -11,14 +11,15 @@ func _() {
_ = x[GraphTypeInvalid-0]
_ = x[GraphTypePlan-1]
_ = x[GraphTypePlanDestroy-2]
_ = x[GraphTypeApply-3]
_ = x[GraphTypeValidate-4]
_ = x[GraphTypeEval-5]
_ = x[GraphTypePlanRefreshOnly-3]
_ = x[GraphTypeApply-4]
_ = x[GraphTypeValidate-5]
_ = x[GraphTypeEval-6]
}
const _GraphType_name = "GraphTypeInvalidGraphTypePlanGraphTypePlanDestroyGraphTypeApplyGraphTypeValidateGraphTypeEval"
const _GraphType_name = "GraphTypeInvalidGraphTypePlanGraphTypePlanDestroyGraphTypePlanRefreshOnlyGraphTypeApplyGraphTypeValidateGraphTypeEval"
var _GraphType_index = [...]uint8{0, 16, 29, 49, 63, 80, 93}
var _GraphType_index = [...]uint8{0, 16, 29, 49, 73, 87, 104, 117}
func (i GraphType) String() string {
if i >= GraphType(len(_GraphType_index)-1) {

View File

@ -24,6 +24,10 @@ type nodeExpandPlannableResource struct {
// skipRefresh indicates that we should skip refreshing individual instances
skipRefresh bool
// skipPlanChanges indicates we should skip trying to plan change actions
// for any instances.
skipPlanChanges bool
// We attach dependencies to the Resource during refresh, since the
// instances are instantiated during DynamicExpand.
dependencies []addrs.ConfigResource
@ -84,6 +88,7 @@ func (n *nodeExpandPlannableResource) DynamicExpand(ctx EvalContext) (*Graph, er
ForceCreateBeforeDestroy: n.ForceCreateBeforeDestroy,
dependencies: n.dependencies,
skipRefresh: n.skipRefresh,
skipPlanChanges: n.skipPlanChanges,
})
}
@ -149,6 +154,10 @@ type NodePlannableResource struct {
// skipRefresh indicates that we should skip refreshing individual instances
skipRefresh bool
// skipPlanChanges indicates we should skip trying to plan change actions
// for any instances.
skipPlanChanges bool
dependencies []addrs.ConfigResource
}
@ -239,6 +248,7 @@ func (n *NodePlannableResource) DynamicExpand(ctx EvalContext) (*Graph, error) {
// nodes that have it.
ForceCreateBeforeDestroy: n.CreateBeforeDestroy(),
skipRefresh: n.skipRefresh,
skipPlanChanges: n.skipPlanChanges,
}
}

View File

@ -18,7 +18,13 @@ import (
type NodePlannableResourceInstance struct {
*NodeAbstractResourceInstance
ForceCreateBeforeDestroy bool
skipRefresh bool
// skipRefresh indicates that we should skip refreshing individual instances
skipRefresh bool
// skipPlanChanges indicates we should skip trying to plan change actions
// for any instances.
skipPlanChanges bool
}
var (
@ -96,7 +102,7 @@ func (n *NodePlannableResourceInstance) managedResourceExecute(ctx EvalContext)
addr := n.ResourceInstanceAddr()
var change *plans.ResourceInstanceChange
var instancePlanState *states.ResourceInstanceObject
var instanceRefreshState *states.ResourceInstanceObject
_, providerSchema, err := getProvider(ctx, n.ResolvedProvider)
diags = diags.Append(err)
@ -150,38 +156,41 @@ func (n *NodePlannableResourceInstance) managedResourceExecute(ctx EvalContext)
}
}
// Plan the instance
change, instancePlanState, planDiags := n.plan(ctx, change, instanceRefreshState, n.ForceCreateBeforeDestroy)
diags = diags.Append(planDiags)
if diags.HasErrors() {
return diags
}
diags = diags.Append(n.checkPreventDestroy(change))
if diags.HasErrors() {
return diags
}
diags = diags.Append(n.writeResourceInstanceState(ctx, instancePlanState, workingState))
if diags.HasErrors() {
return diags
}
// If this plan resulted in a NoOp, then apply won't have a chance to make
// any changes to the stored dependencies. Since this is a NoOp we know
// that the stored dependencies will have no effect during apply, and we can
// write them out now.
if change.Action == plans.NoOp && !depsEqual(instanceRefreshState.Dependencies, n.Dependencies) {
// the refresh state will be the final state for this resource, so
// finalize the dependencies here if they need to be updated.
instanceRefreshState.Dependencies = n.Dependencies
diags = diags.Append(n.writeResourceInstanceState(ctx, instanceRefreshState, refreshState))
// Plan the instance, unless we're in the refresh-only mode
if !n.skipPlanChanges {
change, instancePlanState, planDiags := n.plan(ctx, change, instanceRefreshState, n.ForceCreateBeforeDestroy)
diags = diags.Append(planDiags)
if diags.HasErrors() {
return diags
}
diags = diags.Append(n.checkPreventDestroy(change))
if diags.HasErrors() {
return diags
}
diags = diags.Append(n.writeResourceInstanceState(ctx, instancePlanState, workingState))
if diags.HasErrors() {
return diags
}
// If this plan resulted in a NoOp, then apply won't have a chance to make
// any changes to the stored dependencies. Since this is a NoOp we know
// that the stored dependencies will have no effect during apply, and we can
// write them out now.
if change.Action == plans.NoOp && !depsEqual(instanceRefreshState.Dependencies, n.Dependencies) {
// the refresh state will be the final state for this resource, so
// finalize the dependencies here if they need to be updated.
instanceRefreshState.Dependencies = n.Dependencies
diags = diags.Append(n.writeResourceInstanceState(ctx, instanceRefreshState, refreshState))
if diags.HasErrors() {
return diags
}
}
diags = diags.Append(n.writeChange(ctx, change, ""))
}
diags = diags.Append(n.writeChange(ctx, change, ""))
return diags
}

View File

@ -14,7 +14,12 @@ import (
type NodePlannableResourceInstanceOrphan struct {
*NodeAbstractResourceInstance
// skipRefresh indicates that we should skip refreshing individual instances
skipRefresh bool
// skipPlanChanges indicates we should skip trying to plan change actions
// for any instances.
skipPlanChanges bool
}
var (
@ -75,9 +80,7 @@ func (n *NodePlannableResourceInstanceOrphan) managedResourceExecute(ctx EvalCon
// Declare a bunch of variables that are used for state during
// evaluation. These are written to by-address below.
var change *plans.ResourceInstanceChange
state, readDiags := n.readResourceInstanceState(ctx, addr)
oldState, readDiags := n.readResourceInstanceState(ctx, addr)
diags = diags.Append(readDiags)
if diags.HasErrors() {
return diags
@ -90,34 +93,38 @@ func (n *NodePlannableResourceInstanceOrphan) managedResourceExecute(ctx EvalCon
// plan before apply, and may not handle a missing resource during
// Delete correctly. If this is a simple refresh, Terraform is
// expected to remove the missing resource from the state entirely
state, refreshDiags := n.refresh(ctx, state)
refreshedState, refreshDiags := n.refresh(ctx, oldState)
diags = diags.Append(refreshDiags)
if diags.HasErrors() {
return diags
}
diags = diags.Append(n.writeResourceInstanceState(ctx, state, refreshState))
diags = diags.Append(n.writeResourceInstanceState(ctx, refreshedState, refreshState))
if diags.HasErrors() {
return diags
}
}
change, destroyPlanDiags := n.planDestroy(ctx, state, "")
diags = diags.Append(destroyPlanDiags)
if diags.HasErrors() {
return diags
if !n.skipPlanChanges {
var change *plans.ResourceInstanceChange
change, destroyPlanDiags := n.planDestroy(ctx, oldState, "")
diags = diags.Append(destroyPlanDiags)
if diags.HasErrors() {
return diags
}
diags = diags.Append(n.checkPreventDestroy(change))
if diags.HasErrors() {
return diags
}
diags = diags.Append(n.writeChange(ctx, change, ""))
if diags.HasErrors() {
return diags
}
diags = diags.Append(n.writeResourceInstanceState(ctx, nil, workingState))
}
diags = diags.Append(n.checkPreventDestroy(change))
if diags.HasErrors() {
return diags
}
diags = diags.Append(n.writeChange(ctx, change, ""))
if diags.HasErrors() {
return diags
}
diags = diags.Append(n.writeResourceInstanceState(ctx, nil, workingState))
return diags
}