plans: Track both the previous run and prior states in the plan

Until now we've not really cared much about the state snapshot produced
by the previous Terraform operation, except to use it as a jumping-off
point for our refresh step.

However, we'd like to be able to report to an end-user whenever Terraform
detects a change that occurred outside of Terraform, because that's often
helpful context for understanding why a plan contains changes that don't
seem to have corresponding changes in the configuration.

As part of reporting that we'll need to keep track of the state as it
was before we did any refreshing work, so we can then compare that against
the state after refreshing. To retain enough data to achieve that, the
existing Plan field State is now two fields: PrevRunState and PriorState.

This also includes a very shallow change in the core package to make it
populate something somewhat-reasonable into this field so that integration
tests can function reasonably. However, this shallow implementation isn't
really sufficient for real-world use of PrevRunState because we'll
actually need to update PrevRunState as part of planning in order to
incorporate the results of any provider-specific state upgrades to make
the PrevRunState objects compatible with the current provider schema, or
else our diffs won't be valid. This deeper awareness of PrevRunState in
Terraform Core will follow in a subsequent commit, prior to anything else
making use of Plan.PrevRunState.
This commit is contained in:
Martin Atkins 2021-05-04 15:59:58 -07:00
parent 5f30efe857
commit 7c6e78bcb0
11 changed files with 132 additions and 35 deletions

View File

@ -8,6 +8,7 @@ import (
"github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/backend"
"github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/plans"
"github.com/hashicorp/terraform/plans/planfile" "github.com/hashicorp/terraform/plans/planfile"
"github.com/hashicorp/terraform/states/statefile"
"github.com/hashicorp/terraform/states/statemgr" "github.com/hashicorp/terraform/states/statemgr"
"github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/terraform"
"github.com/hashicorp/terraform/tfdiags" "github.com/hashicorp/terraform/tfdiags"
@ -115,10 +116,22 @@ func (b *Local) opPlan(
// We may have updated the state in the refresh step above, but we // We may have updated the state in the refresh step above, but we
// will freeze that updated state in the plan file for now and // will freeze that updated state in the plan file for now and
// only write it if this plan is subsequently applied. // only write it if this plan is subsequently applied.
plannedStateFile := statemgr.PlannedStateUpdate(opState, plan.State) plannedStateFile := statemgr.PlannedStateUpdate(opState, plan.PriorState)
// We also include a file containing the state as it existed before
// we took any action at all, but this one isn't intended to ever
// be saved to the backend (an equivalent snapshot should already be
// there) and so we just use a stub state file header in this case.
// NOTE: This won't be exactly identical to the latest state snapshot
// in the backend because it's still been subject to state upgrading
// to make it consumable by the current Terraform version, and
// intentionally doesn't preserve the header info.
prevStateFile := &statefile.File{
State: plan.PrevRunState,
}
log.Printf("[INFO] backend/local: writing plan output to: %s", path) log.Printf("[INFO] backend/local: writing plan output to: %s", path)
err := planfile.Create(path, configSnap, plannedStateFile, plan) err := planfile.Create(path, configSnap, prevStateFile, plannedStateFile, plan)
if err != nil { if err != nil {
diags = diags.Append(tfdiags.Sourceless( diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error, tfdiags.Error,
@ -140,7 +153,7 @@ func (b *Local) opPlan(
} }
// Render the plan // Render the plan
op.View.Plan(plan, plan.State, tfCtx.Schemas()) op.View.Plan(plan, plan.PriorState, tfCtx.Schemas())
// If we've accumulated any warnings along the way then we'll show them // If we've accumulated any warnings along the way then we'll show them
// here just before we show the summary and next steps. If we encountered // here just before we show the summary and next steps. If we encountered

View File

@ -182,9 +182,14 @@ func testPlanFile(t *testing.T, configSnap *configload.Snapshot, state *states.S
State: state, State: state,
TerraformVersion: version.SemVer, TerraformVersion: version.SemVer,
} }
prevStateFile := &statefile.File{
Lineage: "",
State: state, // we just assume no changes detected during refresh
TerraformVersion: version.SemVer,
}
path := testTempFile(t) path := testTempFile(t)
err := planfile.Create(path, configSnap, stateFile, plan) err := planfile.Create(path, configSnap, prevStateFile, stateFile, plan)
if err != nil { if err != nil {
t.Fatalf("failed to create temporary plan file: %s", err) t.Fatalf("failed to create temporary plan file: %s", err)
} }

View File

@ -35,7 +35,24 @@ type Plan struct {
ForceReplaceAddrs []addrs.AbsResourceInstance ForceReplaceAddrs []addrs.AbsResourceInstance
ProviderSHA256s map[string][]byte ProviderSHA256s map[string][]byte
Backend Backend Backend Backend
State *states.State
// PrevRunState and PriorState both describe the situation that the plan
// was derived from:
//
// PrevRunState is a representation of the outcome of the previous
// Terraform operation, without any updates from the remote system but
// potentially including some changes that resulted from state upgrade
// actions.
//
// PriorState is a representation of the current state of remote objects,
// which will differ from PrevRunState if the "refresh" step returned
// different data, which might reflect drift.
//
// PriorState is the main snapshot we use for actions during apply.
// PrevRunState is only here so that we can diff PriorState against it in
// order to report to the user any out-of-band changes we've detected.
PrevRunState *states.State
PriorState *states.State
} }
// Backend represents the backend-related configuration and other data as it // Backend represents the backend-related configuration and other data as it

View File

@ -3,10 +3,9 @@ package planfile
import ( import (
"io/ioutil" "io/ioutil"
"path/filepath" "path/filepath"
"reflect"
"testing" "testing"
"github.com/davecgh/go-spew/spew" "github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform/configs/configload" "github.com/hashicorp/terraform/configs/configload"
"github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/plans"
@ -33,6 +32,12 @@ func TestRoundtrip(t *testing.T) {
// We don't need to test the entire thing because the state file // We don't need to test the entire thing because the state file
// serialization is already tested in its own package. // serialization is already tested in its own package.
stateFileIn := &statefile.File{ stateFileIn := &statefile.File{
TerraformVersion: tfversion.SemVer,
Serial: 2,
Lineage: "abc123",
State: states.NewState(),
}
prevStateFileIn := &statefile.File{
TerraformVersion: tfversion.SemVer, TerraformVersion: tfversion.SemVer,
Serial: 1, Serial: 1,
Lineage: "abc123", Lineage: "abc123",
@ -63,7 +68,7 @@ func TestRoundtrip(t *testing.T) {
} }
planFn := filepath.Join(workDir, "tfplan") planFn := filepath.Join(workDir, "tfplan")
err = Create(planFn, snapIn, stateFileIn, planIn) err = Create(planFn, snapIn, prevStateFileIn, stateFileIn, planIn)
if err != nil { if err != nil {
t.Fatalf("failed to create plan file: %s", err) t.Fatalf("failed to create plan file: %s", err)
} }
@ -78,8 +83,8 @@ func TestRoundtrip(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to read plan: %s", err) t.Fatalf("failed to read plan: %s", err)
} }
if !reflect.DeepEqual(planIn, planOut) { if diff := cmp.Diff(planIn, planOut); diff != "" {
t.Errorf("plan did not survive round-trip\nresult: %sinput: %s", spew.Sdump(planOut), spew.Sdump(planIn)) t.Errorf("plan did not survive round-trip\n%s", diff)
} }
}) })
@ -88,8 +93,18 @@ func TestRoundtrip(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to read state: %s", err) t.Fatalf("failed to read state: %s", err)
} }
if !reflect.DeepEqual(stateFileIn, stateFileOut) { if diff := cmp.Diff(stateFileIn, stateFileOut); diff != "" {
t.Errorf("state file did not survive round-trip\nresult: %sinput: %s", spew.Sdump(stateFileOut), spew.Sdump(stateFileIn)) t.Errorf("state file did not survive round-trip\n%s", diff)
}
})
t.Run("ReadPrevStateFile", func(t *testing.T) {
prevStateFileOut, err := pr.ReadPrevStateFile()
if err != nil {
t.Fatalf("failed to read state: %s", err)
}
if diff := cmp.Diff(prevStateFileIn, prevStateFileOut); diff != "" {
t.Errorf("state file did not survive round-trip\n%s", diff)
} }
}) })
@ -98,8 +113,8 @@ func TestRoundtrip(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("failed to read config snapshot: %s", err) t.Fatalf("failed to read config snapshot: %s", err)
} }
if !reflect.DeepEqual(snapIn, snapOut) { if diff := cmp.Diff(snapIn, snapOut); diff != "" {
t.Errorf("config snapshot did not survive round-trip\nresult: %sinput: %s", spew.Sdump(snapOut), spew.Sdump(snapIn)) t.Errorf("config snapshot did not survive round-trip\n%s", diff)
} }
}) })

View File

@ -14,6 +14,7 @@ import (
) )
const tfstateFilename = "tfstate" const tfstateFilename = "tfstate"
const tfstatePreviousFilename = "tfstate-prev"
// Reader is the main type used to read plan files. Create a Reader by calling // Reader is the main type used to read plan files. Create a Reader by calling
// Open. // Open.
@ -90,7 +91,8 @@ func (r *Reader) ReadPlan() (*plans.Plan, error) {
return readTfplan(pr) return readTfplan(pr)
} }
// ReadStateFile reads the state file embedded in the plan file. // ReadStateFile reads the state file embedded in the plan file, which
// represents the "PriorState" as defined in plans.Plan.
// //
// If the plan file contains no embedded state file, the returned error is // If the plan file contains no embedded state file, the returned error is
// statefile.ErrNoState. // statefile.ErrNoState.
@ -107,6 +109,24 @@ func (r *Reader) ReadStateFile() (*statefile.File, error) {
return nil, statefile.ErrNoState return nil, statefile.ErrNoState
} }
// ReadPrevStateFile reads the previous state file embedded in the plan file, which
// represents the "PrevRunState" as defined in plans.Plan.
//
// If the plan file contains no embedded previous state file, the returned error is
// statefile.ErrNoState.
func (r *Reader) ReadPrevStateFile() (*statefile.File, error) {
for _, file := range r.zip.File {
if file.Name == tfstatePreviousFilename {
r, err := file.Open()
if err != nil {
return nil, fmt.Errorf("failed to extract previous state from plan file: %s", err)
}
return statefile.Read(r)
}
}
return nil, statefile.ErrNoState
}
// ReadConfigSnapshot reads the configuration snapshot embedded in the plan // ReadConfigSnapshot reads the configuration snapshot embedded in the plan
// file. // file.
// //

View File

@ -18,7 +18,7 @@ import (
// state file in addition to the plan itself, so that Terraform can detect // state file in addition to the plan itself, so that Terraform can detect
// if the world has changed since the plan was created and thus refuse to // if the world has changed since the plan was created and thus refuse to
// apply it. // apply it.
func Create(filename string, configSnap *configload.Snapshot, stateFile *statefile.File, plan *plans.Plan) error { func Create(filename string, configSnap *configload.Snapshot, prevStateFile, stateFile *statefile.File, plan *plans.Plan) error {
f, err := os.Create(filename) f, err := os.Create(filename)
if err != nil { if err != nil {
return err return err
@ -60,6 +60,22 @@ func Create(filename string, configSnap *configload.Snapshot, stateFile *statefi
} }
} }
// tfstate-prev file
{
w, err := zw.CreateHeader(&zip.FileHeader{
Name: tfstatePreviousFilename,
Method: zip.Deflate,
Modified: time.Now(),
})
if err != nil {
return fmt.Errorf("failed to create embedded tfstate-prev file: %s", err)
}
err = statefile.Write(prevStateFile, w)
if err != nil {
return fmt.Errorf("failed to write previous state snapshot: %s", err)
}
}
// tfconfig directory // tfconfig directory
{ {
err := writeConfigSnapshot(configSnap, zw) err := writeConfigSnapshot(configSnap, zw)

View File

@ -631,6 +631,8 @@ The -target option is not for routine use, and is provided only for exceptional
func (c *Context) plan() (*plans.Plan, tfdiags.Diagnostics) { func (c *Context) plan() (*plans.Plan, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics var diags tfdiags.Diagnostics
prevRunState := c.state.DeepCopy()
graph, graphDiags := c.Graph(GraphTypePlan, nil) graph, graphDiags := c.Graph(GraphTypePlan, nil)
diags = diags.Append(graphDiags) diags = diags.Append(graphDiags)
if graphDiags.HasErrors() { if graphDiags.HasErrors() {
@ -648,12 +650,13 @@ func (c *Context) plan() (*plans.Plan, tfdiags.Diagnostics) {
UIMode: plans.NormalMode, UIMode: plans.NormalMode,
Changes: c.changes, Changes: c.changes,
ForceReplaceAddrs: c.forceReplace, ForceReplaceAddrs: c.forceReplace,
PrevRunState: prevRunState,
} }
c.refreshState.SyncWrapper().RemovePlannedResourceInstanceObjects() c.refreshState.SyncWrapper().RemovePlannedResourceInstanceObjects()
refreshedState := c.refreshState.DeepCopy() refreshedState := c.refreshState.DeepCopy()
plan.State = refreshedState plan.PriorState = refreshedState
// replace the working state with the updated state, so that immediate calls // replace the working state with the updated state, so that immediate calls
// to Apply work as expected. // to Apply work as expected.
@ -665,7 +668,8 @@ func (c *Context) plan() (*plans.Plan, tfdiags.Diagnostics) {
func (c *Context) destroyPlan() (*plans.Plan, tfdiags.Diagnostics) { func (c *Context) destroyPlan() (*plans.Plan, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics var diags tfdiags.Diagnostics
destroyPlan := &plans.Plan{ destroyPlan := &plans.Plan{
State: c.state.DeepCopy(), PrevRunState: c.state.DeepCopy(),
PriorState: c.state.DeepCopy(),
} }
c.changes = plans.NewChanges() c.changes = plans.NewChanges()
@ -682,7 +686,7 @@ func (c *Context) destroyPlan() (*plans.Plan, tfdiags.Diagnostics) {
// insert the refreshed state into the destroy plan result, and discard // insert the refreshed state into the destroy plan result, and discard
// the changes recorded from the refresh. // the changes recorded from the refresh.
destroyPlan.State = refreshPlan.State destroyPlan.PriorState = refreshPlan.PriorState.DeepCopy()
c.changes = plans.NewChanges() c.changes = plans.NewChanges()
} }
@ -708,6 +712,8 @@ func (c *Context) destroyPlan() (*plans.Plan, tfdiags.Diagnostics) {
func (c *Context) refreshOnlyPlan() (*plans.Plan, tfdiags.Diagnostics) { func (c *Context) refreshOnlyPlan() (*plans.Plan, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics var diags tfdiags.Diagnostics
prevRunState := c.state.DeepCopy()
graph, graphDiags := c.Graph(GraphTypePlanRefreshOnly, nil) graph, graphDiags := c.Graph(GraphTypePlanRefreshOnly, nil)
diags = diags.Append(graphDiags) diags = diags.Append(graphDiags)
if graphDiags.HasErrors() { if graphDiags.HasErrors() {
@ -724,6 +730,7 @@ func (c *Context) refreshOnlyPlan() (*plans.Plan, tfdiags.Diagnostics) {
plan := &plans.Plan{ plan := &plans.Plan{
UIMode: plans.RefreshOnlyMode, UIMode: plans.RefreshOnlyMode,
Changes: c.changes, Changes: c.changes,
PrevRunState: prevRunState,
} }
// If the graph builder and graph nodes correctly obeyed our directive // If the graph builder and graph nodes correctly obeyed our directive
@ -740,11 +747,12 @@ func (c *Context) refreshOnlyPlan() (*plans.Plan, tfdiags.Diagnostics) {
c.refreshState.SyncWrapper().RemovePlannedResourceInstanceObjects() c.refreshState.SyncWrapper().RemovePlannedResourceInstanceObjects()
refreshedState := c.refreshState.DeepCopy() refreshedState := c.refreshState
plan.State = refreshedState plan.PriorState = refreshedState.DeepCopy()
// replace the working state with the updated state, so that immediate calls // replace the working state with the updated state, so that immediate calls
// to Apply work as expected. // to Apply work as expected. DeepCopy because such an apply should not
// mutate
c.state = refreshedState c.state = refreshedState
return plan, diags return plan, diags
@ -761,7 +769,7 @@ func (c *Context) Refresh() (*states.State, tfdiags.Diagnostics) {
return nil, diags return nil, diags
} }
return p.State, diags return p.PriorState, diags
} }
// Stop stops the running task. // Stop stops the running task.

View File

@ -333,22 +333,22 @@ resource "aws_instance" "bin" {
t.Fatal(diags.Err()) t.Fatal(diags.Err())
} }
bar := plan.State.ResourceInstance(barAddr) bar := plan.PriorState.ResourceInstance(barAddr)
if len(bar.Current.Dependencies) == 0 || !bar.Current.Dependencies[0].Equal(fooAddr.ContainingResource().Config()) { if len(bar.Current.Dependencies) == 0 || !bar.Current.Dependencies[0].Equal(fooAddr.ContainingResource().Config()) {
t.Fatalf("bar should depend on foo after refresh, but got %s", bar.Current.Dependencies) t.Fatalf("bar should depend on foo after refresh, but got %s", bar.Current.Dependencies)
} }
foo := plan.State.ResourceInstance(fooAddr) foo := plan.PriorState.ResourceInstance(fooAddr)
if len(foo.Current.Dependencies) == 0 || !foo.Current.Dependencies[0].Equal(bazAddr.ContainingResource().Config()) { if len(foo.Current.Dependencies) == 0 || !foo.Current.Dependencies[0].Equal(bazAddr.ContainingResource().Config()) {
t.Fatalf("foo should depend on baz after refresh because of the update, but got %s", foo.Current.Dependencies) t.Fatalf("foo should depend on baz after refresh because of the update, but got %s", foo.Current.Dependencies)
} }
bin := plan.State.ResourceInstance(binAddr) bin := plan.PriorState.ResourceInstance(binAddr)
if len(bin.Current.Dependencies) != 0 { if len(bin.Current.Dependencies) != 0 {
t.Fatalf("bin should depend on nothing after refresh because there is no change, but got %s", bin.Current.Dependencies) t.Fatalf("bin should depend on nothing after refresh because there is no change, but got %s", bin.Current.Dependencies)
} }
baz := plan.State.ResourceInstance(bazAddr) baz := plan.PriorState.ResourceInstance(bazAddr)
if len(baz.Current.Dependencies) == 0 || !baz.Current.Dependencies[0].Equal(bamAddr.ContainingResource().Config()) { if len(baz.Current.Dependencies) == 0 || !baz.Current.Dependencies[0].Equal(bamAddr.ContainingResource().Config()) {
t.Fatalf("baz should depend on bam after refresh, but got %s", baz.Current.Dependencies) t.Fatalf("baz should depend on bam after refresh, but got %s", baz.Current.Dependencies)
} }

View File

@ -370,7 +370,7 @@ resource "test_object" "a" {
t.Fatal(diags.Err()) t.Fatal(diags.Err())
} }
if plan.State == nil { if plan.PriorState == nil {
t.Fatal("missing plan state") t.Fatal("missing plan state")
} }
@ -545,7 +545,7 @@ func TestContext2Plan_refreshOnlyMode(t *testing.T) {
t.Fatalf("plan contains resource changes; want none\n%s", spew.Sdump(plan.Changes.Resources)) t.Fatalf("plan contains resource changes; want none\n%s", spew.Sdump(plan.Changes.Resources))
} }
state = plan.State state = plan.PriorState
instState := state.ResourceInstance(addr) instState := state.ResourceInstance(addr)
if instState == nil { if instState == nil {
t.Fatalf("%s has no state at all after plan", addr) t.Fatalf("%s has no state at all after plan", addr)

View File

@ -6332,9 +6332,9 @@ data "test_data_source" "d" {
t.Fatal(diags.ErrWithWarnings()) t.Fatal(diags.ErrWithWarnings())
} }
d := plan.State.ResourceInstance(mustResourceInstanceAddr("data.test_data_source.d")) d := plan.PriorState.ResourceInstance(mustResourceInstanceAddr("data.test_data_source.d"))
if d == nil || d.Current == nil { if d == nil || d.Current == nil {
t.Fatal("data.test_data_source.d not found in state:", plan.State) t.Fatal("data.test_data_source.d not found in state:", plan.PriorState)
} }
if d.Current.Status != states.ObjectReady { if d.Current.Status != states.ObjectReady {

View File

@ -728,7 +728,10 @@ func contextOptsForPlanViaFile(configSnap *configload.Snapshot, plan *plans.Plan
// to run through any of the codepaths that care about Lineage/Serial/etc // to run through any of the codepaths that care about Lineage/Serial/etc
// here anyway. // here anyway.
stateFile := &statefile.File{ stateFile := &statefile.File{
State: plan.State, State: plan.PriorState,
}
prevStateFile := &statefile.File{
State: plan.PrevRunState,
} }
// To make life a little easier for test authors, we'll populate a simple // To make life a little easier for test authors, we'll populate a simple
@ -746,7 +749,7 @@ func contextOptsForPlanViaFile(configSnap *configload.Snapshot, plan *plans.Plan
} }
filename := filepath.Join(dir, "tfplan") filename := filepath.Join(dir, "tfplan")
err = planfile.Create(filename, configSnap, stateFile, plan) err = planfile.Create(filename, configSnap, prevStateFile, stateFile, plan)
if err != nil { if err != nil {
return nil, err return nil, err
} }