command+backend/local: -refresh-only and drift detection

This is a light revamp of our plan output to make use of Terraform core's
new ability to report both the previous run state and the refreshed state,
allowing us to explicitly report changes made outside of Terraform.

Because whether a plan has "changes" or not is no longer such a
straightforward matter, this now merges views.Operation.Plan with
views.Operation.PlanNoChanges to produce a single function that knows how
to report all of the various permutations. This was also an opportunity
to fill some holes in our previous logic which caused it to produce some
confusing messages, including a new tailored message for when
"terraform destroy" detects that nothing needs to be destroyed.

This also allows users to request the refresh-only planning mode using a
new -refresh-only command line option. In that case, Terraform _only_
performs drift detection, and so applying a refresh-only plan only
involves writing a new state snapshot, without changing any real
infrastructure objects.
This commit is contained in:
Martin Atkins 2021-05-06 15:22:48 -07:00
parent ce69c3903f
commit 3c8a4e6e05
19 changed files with 654 additions and 213 deletions

View File

@ -72,12 +72,15 @@ func (b *Local) opApply(
return
}
trivialPlan := plan.Changes.Empty()
trivialPlan := !plan.CanApply()
hasUI := op.UIOut != nil && op.UIIn != nil
mustConfirm := hasUI && !op.AutoApprove && !trivialPlan
op.View.Plan(plan, tfCtx.Schemas())
if mustConfirm {
var desc, query string
if op.PlanMode == plans.DestroyMode {
switch op.PlanMode {
case plans.DestroyMode:
if op.Workspace != "default" {
query = "Do you really want to destroy all resources in workspace \"" + op.Workspace + "\"?"
} else {
@ -85,7 +88,15 @@ func (b *Local) opApply(
}
desc = "Terraform will destroy all your managed infrastructure, as shown above.\n" +
"There is no undo. Only 'yes' will be accepted to confirm."
} else {
case plans.RefreshOnlyMode:
if op.Workspace != "default" {
query = "Would you like to update the Terraform state for \"" + op.Workspace + "\" to reflect these detected changes?"
} else {
query = "Would you like to update the Terraform state to reflect these detected changes?"
}
desc = "Terraform will write these changes to the state without modifying any real infrastructure.\n" +
"There is no undo. Only 'yes' will be accepted to confirm."
default:
if op.Workspace != "default" {
query = "Do you want to perform these actions in workspace \"" + op.Workspace + "\"?"
} else {
@ -95,10 +106,6 @@ func (b *Local) opApply(
"Only 'yes' will be accepted to approve."
}
if !trivialPlan {
op.View.Plan(plan, tfCtx.Schemas())
}
// We'll show any accumulated warnings before we display the prompt,
// so the user can consider them when deciding how to answer.
if len(diags) > 0 {
@ -121,12 +128,6 @@ func (b *Local) opApply(
runningOp.Result = backend.OperationFailure
return
}
} else {
for _, change := range plan.Changes.Resources {
if change.Action != plans.NoOp {
op.View.PlannedChange(change)
}
}
}
} else {
plan, err := op.PlanFile.ReadPlan()

View File

@ -98,7 +98,7 @@ func (b *Local) opPlan(
}
// Record whether this plan includes any side-effects that could be applied.
runningOp.PlanEmpty = plan.Changes.Empty()
runningOp.PlanEmpty = !plan.CanApply()
// Save the plan to disk
if path := op.PlanOutPath; path != "" {
@ -143,15 +143,6 @@ func (b *Local) opPlan(
}
}
// Perform some output tasks
if runningOp.PlanEmpty {
op.View.PlanNoChanges()
// Even if there are no changes, there still could be some warnings
op.View.Diagnostics(diags)
return
}
// Render the plan
op.View.Plan(plan, tfCtx.Schemas())
@ -160,5 +151,7 @@ func (b *Local) opPlan(
// errors then we would've returned early at some other point above.
op.View.Diagnostics(diags)
op.View.PlanNextStep(op.PlanOutPath)
if !runningOp.PlanEmpty {
op.View.PlanNextStep(op.PlanOutPath)
}
}

View File

@ -202,22 +202,23 @@ func TestLocal_planOutputsChanged(t *testing.T) {
t.Fatalf("plan operation failed")
}
if run.PlanEmpty {
t.Fatal("plan should not be empty")
t.Error("plan should not be empty")
}
expectedOutput := strings.TrimSpace(`
Plan: 0 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ added = "after"
~ changed = "before" -> "after"
- removed = "before" -> null
~ sensitive_after = (sensitive value)
~ sensitive_before = (sensitive value)
You can apply this plan to save these new output values to the Terraform
state, without changing any real infrastructure.
`)
if output := done(t).Stdout(); !strings.Contains(output, expectedOutput) {
t.Fatalf("Unexpected output:\n%s\n\nwant output containing:\n%s", output, expectedOutput)
t.Errorf("Unexpected output:\n%s\n\nwant output containing:\n%s", output, expectedOutput)
}
}
@ -262,7 +263,7 @@ func TestLocal_planModuleOutputsChanged(t *testing.T) {
}
expectedOutput := strings.TrimSpace(`
No changes. Infrastructure is up-to-date.
No changes. Your infrastructure matches the configuration.
`)
if output := done(t).Stdout(); !strings.Contains(output, expectedOutput) {
t.Fatalf("Unexpected output:\n%s\n\nwant output containing:\n%s", output, expectedOutput)
@ -323,7 +324,7 @@ Terraform will perform the following actions:
Plan: 1 to add, 0 to change, 1 to destroy.`
if output := done(t).Stdout(); !strings.Contains(output, expectedOutput) {
t.Fatalf("Unexpected output:\n%s", output)
t.Fatalf("Unexpected output\ngot\n%s\n\nwant:\n%s", output, expectedOutput)
}
}

View File

@ -74,6 +74,14 @@ func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operatio
))
}
if op.PlanMode == plans.RefreshOnlyMode {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Refresh-only mode is currently not supported",
`The "remote" backend does not currently support the refresh-only planning mode.`,
))
}
if b.hasExplicitVariableValues(op) {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
@ -102,6 +110,14 @@ func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operatio
))
}
if len(op.ForceReplace) != 0 {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Forced replacement is currently not supported",
`The "remote" backend does not currently support the -replace=... planning option.`,
))
}
if len(op.Targets) != 0 {
// For API versions prior to 2.3, RemoteAPIVersion will return an empty string,
// so if there's an error when parsing the RemoteAPIVersion, it's handled as

View File

@ -2103,7 +2103,7 @@ func TestApply_jsonGoldenReference(t *testing.T) {
wantLines := strings.Split(want, "\n")
if len(gotLines) != len(wantLines) {
t.Fatalf("unexpected number of log lines: got %d, want %d", len(gotLines), len(wantLines))
t.Errorf("unexpected number of log lines: got %d, want %d", len(gotLines), len(wantLines))
}
// Verify that the log starts with a version message
@ -2130,26 +2130,30 @@ func TestApply_jsonGoldenReference(t *testing.T) {
}
// Compare the rest of the lines against the golden reference
for i := range gotLines[1:] {
var gotLineMaps []map[string]interface{}
for i, line := range gotLines[1:] {
index := i + 1
var gotMap, wantMap map[string]interface{}
if err := json.Unmarshal([]byte(gotLines[index]), &gotMap); err != nil {
t.Errorf("failed to unmarshal got line %d: %s\n%s", index, err, gotLines[i])
var gotMap map[string]interface{}
if err := json.Unmarshal([]byte(line), &gotMap); err != nil {
t.Errorf("failed to unmarshal got line %d: %s\n%s", index, err, gotLines[index])
}
if err := json.Unmarshal([]byte(wantLines[index]), &wantMap); err != nil {
t.Errorf("failed to unmarshal want line %d: %s\n%s", index, err, wantLines[i])
}
// The timestamp field is the only one that should change, so we drop
// it from the comparison
if _, ok := gotMap["@timestamp"]; !ok {
t.Errorf("missing @timestamp field in log: %s", gotLines[i])
t.Errorf("missing @timestamp field in log: %s", gotLines[index])
}
delete(gotMap, "@timestamp")
if !cmp.Equal(wantMap, gotMap) {
t.Errorf("unexpected log:\n%s", cmp.Diff(wantMap, gotMap))
gotLineMaps = append(gotLineMaps, gotMap)
}
var wantLineMaps []map[string]interface{}
for i, line := range wantLines[1:] {
index := i + 1
var wantMap map[string]interface{}
if err := json.Unmarshal([]byte(line), &wantMap); err != nil {
t.Errorf("failed to unmarshal want line %d: %s\n%s", index, err, gotLines[index])
}
wantLineMaps = append(wantLineMaps, wantMap)
}
if diff := cmp.Diff(wantLineMaps, gotLineMaps); diff != "" {
t.Errorf("wrong output lines\n%s", diff)
}
}

View File

@ -81,6 +81,7 @@ type Operation struct {
targetsRaw []string
forceReplaceRaw []string
destroyRaw bool
refreshOnlyRaw bool
}
// Parse must be called on Operation after initial flag parse. This processes
@ -151,8 +152,23 @@ func (o *Operation) Parse() tfdiags.Diagnostics {
// If you add a new possible value for o.PlanMode here, consider also
// adding a specialized error message for it in ParseApplyDestroy.
switch {
case o.destroyRaw && o.refreshOnlyRaw:
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Incompatible plan mode options",
"The -destroy and -refresh-only options are mutually-exclusive.",
))
case o.destroyRaw:
o.PlanMode = plans.DestroyMode
case o.refreshOnlyRaw:
o.PlanMode = plans.RefreshOnlyMode
if !o.Refresh {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Incompatible refresh options",
"It doesn't make sense to use -refresh-only at the same time as -refresh=false, because Terraform would have nothing to do.",
))
}
default:
o.PlanMode = plans.NormalMode
}
@ -206,6 +222,7 @@ func extendedFlagSet(name string, state *State, operation *Operation, vars *Vars
f.IntVar(&operation.Parallelism, "parallelism", DefaultParallelism, "parallelism")
f.BoolVar(&operation.Refresh, "refresh", true, "refresh")
f.BoolVar(&operation.destroyRaw, "destroy", false, "destroy")
f.BoolVar(&operation.refreshOnlyRaw, "refresh-only", false, "refresh-only")
f.Var((*flagStringSlice)(&operation.targetsRaw), "target", "target")
f.Var((*flagStringSlice)(&operation.forceReplaceRaw), "replace", "replace")
}

View File

@ -165,8 +165,8 @@ func TestPrimaryChdirOption(t *testing.T) {
t.Fatalf("unexpected plan error: %s\nstderr:\n%s", err, stderr)
}
if !strings.Contains(stdout, "0 to add, 0 to change, 0 to destroy") {
t.Errorf("incorrect plan tally; want 0 to add:\n%s", stdout)
if want := "You can apply this plan to save these new output values"; !strings.Contains(stdout, want) {
t.Errorf("missing expected message for an outputs-only plan\ngot:\n%s\n\nwant substring: %s", stdout, want)
}
if !strings.Contains(stdout, "Saved the plan to: tfplan") {

View File

@ -235,12 +235,14 @@ func ResourceInstanceDrift(
}
if after != nil && after.Current != nil {
newObj, err = after.Current.Decode(ty)
// We shouldn't encounter errors here because Terraform Core should've
// made sure that the prior state object conforms to the current
// schema by having the provider upgrade it, even if we skipped
// refreshing on this run, but we'll be robust here in case there are
// some edges we didn't find yet.
return fmt.Sprintf(" # %s refreshed state doesn't conform to current schema; this is a Terraform bug\n # %s\n", addr, err)
if err != nil {
// We shouldn't encounter errors here because Terraform Core should've
// made sure that the prior state object conforms to the current
// schema by having the provider upgrade it, even if we skipped
// refreshing on this run, but we'll be robust here in case there are
// some edges we didn't find yet.
return fmt.Sprintf(" # %s refreshed state doesn't conform to current schema; this is a Terraform bug\n # %s\n", addr, err)
}
}
oldVal := oldObj.Value

View File

@ -31,8 +31,9 @@ type plan struct {
TerraformVersion string `json:"terraform_version,omitempty"`
Variables variables `json:"variables,omitempty"`
PlannedValues stateValues `json:"planned_values,omitempty"`
// ResourceChanges are sorted in a user-friendly order that is undefined at
// this time, but consistent.
// ResourceDrift and ResourceChanges are sorted in a user-friendly order
// that is undefined at this time, but consistent.
ResourceDrift []resourceChange `json:"resource_drift,omitempty"`
ResourceChanges []resourceChange `json:"resource_changes,omitempty"`
OutputChanges map[string]change `json:"output_changes,omitempty"`
PriorState json.RawMessage `json:"prior_state,omitempty"`
@ -128,6 +129,12 @@ func Marshal(
return nil, fmt.Errorf("error in marshalPlannedValues: %s", err)
}
// output.ResourceDrift
err = output.marshalResourceDrift(p.PrevRunState, p.PriorState, schemas)
if err != nil {
return nil, fmt.Errorf("error in marshalResourceDrift: %s", err)
}
// output.ResourceChanges
err = output.marshalResourceChanges(p.Changes, schemas)
if err != nil {
@ -181,6 +188,136 @@ func (p *plan) marshalPlanVariables(vars map[string]plans.DynamicValue, schemas
return nil
}
func (p *plan) marshalResourceDrift(oldState, newState *states.State, schemas *terraform.Schemas) error {
// Our goal here is to build a data structure of the same shape as we use
// to describe planned resource changes, but in this case we'll be
// taking the old and new values from different state snapshots rather
// than from a real "Changes" object.
//
// In doing this we make an assumption that drift detection can only
// ever show objects as updated or removed, and will never show anything
// as created because we only refresh objects we were already tracking
// after the previous run. This means we can use oldState as our baseline
// for what resource instances we might include, and check for each item
// whether it's present in newState. If we ever have some mechanism to
// detect "additive drift" later then we'll need to take a different
// approach here, but we have no plans for that at the time of writing.
//
// We also assume that both states have had all managed resource objects
// upgraded to match the current schemas given in schemas, so we shouldn't
// need to contend with oldState having old-shaped objects even if the
// user changed provider versions since the last run.
if newState.ManagedResourcesEqual(oldState) {
// Nothing to do, because we only detect and report drift for managed
// resource instances.
return nil
}
for _, ms := range oldState.Modules {
for _, rs := range ms.Resources {
if rs.Addr.Resource.Mode != addrs.ManagedResourceMode {
// Drift reporting is only for managed resources
continue
}
provider := rs.ProviderConfig.Provider
for key, oldIS := range rs.Instances {
if oldIS.Current == nil {
// Not interested in instances that only have deposed objects
continue
}
addr := rs.Addr.Instance(key)
newIS := newState.ResourceInstance(addr)
schema, _ := schemas.ResourceTypeConfig(
provider,
addr.Resource.Resource.Mode,
addr.Resource.Resource.Type,
)
if schema == nil {
return fmt.Errorf("no schema found for %s (in provider %s)", addr, provider)
}
ty := schema.ImpliedType()
oldObj, err := oldIS.Current.Decode(ty)
if err != nil {
return fmt.Errorf("failed to decode previous run data for %s: %s", addr, err)
}
var newObj *states.ResourceInstanceObject
if newIS != nil && newIS.Current != nil {
newObj, err = newIS.Current.Decode(ty)
if err != nil {
return fmt.Errorf("failed to decode refreshed data for %s: %s", addr, err)
}
}
var oldVal, newVal cty.Value
oldVal = oldObj.Value
if newObj != nil {
newVal = newObj.Value
} else {
newVal = cty.NullVal(ty)
}
oldSensitive := sensitiveAsBool(oldVal)
newSensitive := sensitiveAsBool(newVal)
oldVal, _ = oldVal.UnmarkDeep()
newVal, _ = newVal.UnmarkDeep()
var before, after []byte
var beforeSensitive, afterSensitive []byte
before, err = ctyjson.Marshal(oldVal, oldVal.Type())
if err != nil {
return fmt.Errorf("failed to encode previous run data for %s as JSON: %s", addr, err)
}
after, err = ctyjson.Marshal(newVal, oldVal.Type())
if err != nil {
return fmt.Errorf("failed to encode refreshed data for %s as JSON: %s", addr, err)
}
beforeSensitive, err = ctyjson.Marshal(oldSensitive, oldSensitive.Type())
if err != nil {
return fmt.Errorf("failed to encode previous run data sensitivity for %s as JSON: %s", addr, err)
}
afterSensitive, err = ctyjson.Marshal(newSensitive, newSensitive.Type())
if err != nil {
return fmt.Errorf("failed to encode refreshed data sensitivity for %s as JSON: %s", addr, err)
}
// We can only detect updates and deletes as drift.
action := plans.Update
if newVal.IsNull() {
action = plans.Delete
}
change := resourceChange{
ModuleAddress: addr.Module.String(),
Mode: "managed", // drift reporting is only for managed resources
Name: addr.Resource.Resource.Name,
Type: addr.Resource.Resource.Type,
ProviderName: provider.String(),
Change: change{
Actions: actionString(action.String()),
Before: json.RawMessage(before),
BeforeSensitive: json.RawMessage(beforeSensitive),
After: json.RawMessage(after),
AfterSensitive: json.RawMessage(afterSensitive),
// AfterUnknown is never populated here because
// values in a state are always fully known.
},
}
p.ResourceDrift = append(p.ResourceDrift, change)
}
}
}
sort.Slice(p.ResourceChanges, func(i, j int) bool {
return p.ResourceChanges[i].Address < p.ResourceChanges[j].Address
})
return nil
}
func (p *plan) marshalResourceChanges(changes *plans.Changes, schemas *terraform.Schemas) error {
if changes == nil {
// Nothing to do!

View File

@ -202,13 +202,19 @@ Plan Customization Options:
can also use these options when you run "terraform apply" without passing
it a saved plan, in order to plan and apply in a single command.
-destroy If set, a plan will be generated to destroy all resources
managed by the given configuration and state.
-destroy Select the "destroy" planning mode, which creates a plan
to destroy all objects currently managed by this
Terraform configuration instead of the usual behavior.
-refresh=false Skip checking for changes to remote objects while
creating the plan. This can potentially make planning
faster, but at the expense of possibly planning against
a stale record of the remote system state.
-refresh-only Select the "refresh only" planning mode, which checks
whether remote objects still match the outcome of the
most recent Terraform apply but does not propose any
actions to undo any changes made outside of Terraform.
-refresh=false Skip checking for external changes to remote objects
while creating the plan. This can potentially make
planning faster, but at the expense of possibly planning
against a stale record of the remote system state.
-replace=resource Force replacement of a particular resource instance using
its resource address. If the plan would've normally
@ -221,17 +227,19 @@ Plan Customization Options:
include more than one object. This is for exceptional
use only.
-var 'foo=bar' Set a variable in the Terraform configuration. This
flag can be set multiple times.
-var 'foo=bar' Set a value for one of the input variables in the root
module of the configuration. Use this option more than
once to set more than one variable.
-var-file=foo Set variables in the Terraform configuration from
a file. If "terraform.tfvars" or any ".auto.tfvars"
files are present, they will be automatically loaded.
-var-file=filename Load variable values from the given file, in addition
to the default files terraform.tfvars and *.auto.tfvars.
Use this option more than once to include more than one
variables file.
Other Options:
-compact-warnings If Terraform produces any warnings that are not
accompanied by errors, show them in a more compact form
accompanied by errors, shows them in a more compact form
that includes only the summary messages.
-detailed-exitcode Return detailed exit codes when the command exits. This

View File

@ -140,7 +140,7 @@ func TestShow_noArgsNoState(t *testing.T) {
}
}
func TestShow_plan(t *testing.T) {
func TestShow_planNoop(t *testing.T) {
planPath := testPlanFileNoop(t)
ui := cli.NewMockUi()
@ -160,7 +160,7 @@ func TestShow_plan(t *testing.T) {
t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
}
want := `Terraform will perform the following actions`
want := `No changes. Your infrastructure matches the configuration.`
got := done(t).Stdout()
if !strings.Contains(got, want) {
t.Errorf("missing expected output\nwant: %s\ngot:\n%s", want, got)

View File

@ -1,5 +1,6 @@
{"@level":"info","@message":"Terraform 0.15.0-dev","@module":"terraform.ui","terraform":"0.15.0-dev","type":"version","ui":"0.1.0"}
{"@level":"info","@message":"test_instance.foo: Plan to create","@module":"terraform.ui","change":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create"},"type":"planned_change"}
{"@level":"info","@message":"Plan: 1 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","changes":{"add":1,"change":0,"remove":0,"operation":"plan"},"type":"change_summary"}
{"@level":"info","@message":"test_instance.foo: Creating...","@module":"terraform.ui","hook":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create"},"type":"apply_start"}
{"@level":"info","@message":"test_instance.foo: Creation complete after 0s","@module":"terraform.ui","hook":{"resource":{"addr":"test_instance.foo","module":"","resource":"test_instance.foo","implied_provider":"test","resource_type":"test_instance","resource_name":"foo","resource_key":null},"action":"create","elapsed_seconds":0},"type":"apply_complete"}
{"@level":"info","@message":"Apply complete! Resources: 1 added, 0 changed, 0 destroyed.","@module":"terraform.ui","changes":{"add":1,"change":0,"remove":0,"operation":"apply"},"type":"change_summary"}

View File

@ -11,6 +11,7 @@ const (
// Operation results
MessagePlannedChange MessageType = "planned_change"
MessageChangeSummary MessageType = "change_summary"
MessageDriftSummary MessageType = "drift_summary"
MessageOutputs MessageType = "outputs"
// Hook-driven messages

View File

@ -103,6 +103,14 @@ func (v *JSONView) ChangeSummary(cs *json.ChangeSummary) {
)
}
func (v *JSONView) DriftSummary(cs *json.ChangeSummary) {
v.log.Info(
cs.String(),
"type", json.MessageDriftSummary,
"changes", cs,
)
}
func (v *JSONView) Hook(h json.Hook) {
v.log.Info(
h.String(),

View File

@ -24,7 +24,6 @@ type Operation interface {
EmergencyDumpState(stateFile *statefile.File) error
PlannedChange(change *plans.ResourceInstanceChangeSrc)
PlanNoChanges()
Plan(plan *plans.Plan, schemas *terraform.Schemas)
PlanNextStep(planPath string)
@ -86,16 +85,15 @@ func (v *OperationHuman) EmergencyDumpState(stateFile *statefile.File) error {
return nil
}
func (v *OperationHuman) PlanNoChanges() {
v.view.streams.Println("\n" + v.view.colorize.Color(strings.TrimSpace(planNoChanges)))
v.view.streams.Println("\n" + strings.TrimSpace(format.WordWrap(planNoChangesDetail, v.view.outputColumns())))
}
func (v *OperationHuman) Plan(plan *plans.Plan, schemas *terraform.Schemas) {
renderPlan(plan, schemas, v.view)
}
func (v *OperationHuman) PlannedChange(change *plans.ResourceInstanceChangeSrc) {
// PlannedChange is primarily for machine-readable output in order to
// get a per-resource-instance change description. We don't use it
// with OperationHuman because the output of Plan already includes the
// change details for all resource instances.
}
// PlanNextStep gives the user some next-steps, unless we're running in an
@ -159,16 +157,6 @@ func (v *OperationJSON) EmergencyDumpState(stateFile *statefile.File) error {
return nil
}
// Log an empty change summary.
func (v *OperationJSON) PlanNoChanges() {
v.view.ChangeSummary(&json.ChangeSummary{
Add: 0,
Change: 0,
Remove: 0,
Operation: json.OperationPlanned,
})
}
// Log a change summary and a series of "planned" messages for the changes in
// the plan.
func (v *OperationJSON) Plan(plan *plans.Plan, schemas *terraform.Schemas) {
@ -227,14 +215,6 @@ Please wait for Terraform to exit or data loss may occur.
Gracefully shutting down...
`
const planNoChanges = `
[reset][bold][green]No changes. Infrastructure is up-to-date.[reset][green]
`
const planNoChangesDetail = `
This means that Terraform did not detect any differences between your configuration and the remote system(s). As a result, there are no actions to take.
`
const planHeaderNoOutput = `
Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform apply" now.
`

View File

@ -10,7 +10,9 @@ import (
"github.com/hashicorp/terraform/command/arguments"
"github.com/hashicorp/terraform/internal/terminal"
"github.com/hashicorp/terraform/plans"
"github.com/hashicorp/terraform/states"
"github.com/hashicorp/terraform/states/statefile"
"github.com/hashicorp/terraform/terraform"
)
func TestOperation_stopping(t *testing.T) {
@ -72,13 +74,130 @@ func TestOperation_emergencyDumpState(t *testing.T) {
}
func TestOperation_planNoChanges(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
v := NewOperation(arguments.ViewHuman, false, NewView(streams))
v.PlanNoChanges()
tests := map[string]struct {
plan func(schemas *terraform.Schemas) *plans.Plan
wantText string
}{
"nothing at all in normal mode": {
func(schemas *terraform.Schemas) *plans.Plan {
return &plans.Plan{
UIMode: plans.NormalMode,
Changes: plans.NewChanges(),
PrevRunState: states.NewState(),
PriorState: states.NewState(),
}
},
"no differences, so no changes are needed.",
},
"nothing at all in refresh-only mode": {
func(schemas *terraform.Schemas) *plans.Plan {
return &plans.Plan{
UIMode: plans.RefreshOnlyMode,
Changes: plans.NewChanges(),
PrevRunState: states.NewState(),
PriorState: states.NewState(),
}
},
"Terraform has checked that the real remote objects still match",
},
"nothing at all in destroy mode": {
func(schemas *terraform.Schemas) *plans.Plan {
return &plans.Plan{
UIMode: plans.DestroyMode,
Changes: plans.NewChanges(),
PrevRunState: states.NewState(),
PriorState: states.NewState(),
}
},
"No objects need to be destroyed.",
},
"drift detected in normal mode": {
func(schemas *terraform.Schemas) *plans.Plan {
return &plans.Plan{
UIMode: plans.NormalMode,
Changes: plans.NewChanges(),
PrevRunState: states.BuildState(func(state *states.SyncState) {
state.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "something",
Name: "somewhere",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
AttrsJSON: []byte(`{}`),
},
addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewBuiltInProvider("test")),
)
}),
PriorState: states.NewState(),
}
},
"to update the Terraform state to match, create and apply a refresh-only plan",
},
"drift detected in refresh-only mode": {
func(schemas *terraform.Schemas) *plans.Plan {
return &plans.Plan{
UIMode: plans.RefreshOnlyMode,
Changes: plans.NewChanges(),
PrevRunState: states.BuildState(func(state *states.SyncState) {
state.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "something",
Name: "somewhere",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
AttrsJSON: []byte(`{}`),
},
addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewBuiltInProvider("test")),
)
}),
PriorState: states.NewState(),
}
},
"If you were expecting these changes then you can apply this plan",
},
"drift detected in destroy mode": {
func(schemas *terraform.Schemas) *plans.Plan {
return &plans.Plan{
UIMode: plans.DestroyMode,
Changes: plans.NewChanges(),
PrevRunState: states.BuildState(func(state *states.SyncState) {
state.SetResourceInstanceCurrent(
addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "something",
Name: "somewhere",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
&states.ResourceInstanceObjectSrc{
Status: states.ObjectReady,
AttrsJSON: []byte(`{}`),
},
addrs.RootModuleInstance.ProviderConfigDefault(addrs.NewBuiltInProvider("test")),
)
}),
PriorState: states.NewState(),
}
},
"No objects need to be destroyed.",
},
}
if got, want := done(t).Stdout(), "No changes. Infrastructure is up-to-date."; !strings.Contains(got, want) {
t.Errorf("wrong result\ngot: %q\nwant: %q", got, want)
schemas := testSchemas()
for name, test := range tests {
t.Run(name, func(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
v := NewOperation(arguments.ViewHuman, false, NewView(streams))
plan := test.plan(schemas)
v.Plan(plan, schemas)
got := done(t).Stdout()
if want := test.wantText; want != "" && !strings.Contains(got, want) {
t.Errorf("missing expected message\ngot:\n%s\n\nwant substring: %s", got, want)
}
})
}
}
@ -242,7 +361,10 @@ func TestOperationJSON_planNoChanges(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
v := &OperationJSON{view: NewJSONView(NewView(streams))}
v.PlanNoChanges()
plan := &plans.Plan{
Changes: plans.NewChanges(),
}
v.Plan(plan, nil)
want := []map[string]interface{}{
{

View File

@ -111,13 +111,14 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) {
view.outputColumns(),
))
}
view.streams.Print(format.HorizontalRule(view.colorize, view.outputColumns()))
view.streams.Println("")
}
counts := map[plans.Action]int{}
var rChanges []*plans.ResourceInstanceChangeSrc
for _, change := range plan.Changes.Resources {
if change.Action == plans.NoOp {
continue // We don't show anything for no-op changes
}
if change.Action == plans.Delete && change.Addr.Resource.Resource.Mode == addrs.DataResourceMode {
// Avoid rendering data sources on deletion
continue
@ -126,90 +127,6 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) {
rChanges = append(rChanges, change)
counts[change.Action]++
}
headerBuf := &bytes.Buffer{}
fmt.Fprintf(headerBuf, "\n%s\n", strings.TrimSpace(format.WordWrap(planHeaderIntro, view.outputColumns())))
if counts[plans.Create] > 0 {
fmt.Fprintf(headerBuf, "%s create\n", format.DiffActionSymbol(plans.Create))
}
if counts[plans.Update] > 0 {
fmt.Fprintf(headerBuf, "%s update in-place\n", format.DiffActionSymbol(plans.Update))
}
if counts[plans.Delete] > 0 {
fmt.Fprintf(headerBuf, "%s destroy\n", format.DiffActionSymbol(plans.Delete))
}
if counts[plans.DeleteThenCreate] > 0 {
fmt.Fprintf(headerBuf, "%s destroy and then create replacement\n", format.DiffActionSymbol(plans.DeleteThenCreate))
}
if counts[plans.CreateThenDelete] > 0 {
fmt.Fprintf(headerBuf, "%s create replacement and then destroy\n", format.DiffActionSymbol(plans.CreateThenDelete))
}
if counts[plans.Read] > 0 {
fmt.Fprintf(headerBuf, "%s read (data resources)\n", format.DiffActionSymbol(plans.Read))
}
view.streams.Println(view.colorize.Color(headerBuf.String()))
view.streams.Printf("Terraform will perform the following actions:\n\n")
// Note: we're modifying the backing slice of this plan object in-place
// here. The ordering of resource changes in a plan is not significant,
// but we can only do this safely here because we can assume that nobody
// is concurrently modifying our changes while we're trying to print it.
sort.Slice(rChanges, func(i, j int) bool {
iA := rChanges[i].Addr
jA := rChanges[j].Addr
if iA.String() == jA.String() {
return rChanges[i].DeposedKey < rChanges[j].DeposedKey
}
return iA.Less(jA)
})
for _, rcs := range rChanges {
if rcs.Action == plans.NoOp {
continue
}
providerSchema := schemas.ProviderSchema(rcs.ProviderAddr.Provider)
if providerSchema == nil {
// Should never happen
view.streams.Printf("(schema missing for %s)\n\n", rcs.ProviderAddr)
continue
}
rSchema, _ := providerSchema.SchemaForResourceAddr(rcs.Addr.Resource.Resource)
if rSchema == nil {
// Should never happen
view.streams.Printf("(schema missing for %s)\n\n", rcs.Addr)
continue
}
view.streams.Println(format.ResourceChange(
rcs,
rSchema,
view.colorize,
))
}
// stats is similar to counts above, but:
// - it considers only resource changes
// - it simplifies "replace" into both a create and a delete
stats := map[plans.Action]int{}
for _, change := range rChanges {
switch change.Action {
case plans.CreateThenDelete, plans.DeleteThenCreate:
stats[plans.Create]++
stats[plans.Delete]++
default:
stats[change.Action]++
}
}
view.streams.Printf(
view.colorize.Color("[reset][bold]Plan:[reset] %d to add, %d to change, %d to destroy.\n"),
stats[plans.Create], stats[plans.Update], stats[plans.Delete],
)
// If there is at least one planned change to the root module outputs
// then we'll render a summary of those too.
var changedRootModuleOutputs []*plans.OutputChangeSrc
for _, output := range plan.Changes.Outputs {
if !output.Addr.Module.IsRoot() {
@ -220,11 +137,195 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) {
}
changedRootModuleOutputs = append(changedRootModuleOutputs, output)
}
if len(counts) == 0 && len(changedRootModuleOutputs) == 0 {
// If we didn't find any changes to report at all then this is a
// "No changes" plan. How we'll present this depends on whether
// the plan is "applyable" and, if so, whether it had refresh changes
// that we already would've presented above.
switch plan.UIMode {
case plans.RefreshOnlyMode:
if haveRefreshChanges {
// We already generated a sufficient prompt about what will
// happen if applying this change above, so we don't need to
// say anything more.
return
}
view.streams.Print(
view.colorize.Color("\n[reset][bold][green]No changes.[reset][bold] Your infrastructure still matches the configuration.[reset]\n\n"),
)
view.streams.Println(format.WordWrap(
"Terraform has checked that the real remote objects still match the result of your most recent changes, and found no differences.",
view.outputColumns(),
))
case plans.DestroyMode:
if haveRefreshChanges {
view.streams.Print(format.HorizontalRule(view.colorize, view.outputColumns()))
view.streams.Println("")
}
view.streams.Print(
view.colorize.Color("\n[reset][bold][green]No changes.[reset][bold] No objects need to be destroyed.[reset]\n\n"),
)
view.streams.Println(format.WordWrap(
"Either you have not created any objects yet or the existing objects were already deleted outside of Terraform.",
view.outputColumns(),
))
default:
if haveRefreshChanges {
view.streams.Print(format.HorizontalRule(view.colorize, view.outputColumns()))
view.streams.Println("")
}
view.streams.Print(
view.colorize.Color("\n[reset][bold][green]No changes.[reset][bold] Your infrastructure matches the configuration.[reset]\n\n"),
)
if haveRefreshChanges && !plan.CanApply() {
if plan.CanApply() {
// In this case, applying this plan will not change any
// remote objects but _will_ update the state to match what
// we detected during refresh, so we'll reassure the user
// about that.
view.streams.Println(format.WordWrap(
"Your configuration already matches the changes detected above, so applying this plan will only update the state to include the changes detected above and won't change any real infrastructure.",
view.outputColumns(),
))
} else {
// In this case we detected changes during refresh but this isn't
// a planning mode where we consider those to be applyable. The
// user must re-run in refresh-only mode in order to update the
// state to match the upstream changes.
suggestion := "."
if !view.runningInAutomation {
// The normal message includes a specific command line to run.
suggestion = ":\n terraform apply -refresh-only"
}
view.streams.Println(format.WordWrap(
"Your configuration already matches the changes detected above. If you'd like to update the Terraform state to match, create and apply a refresh-only plan"+suggestion,
view.outputColumns(),
))
}
return
}
// If we get down here then we're just in the simple situation where
// the plan isn't applyable at all.
view.streams.Println(format.WordWrap(
"Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed.",
view.outputColumns(),
))
}
return
}
if haveRefreshChanges {
view.streams.Print(format.HorizontalRule(view.colorize, view.outputColumns()))
view.streams.Println("")
}
if len(counts) != 0 {
headerBuf := &bytes.Buffer{}
fmt.Fprintf(headerBuf, "\n%s\n", strings.TrimSpace(format.WordWrap(planHeaderIntro, view.outputColumns())))
if counts[plans.Create] > 0 {
fmt.Fprintf(headerBuf, "%s create\n", format.DiffActionSymbol(plans.Create))
}
if counts[plans.Update] > 0 {
fmt.Fprintf(headerBuf, "%s update in-place\n", format.DiffActionSymbol(plans.Update))
}
if counts[plans.Delete] > 0 {
fmt.Fprintf(headerBuf, "%s destroy\n", format.DiffActionSymbol(plans.Delete))
}
if counts[plans.DeleteThenCreate] > 0 {
fmt.Fprintf(headerBuf, "%s destroy and then create replacement\n", format.DiffActionSymbol(plans.DeleteThenCreate))
}
if counts[plans.CreateThenDelete] > 0 {
fmt.Fprintf(headerBuf, "%s create replacement and then destroy\n", format.DiffActionSymbol(plans.CreateThenDelete))
}
if counts[plans.Read] > 0 {
fmt.Fprintf(headerBuf, "%s read (data resources)\n", format.DiffActionSymbol(plans.Read))
}
view.streams.Println(view.colorize.Color(headerBuf.String()))
view.streams.Printf("Terraform will perform the following actions:\n\n")
// Note: we're modifying the backing slice of this plan object in-place
// here. The ordering of resource changes in a plan is not significant,
// but we can only do this safely here because we can assume that nobody
// is concurrently modifying our changes while we're trying to print it.
sort.Slice(rChanges, func(i, j int) bool {
iA := rChanges[i].Addr
jA := rChanges[j].Addr
if iA.String() == jA.String() {
return rChanges[i].DeposedKey < rChanges[j].DeposedKey
}
return iA.Less(jA)
})
for _, rcs := range rChanges {
if rcs.Action == plans.NoOp {
continue
}
providerSchema := schemas.ProviderSchema(rcs.ProviderAddr.Provider)
if providerSchema == nil {
// Should never happen
view.streams.Printf("(schema missing for %s)\n\n", rcs.ProviderAddr)
continue
}
rSchema, _ := providerSchema.SchemaForResourceAddr(rcs.Addr.Resource.Resource)
if rSchema == nil {
// Should never happen
view.streams.Printf("(schema missing for %s)\n\n", rcs.Addr)
continue
}
view.streams.Println(format.ResourceChange(
rcs,
rSchema,
view.colorize,
))
}
// stats is similar to counts above, but:
// - it considers only resource changes
// - it simplifies "replace" into both a create and a delete
stats := map[plans.Action]int{}
for _, change := range rChanges {
switch change.Action {
case plans.CreateThenDelete, plans.DeleteThenCreate:
stats[plans.Create]++
stats[plans.Delete]++
default:
stats[change.Action]++
}
}
view.streams.Printf(
view.colorize.Color("[reset][bold]Plan:[reset] %d to add, %d to change, %d to destroy.\n"),
stats[plans.Create], stats[plans.Update], stats[plans.Delete],
)
}
// If there is at least one planned change to the root module outputs
// then we'll render a summary of those too.
if len(changedRootModuleOutputs) > 0 {
view.streams.Println(
view.colorize.Color("[reset]\n[bold]Changes to Outputs:[reset]") +
format.OutputChanges(changedRootModuleOutputs, view.colorize),
)
if len(counts) == 0 {
// If we have output changes but not resource changes then we
// won't have output any indication about the changes at all yet,
// so we need some extra context about what it would mean to
// apply a change that _only_ includes output changes.
view.streams.Println(format.WordWrap(
"\nYou can apply this plan to save these new output values to the Terraform state, without changing any real infrastructure.",
view.outputColumns(),
))
}
}
}

View File

@ -55,36 +55,44 @@ type Plan struct {
PriorState *states.State
}
// Backend represents the backend-related configuration and other data as it
// existed when a plan was created.
type Backend struct {
// Type is the type of backend that the plan will apply against.
Type string
// CanApply returns true if and only if the recieving plan includes content
// that would make sense to apply. If it returns false, the plan operation
// should indicate that there's nothing to do and Terraform should exit
// without prompting the user to confirm the changes.
//
// This function represents our main business logic for making the decision
// about whether a given plan represents meaningful "changes", and so its
// exact definition may change over time; the intent is just to centralize the
// rules for that rather than duplicating different versions of it at various
// locations in the UI code.
func (p *Plan) CanApply() bool {
switch {
case !p.Changes.Empty():
// "Empty" means that everything in the changes is a "NoOp", so if
// not empty then there's at least one non-NoOp change.
return true
// Config is the configuration of the backend, whose schema is decided by
// the backend Type.
Config DynamicValue
case !p.PriorState.ManagedResourcesEqual(p.PrevRunState):
// If there are no changes planned but we detected some
// outside-Terraform changes while refreshing then we consider
// that applyable in isolation only if this was a refresh-only
// plan where we expect updating the state to include these
// changes was the intended goal.
//
// (We don't treat a "refresh only" plan as applyable in normal
// planning mode because historically the refresh result wasn't
// considered part of a plan at all, and so it would be
// a disruptive breaking change if refreshing alone suddenly
// became applyable in the normal case and an existing configuration
// was relying on ignore_changes in order to be convergent in spite
// of intentional out-of-band operations.)
return p.UIMode == RefreshOnlyMode
// Workspace is the name of the workspace that was active when the plan
// was created. It is illegal to apply a plan created for one workspace
// to the state of another workspace.
// (This constraint is already enforced by the statefile lineage mechanism,
// but storing this explicitly allows us to return a better error message
// in the situation where the user has the wrong workspace selected.)
Workspace string
}
func NewBackend(typeName string, config cty.Value, configSchema *configschema.Block, workspaceName string) (*Backend, error) {
dv, err := NewDynamicValue(config, configSchema.ImpliedType())
if err != nil {
return nil, err
default:
// Otherwise, there are either no changes to apply or they are changes
// our cases above don't consider as worthy of applying in isolation.
return false
}
return &Backend{
Type: typeName,
Config: dv,
Workspace: workspaceName,
}, nil
}
// ProviderAddrs returns a list of all of the provider configuration addresses
@ -118,3 +126,35 @@ func (p *Plan) ProviderAddrs() []addrs.AbsProviderConfig {
return ret
}
// Backend represents the backend-related configuration and other data as it
// existed when a plan was created.
type Backend struct {
// Type is the type of backend that the plan will apply against.
Type string
// Config is the configuration of the backend, whose schema is decided by
// the backend Type.
Config DynamicValue
// Workspace is the name of the workspace that was active when the plan
// was created. It is illegal to apply a plan created for one workspace
// to the state of another workspace.
// (This constraint is already enforced by the statefile lineage mechanism,
// but storing this explicitly allows us to return a better error message
// in the situation where the user has the wrong workspace selected.)
Workspace string
}
func NewBackend(typeName string, config cty.Value, configSchema *configschema.Block, workspaceName string) (*Backend, error) {
dv, err := NewDynamicValue(config, configSchema.ImpliedType())
if err != nil {
return nil, err
}
return &Backend{
Type: typeName,
Config: dv,
Workspace: workspaceName,
}, nil
}

View File

@ -86,7 +86,7 @@ The section above described Terraform's default planning behavior, which is
intended for changing the remote system to match with changes you've made to
your configuration.
Terraform has one alternative planning mode, which creates a plan with
Terraform has two alternative planning modes, each of which creates a plan with
a different intended outcome:
* **Destroy mode:** creates a plan whose goal is to destroy all remote objects
@ -96,6 +96,15 @@ a different intended outcome:
Activate destroy mode using the `-destroy` command line option.
* **Refresh-only mode:** creates a plan whose goal is only to update the
Terraform state and any root module output values to match changes made to
remote objects outside of Terraform. This can be useful if you've
intentionally changed one or more remote objects outside of the usual
workflow (e.g. while responding to an incident) and you now need to reconcile
Terraform's records with those changes.
Activate refresh-only mode using the `-refresh-only` command line option.
In situations where we need to discuss the default planning mode that Terraform
uses when none of the alternative modes are selected, we refer to it as
"Normal mode". Because these alternative modes are for specialized situations