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:
parent
ce69c3903f
commit
3c8a4e6e05
|
@ -72,12 +72,15 @@ func (b *Local) opApply(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
trivialPlan := plan.Changes.Empty()
|
trivialPlan := !plan.CanApply()
|
||||||
hasUI := op.UIOut != nil && op.UIIn != nil
|
hasUI := op.UIOut != nil && op.UIIn != nil
|
||||||
mustConfirm := hasUI && !op.AutoApprove && !trivialPlan
|
mustConfirm := hasUI && !op.AutoApprove && !trivialPlan
|
||||||
|
op.View.Plan(plan, tfCtx.Schemas())
|
||||||
|
|
||||||
if mustConfirm {
|
if mustConfirm {
|
||||||
var desc, query string
|
var desc, query string
|
||||||
if op.PlanMode == plans.DestroyMode {
|
switch op.PlanMode {
|
||||||
|
case plans.DestroyMode:
|
||||||
if op.Workspace != "default" {
|
if op.Workspace != "default" {
|
||||||
query = "Do you really want to destroy all resources in workspace \"" + op.Workspace + "\"?"
|
query = "Do you really want to destroy all resources in workspace \"" + op.Workspace + "\"?"
|
||||||
} else {
|
} else {
|
||||||
|
@ -85,7 +88,15 @@ func (b *Local) opApply(
|
||||||
}
|
}
|
||||||
desc = "Terraform will destroy all your managed infrastructure, as shown above.\n" +
|
desc = "Terraform will destroy all your managed infrastructure, as shown above.\n" +
|
||||||
"There is no undo. Only 'yes' will be accepted to confirm."
|
"There is no undo. Only 'yes' will be accepted to confirm."
|
||||||
|
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 {
|
} 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" {
|
if op.Workspace != "default" {
|
||||||
query = "Do you want to perform these actions in workspace \"" + op.Workspace + "\"?"
|
query = "Do you want to perform these actions in workspace \"" + op.Workspace + "\"?"
|
||||||
} else {
|
} else {
|
||||||
|
@ -95,10 +106,6 @@ func (b *Local) opApply(
|
||||||
"Only 'yes' will be accepted to approve."
|
"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,
|
// We'll show any accumulated warnings before we display the prompt,
|
||||||
// so the user can consider them when deciding how to answer.
|
// so the user can consider them when deciding how to answer.
|
||||||
if len(diags) > 0 {
|
if len(diags) > 0 {
|
||||||
|
@ -121,12 +128,6 @@ func (b *Local) opApply(
|
||||||
runningOp.Result = backend.OperationFailure
|
runningOp.Result = backend.OperationFailure
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
for _, change := range plan.Changes.Resources {
|
|
||||||
if change.Action != plans.NoOp {
|
|
||||||
op.View.PlannedChange(change)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
plan, err := op.PlanFile.ReadPlan()
|
plan, err := op.PlanFile.ReadPlan()
|
||||||
|
|
|
@ -98,7 +98,7 @@ func (b *Local) opPlan(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record whether this plan includes any side-effects that could be applied.
|
// 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
|
// Save the plan to disk
|
||||||
if path := op.PlanOutPath; path != "" {
|
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
|
// Render the plan
|
||||||
op.View.Plan(plan, tfCtx.Schemas())
|
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.
|
// errors then we would've returned early at some other point above.
|
||||||
op.View.Diagnostics(diags)
|
op.View.Diagnostics(diags)
|
||||||
|
|
||||||
|
if !runningOp.PlanEmpty {
|
||||||
op.View.PlanNextStep(op.PlanOutPath)
|
op.View.PlanNextStep(op.PlanOutPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -202,22 +202,23 @@ func TestLocal_planOutputsChanged(t *testing.T) {
|
||||||
t.Fatalf("plan operation failed")
|
t.Fatalf("plan operation failed")
|
||||||
}
|
}
|
||||||
if run.PlanEmpty {
|
if run.PlanEmpty {
|
||||||
t.Fatal("plan should not be empty")
|
t.Error("plan should not be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedOutput := strings.TrimSpace(`
|
expectedOutput := strings.TrimSpace(`
|
||||||
Plan: 0 to add, 0 to change, 0 to destroy.
|
|
||||||
|
|
||||||
Changes to Outputs:
|
Changes to Outputs:
|
||||||
+ added = "after"
|
+ added = "after"
|
||||||
~ changed = "before" -> "after"
|
~ changed = "before" -> "after"
|
||||||
- removed = "before" -> null
|
- removed = "before" -> null
|
||||||
~ sensitive_after = (sensitive value)
|
~ sensitive_after = (sensitive value)
|
||||||
~ sensitive_before = (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) {
|
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(`
|
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) {
|
if output := done(t).Stdout(); !strings.Contains(output, expectedOutput) {
|
||||||
t.Fatalf("Unexpected output:\n%s\n\nwant output containing:\n%s", 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.`
|
Plan: 1 to add, 0 to change, 1 to destroy.`
|
||||||
if output := done(t).Stdout(); !strings.Contains(output, expectedOutput) {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
if b.hasExplicitVariableValues(op) {
|
||||||
diags = diags.Append(tfdiags.Sourceless(
|
diags = diags.Append(tfdiags.Sourceless(
|
||||||
tfdiags.Error,
|
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 {
|
if len(op.Targets) != 0 {
|
||||||
// For API versions prior to 2.3, RemoteAPIVersion will return an empty string,
|
// 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
|
// so if there's an error when parsing the RemoteAPIVersion, it's handled as
|
||||||
|
|
|
@ -2103,7 +2103,7 @@ func TestApply_jsonGoldenReference(t *testing.T) {
|
||||||
wantLines := strings.Split(want, "\n")
|
wantLines := strings.Split(want, "\n")
|
||||||
|
|
||||||
if len(gotLines) != len(wantLines) {
|
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
|
// 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
|
// 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
|
index := i + 1
|
||||||
var gotMap, wantMap map[string]interface{}
|
var gotMap map[string]interface{}
|
||||||
if err := json.Unmarshal([]byte(gotLines[index]), &gotMap); err != nil {
|
if err := json.Unmarshal([]byte(line), &gotMap); err != nil {
|
||||||
t.Errorf("failed to unmarshal got line %d: %s\n%s", index, err, gotLines[i])
|
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 {
|
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")
|
delete(gotMap, "@timestamp")
|
||||||
|
gotLineMaps = append(gotLineMaps, gotMap)
|
||||||
if !cmp.Equal(wantMap, gotMap) {
|
|
||||||
t.Errorf("unexpected log:\n%s", cmp.Diff(wantMap, 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -81,6 +81,7 @@ type Operation struct {
|
||||||
targetsRaw []string
|
targetsRaw []string
|
||||||
forceReplaceRaw []string
|
forceReplaceRaw []string
|
||||||
destroyRaw bool
|
destroyRaw bool
|
||||||
|
refreshOnlyRaw bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse must be called on Operation after initial flag parse. This processes
|
// 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
|
// If you add a new possible value for o.PlanMode here, consider also
|
||||||
// adding a specialized error message for it in ParseApplyDestroy.
|
// adding a specialized error message for it in ParseApplyDestroy.
|
||||||
switch {
|
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:
|
case o.destroyRaw:
|
||||||
o.PlanMode = plans.DestroyMode
|
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:
|
default:
|
||||||
o.PlanMode = plans.NormalMode
|
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.IntVar(&operation.Parallelism, "parallelism", DefaultParallelism, "parallelism")
|
||||||
f.BoolVar(&operation.Refresh, "refresh", true, "refresh")
|
f.BoolVar(&operation.Refresh, "refresh", true, "refresh")
|
||||||
f.BoolVar(&operation.destroyRaw, "destroy", false, "destroy")
|
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.targetsRaw), "target", "target")
|
||||||
f.Var((*flagStringSlice)(&operation.forceReplaceRaw), "replace", "replace")
|
f.Var((*flagStringSlice)(&operation.forceReplaceRaw), "replace", "replace")
|
||||||
}
|
}
|
||||||
|
|
|
@ -165,8 +165,8 @@ func TestPrimaryChdirOption(t *testing.T) {
|
||||||
t.Fatalf("unexpected plan error: %s\nstderr:\n%s", err, stderr)
|
t.Fatalf("unexpected plan error: %s\nstderr:\n%s", err, stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.Contains(stdout, "0 to add, 0 to change, 0 to destroy") {
|
if want := "You can apply this plan to save these new output values"; !strings.Contains(stdout, want) {
|
||||||
t.Errorf("incorrect plan tally; want 0 to add:\n%s", stdout)
|
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") {
|
if !strings.Contains(stdout, "Saved the plan to: tfplan") {
|
||||||
|
|
|
@ -235,6 +235,7 @@ func ResourceInstanceDrift(
|
||||||
}
|
}
|
||||||
if after != nil && after.Current != nil {
|
if after != nil && after.Current != nil {
|
||||||
newObj, err = after.Current.Decode(ty)
|
newObj, err = after.Current.Decode(ty)
|
||||||
|
if err != nil {
|
||||||
// We shouldn't encounter errors here because Terraform Core should've
|
// We shouldn't encounter errors here because Terraform Core should've
|
||||||
// made sure that the prior state object conforms to the current
|
// made sure that the prior state object conforms to the current
|
||||||
// schema by having the provider upgrade it, even if we skipped
|
// schema by having the provider upgrade it, even if we skipped
|
||||||
|
@ -242,6 +243,7 @@ func ResourceInstanceDrift(
|
||||||
// some edges we didn't find yet.
|
// 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)
|
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
|
oldVal := oldObj.Value
|
||||||
var newVal cty.Value
|
var newVal cty.Value
|
||||||
|
|
|
@ -31,8 +31,9 @@ type plan struct {
|
||||||
TerraformVersion string `json:"terraform_version,omitempty"`
|
TerraformVersion string `json:"terraform_version,omitempty"`
|
||||||
Variables variables `json:"variables,omitempty"`
|
Variables variables `json:"variables,omitempty"`
|
||||||
PlannedValues stateValues `json:"planned_values,omitempty"`
|
PlannedValues stateValues `json:"planned_values,omitempty"`
|
||||||
// ResourceChanges are sorted in a user-friendly order that is undefined at
|
// ResourceDrift and ResourceChanges are sorted in a user-friendly order
|
||||||
// this time, but consistent.
|
// that is undefined at this time, but consistent.
|
||||||
|
ResourceDrift []resourceChange `json:"resource_drift,omitempty"`
|
||||||
ResourceChanges []resourceChange `json:"resource_changes,omitempty"`
|
ResourceChanges []resourceChange `json:"resource_changes,omitempty"`
|
||||||
OutputChanges map[string]change `json:"output_changes,omitempty"`
|
OutputChanges map[string]change `json:"output_changes,omitempty"`
|
||||||
PriorState json.RawMessage `json:"prior_state,omitempty"`
|
PriorState json.RawMessage `json:"prior_state,omitempty"`
|
||||||
|
@ -128,6 +129,12 @@ func Marshal(
|
||||||
return nil, fmt.Errorf("error in marshalPlannedValues: %s", err)
|
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
|
// output.ResourceChanges
|
||||||
err = output.marshalResourceChanges(p.Changes, schemas)
|
err = output.marshalResourceChanges(p.Changes, schemas)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -181,6 +188,136 @@ func (p *plan) marshalPlanVariables(vars map[string]plans.DynamicValue, schemas
|
||||||
return nil
|
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 {
|
func (p *plan) marshalResourceChanges(changes *plans.Changes, schemas *terraform.Schemas) error {
|
||||||
if changes == nil {
|
if changes == nil {
|
||||||
// Nothing to do!
|
// Nothing to do!
|
||||||
|
|
|
@ -202,13 +202,19 @@ Plan Customization Options:
|
||||||
can also use these options when you run "terraform apply" without passing
|
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.
|
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
|
-destroy Select the "destroy" planning mode, which creates a plan
|
||||||
managed by the given configuration and state.
|
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
|
-refresh-only Select the "refresh only" planning mode, which checks
|
||||||
creating the plan. This can potentially make planning
|
whether remote objects still match the outcome of the
|
||||||
faster, but at the expense of possibly planning against
|
most recent Terraform apply but does not propose any
|
||||||
a stale record of the remote system state.
|
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
|
-replace=resource Force replacement of a particular resource instance using
|
||||||
its resource address. If the plan would've normally
|
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
|
include more than one object. This is for exceptional
|
||||||
use only.
|
use only.
|
||||||
|
|
||||||
-var 'foo=bar' Set a variable in the Terraform configuration. This
|
-var 'foo=bar' Set a value for one of the input variables in the root
|
||||||
flag can be set multiple times.
|
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
|
-var-file=filename Load variable values from the given file, in addition
|
||||||
a file. If "terraform.tfvars" or any ".auto.tfvars"
|
to the default files terraform.tfvars and *.auto.tfvars.
|
||||||
files are present, they will be automatically loaded.
|
Use this option more than once to include more than one
|
||||||
|
variables file.
|
||||||
|
|
||||||
Other Options:
|
Other Options:
|
||||||
|
|
||||||
-compact-warnings If Terraform produces any warnings that are not
|
-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.
|
that includes only the summary messages.
|
||||||
|
|
||||||
-detailed-exitcode Return detailed exit codes when the command exits. This
|
-detailed-exitcode Return detailed exit codes when the command exits. This
|
||||||
|
|
|
@ -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)
|
planPath := testPlanFileNoop(t)
|
||||||
|
|
||||||
ui := cli.NewMockUi()
|
ui := cli.NewMockUi()
|
||||||
|
@ -160,7 +160,7 @@ func TestShow_plan(t *testing.T) {
|
||||||
t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
|
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()
|
got := done(t).Stdout()
|
||||||
if !strings.Contains(got, want) {
|
if !strings.Contains(got, want) {
|
||||||
t.Errorf("missing expected output\nwant: %s\ngot:\n%s", want, got)
|
t.Errorf("missing expected output\nwant: %s\ngot:\n%s", want, got)
|
||||||
|
|
|
@ -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":"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":"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: 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":"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"}
|
{"@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"}
|
||||||
|
|
|
@ -11,6 +11,7 @@ const (
|
||||||
// Operation results
|
// Operation results
|
||||||
MessagePlannedChange MessageType = "planned_change"
|
MessagePlannedChange MessageType = "planned_change"
|
||||||
MessageChangeSummary MessageType = "change_summary"
|
MessageChangeSummary MessageType = "change_summary"
|
||||||
|
MessageDriftSummary MessageType = "drift_summary"
|
||||||
MessageOutputs MessageType = "outputs"
|
MessageOutputs MessageType = "outputs"
|
||||||
|
|
||||||
// Hook-driven messages
|
// Hook-driven messages
|
||||||
|
|
|
@ -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) {
|
func (v *JSONView) Hook(h json.Hook) {
|
||||||
v.log.Info(
|
v.log.Info(
|
||||||
h.String(),
|
h.String(),
|
||||||
|
|
|
@ -24,7 +24,6 @@ type Operation interface {
|
||||||
EmergencyDumpState(stateFile *statefile.File) error
|
EmergencyDumpState(stateFile *statefile.File) error
|
||||||
|
|
||||||
PlannedChange(change *plans.ResourceInstanceChangeSrc)
|
PlannedChange(change *plans.ResourceInstanceChangeSrc)
|
||||||
PlanNoChanges()
|
|
||||||
Plan(plan *plans.Plan, schemas *terraform.Schemas)
|
Plan(plan *plans.Plan, schemas *terraform.Schemas)
|
||||||
PlanNextStep(planPath string)
|
PlanNextStep(planPath string)
|
||||||
|
|
||||||
|
@ -86,16 +85,15 @@ func (v *OperationHuman) EmergencyDumpState(stateFile *statefile.File) error {
|
||||||
return nil
|
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) {
|
func (v *OperationHuman) Plan(plan *plans.Plan, schemas *terraform.Schemas) {
|
||||||
renderPlan(plan, schemas, v.view)
|
renderPlan(plan, schemas, v.view)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *OperationHuman) PlannedChange(change *plans.ResourceInstanceChangeSrc) {
|
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
|
// 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
|
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
|
// Log a change summary and a series of "planned" messages for the changes in
|
||||||
// the plan.
|
// the plan.
|
||||||
func (v *OperationJSON) Plan(plan *plans.Plan, schemas *terraform.Schemas) {
|
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...
|
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 = `
|
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.
|
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.
|
||||||
`
|
`
|
||||||
|
|
|
@ -10,7 +10,9 @@ import (
|
||||||
"github.com/hashicorp/terraform/command/arguments"
|
"github.com/hashicorp/terraform/command/arguments"
|
||||||
"github.com/hashicorp/terraform/internal/terminal"
|
"github.com/hashicorp/terraform/internal/terminal"
|
||||||
"github.com/hashicorp/terraform/plans"
|
"github.com/hashicorp/terraform/plans"
|
||||||
|
"github.com/hashicorp/terraform/states"
|
||||||
"github.com/hashicorp/terraform/states/statefile"
|
"github.com/hashicorp/terraform/states/statefile"
|
||||||
|
"github.com/hashicorp/terraform/terraform"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestOperation_stopping(t *testing.T) {
|
func TestOperation_stopping(t *testing.T) {
|
||||||
|
@ -72,13 +74,130 @@ func TestOperation_emergencyDumpState(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOperation_planNoChanges(t *testing.T) {
|
func TestOperation_planNoChanges(t *testing.T) {
|
||||||
|
|
||||||
|
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.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
schemas := testSchemas()
|
||||||
|
for name, test := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
streams, done := terminal.StreamsForTesting(t)
|
streams, done := terminal.StreamsForTesting(t)
|
||||||
v := NewOperation(arguments.ViewHuman, false, NewView(streams))
|
v := NewOperation(arguments.ViewHuman, false, NewView(streams))
|
||||||
|
plan := test.plan(schemas)
|
||||||
v.PlanNoChanges()
|
v.Plan(plan, schemas)
|
||||||
|
got := done(t).Stdout()
|
||||||
if got, want := done(t).Stdout(), "No changes. Infrastructure is up-to-date."; !strings.Contains(got, want) {
|
if want := test.wantText; want != "" && !strings.Contains(got, want) {
|
||||||
t.Errorf("wrong result\ngot: %q\nwant: %q", 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)
|
streams, done := terminal.StreamsForTesting(t)
|
||||||
v := &OperationJSON{view: NewJSONView(NewView(streams))}
|
v := &OperationJSON{view: NewJSONView(NewView(streams))}
|
||||||
|
|
||||||
v.PlanNoChanges()
|
plan := &plans.Plan{
|
||||||
|
Changes: plans.NewChanges(),
|
||||||
|
}
|
||||||
|
v.Plan(plan, nil)
|
||||||
|
|
||||||
want := []map[string]interface{}{
|
want := []map[string]interface{}{
|
||||||
{
|
{
|
||||||
|
|
|
@ -111,13 +111,14 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) {
|
||||||
view.outputColumns(),
|
view.outputColumns(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
view.streams.Print(format.HorizontalRule(view.colorize, view.outputColumns()))
|
|
||||||
view.streams.Println("")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
counts := map[plans.Action]int{}
|
counts := map[plans.Action]int{}
|
||||||
var rChanges []*plans.ResourceInstanceChangeSrc
|
var rChanges []*plans.ResourceInstanceChangeSrc
|
||||||
for _, change := range plan.Changes.Resources {
|
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 {
|
if change.Action == plans.Delete && change.Addr.Resource.Resource.Mode == addrs.DataResourceMode {
|
||||||
// Avoid rendering data sources on deletion
|
// Avoid rendering data sources on deletion
|
||||||
continue
|
continue
|
||||||
|
@ -126,7 +127,105 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) {
|
||||||
rChanges = append(rChanges, change)
|
rChanges = append(rChanges, change)
|
||||||
counts[change.Action]++
|
counts[change.Action]++
|
||||||
}
|
}
|
||||||
|
var changedRootModuleOutputs []*plans.OutputChangeSrc
|
||||||
|
for _, output := range plan.Changes.Outputs {
|
||||||
|
if !output.Addr.Module.IsRoot() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if output.ChangeSrc.Action == plans.NoOp {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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{}
|
headerBuf := &bytes.Buffer{}
|
||||||
fmt.Fprintf(headerBuf, "\n%s\n", strings.TrimSpace(format.WordWrap(planHeaderIntro, view.outputColumns())))
|
fmt.Fprintf(headerBuf, "\n%s\n", strings.TrimSpace(format.WordWrap(planHeaderIntro, view.outputColumns())))
|
||||||
if counts[plans.Create] > 0 {
|
if counts[plans.Create] > 0 {
|
||||||
|
@ -207,24 +306,26 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) {
|
||||||
view.colorize.Color("[reset][bold]Plan:[reset] %d to add, %d to change, %d to destroy.\n"),
|
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],
|
stats[plans.Create], stats[plans.Update], stats[plans.Delete],
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// If there is at least one planned change to the root module outputs
|
// If there is at least one planned change to the root module outputs
|
||||||
// then we'll render a summary of those too.
|
// then we'll render a summary of those too.
|
||||||
var changedRootModuleOutputs []*plans.OutputChangeSrc
|
|
||||||
for _, output := range plan.Changes.Outputs {
|
|
||||||
if !output.Addr.Module.IsRoot() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if output.ChangeSrc.Action == plans.NoOp {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
changedRootModuleOutputs = append(changedRootModuleOutputs, output)
|
|
||||||
}
|
|
||||||
if len(changedRootModuleOutputs) > 0 {
|
if len(changedRootModuleOutputs) > 0 {
|
||||||
view.streams.Println(
|
view.streams.Println(
|
||||||
view.colorize.Color("[reset]\n[bold]Changes to Outputs:[reset]") +
|
view.colorize.Color("[reset]\n[bold]Changes to Outputs:[reset]") +
|
||||||
format.OutputChanges(changedRootModuleOutputs, view.colorize),
|
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(),
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -55,36 +55,44 @@ type Plan struct {
|
||||||
PriorState *states.State
|
PriorState *states.State
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backend represents the backend-related configuration and other data as it
|
// CanApply returns true if and only if the recieving plan includes content
|
||||||
// existed when a plan was created.
|
// that would make sense to apply. If it returns false, the plan operation
|
||||||
type Backend struct {
|
// should indicate that there's nothing to do and Terraform should exit
|
||||||
// Type is the type of backend that the plan will apply against.
|
// without prompting the user to confirm the changes.
|
||||||
Type string
|
//
|
||||||
|
// 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
|
case !p.PriorState.ManagedResourcesEqual(p.PrevRunState):
|
||||||
// the backend Type.
|
// If there are no changes planned but we detected some
|
||||||
Config DynamicValue
|
// 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
|
default:
|
||||||
// was created. It is illegal to apply a plan created for one workspace
|
// Otherwise, there are either no changes to apply or they are changes
|
||||||
// to the state of another workspace.
|
// our cases above don't consider as worthy of applying in isolation.
|
||||||
// (This constraint is already enforced by the statefile lineage mechanism,
|
return false
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProviderAddrs returns a list of all of the provider configuration addresses
|
// ProviderAddrs returns a list of all of the provider configuration addresses
|
||||||
|
@ -118,3 +126,35 @@ func (p *Plan) ProviderAddrs() []addrs.AbsProviderConfig {
|
||||||
|
|
||||||
return ret
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
intended for changing the remote system to match with changes you've made to
|
||||||
your configuration.
|
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:
|
a different intended outcome:
|
||||||
|
|
||||||
* **Destroy mode:** creates a plan whose goal is to destroy all remote objects
|
* **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.
|
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
|
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
|
uses when none of the alternative modes are selected, we refer to it as
|
||||||
"Normal mode". Because these alternative modes are for specialized situations
|
"Normal mode". Because these alternative modes are for specialized situations
|
||||||
|
|
Loading…
Reference in New Issue