diff --git a/internal/command/jsonformat/diff.go b/internal/command/jsonformat/diff.go index fea165126a..402a414d84 100644 --- a/internal/command/jsonformat/diff.go +++ b/internal/command/jsonformat/diff.go @@ -62,6 +62,18 @@ func precomputeDiffs(plan Plan, mode plans.Mode) diffs { }) } + for _, change := range plan.DeferredChanges { + schema := plan.getSchema(change.ResourceChange) + structuredChange := structured.FromJsonChange(change.ResourceChange.Change, attribute_path.AlwaysMatcher()) + diffs.deferred = append(diffs.deferred, deferredDiff{ + reason: change.Reason, + diff: diff{ + change: change.ResourceChange, + diff: differ.ComputeDiffForBlock(structuredChange, schema.Block), + }, + }) + } + for key, output := range plan.OutputChanges { change := structured.FromJsonChange(output, attribute_path.AlwaysMatcher()) diffs.outputs[key] = differ.ComputeDiffForOutput(change) @@ -71,9 +83,10 @@ func precomputeDiffs(plan Plan, mode plans.Mode) diffs { } type diffs struct { - drift []diff - changes []diff - outputs map[string]computed.Diff + drift []diff + changes []diff + deferred []deferredDiff + outputs map[string]computed.Diff } func (d diffs) Empty() bool { @@ -104,3 +117,8 @@ func (d diff) Moved() bool { func (d diff) Importing() bool { return d.change.Change.Importing != nil } + +type deferredDiff struct { + diff diff + reason string +} diff --git a/internal/command/jsonformat/plan.go b/internal/command/jsonformat/plan.go index d24b84928b..9b173a7a14 100644 --- a/internal/command/jsonformat/plan.go +++ b/internal/command/jsonformat/plan.go @@ -25,11 +25,12 @@ const ( ) type Plan struct { - PlanFormatVersion string `json:"plan_format_version"` - OutputChanges map[string]jsonplan.Change `json:"output_changes,omitempty"` - ResourceChanges []jsonplan.ResourceChange `json:"resource_changes,omitempty"` - ResourceDrift []jsonplan.ResourceChange `json:"resource_drift,omitempty"` - RelevantAttributes []jsonplan.ResourceAttr `json:"relevant_attributes,omitempty"` + PlanFormatVersion string `json:"plan_format_version"` + OutputChanges map[string]jsonplan.Change `json:"output_changes,omitempty"` + ResourceChanges []jsonplan.ResourceChange `json:"resource_changes,omitempty"` + ResourceDrift []jsonplan.ResourceChange `json:"resource_drift,omitempty"` + RelevantAttributes []jsonplan.ResourceAttr `json:"relevant_attributes,omitempty"` + DeferredChanges []jsonplan.DeferredResourceChange `json:"deferred_changes,omitempty"` ProviderFormatVersion string `json:"provider_format_version"` ProviderSchemas map[string]*jsonprovider.Provider `json:"provider_schemas,omitempty"` @@ -180,6 +181,12 @@ func (plan Plan) renderHuman(renderer Renderer, mode plans.Mode, opts ...plans.Q renderer.Streams.Println() } + haveDeferredChanges := renderHumanDeferredChanges(renderer, diffs, mode) + if haveDeferredChanges { + renderer.Streams.Print(format.HorizontalRule(renderer.Colorize, renderer.Streams.Stdout.Columns())) + renderer.Streams.Println() + } + if willPrintResourceChanges { renderer.Streams.Println(format.WordWrap( "\nTerraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:", @@ -334,6 +341,24 @@ func renderHumanDiffDrift(renderer Renderer, diffs diffs, mode plans.Mode) bool return true } +func renderHumanDeferredChanges(renderer Renderer, diffs diffs, mode plans.Mode) bool { + if len(diffs.deferred) == 0 { + return false + } + + renderer.Streams.Print(renderer.Colorize.Color("\n[bold][cyan]Note:[reset][bold] This is a partial plan, parts can only be known in the next plan / apply cycle.\n")) + renderer.Streams.Println() + + for _, deferred := range diffs.deferred { + diff, render := renderHumanDeferredDiff(renderer, deferred) + if render { + renderer.Streams.Println() + renderer.Streams.Println(diff) + } + } + return true +} + func renderHumanDiff(renderer Renderer, diff diff, cause string) (string, bool) { // Internally, our computed diffs can't tell the difference between a @@ -359,6 +384,47 @@ func renderHumanDiff(renderer Renderer, diff diff, cause string) (string, bool) return buf.String(), true } +func renderHumanDeferredDiff(renderer Renderer, deferred deferredDiff) (string, bool) { + + // Internally, our computed diffs can't tell the difference between a + // replace action (eg. CreateThenDestroy, DestroyThenCreate) and a simple + // update action. So, at the top most level we rely on the action provided + // by the plan itself instead of what we compute. Nested attributes and + // blocks however don't have the replace type of actions, so we can trust + // the computed actions of these. + action := jsonplan.UnmarshalActions(deferred.diff.change.Change.Actions) + if action == plans.NoOp && !deferred.diff.Moved() && !deferred.diff.Importing() { + // Skip resource changes that have nothing interesting to say. + return "", false + } + + var buf bytes.Buffer + var explanation string + switch deferred.reason { + // TODO: Add other cases + case jsonplan.DeferredReasonInstanceCountUnknown: + explanation = "The number of resource instances is unknown." + case jsonplan.DeferredReasonResourceConfigUnknown: + explanation = "The resource configuration is unknown." + case jsonplan.DeferredReasonProviderConfigUnknown: + explanation = "The provider configuration is unknown." + case jsonplan.DeferredReasonDeferredPrereq: + explanation = "A prerequisite for this resource is deferred." + case jsonplan.DeferredReasonAbsentPrereq: + explanation = "A prerequisite for this resource is absent." + default: + explanation = "Unknown / Not supported by this version of Terraform." + } + + buf.WriteString(renderer.Colorize.Color(fmt.Sprintf("%s was deferred: \nReason: %s\n", deferred.diff.change.Address, explanation))) + + opts := computed.NewRenderHumanOpts(renderer.Colorize) + opts.ShowUnchangedChildren = deferred.diff.Importing() + + buf.WriteString(fmt.Sprintf("%s %s %s", renderer.Colorize.Color(format.DiffActionSymbol(action)), resourceChangeHeader(deferred.diff.change), deferred.diff.diff.RenderHuman(0, opts))) + return buf.String(), true +} + func resourceChangeComment(resource jsonplan.ResourceChange, action plans.Action, changeCause string) string { var buf bytes.Buffer diff --git a/internal/command/jsonformat/plan_test.go b/internal/command/jsonformat/plan_test.go index ddce634499..373791a103 100644 --- a/internal/command/jsonformat/plan_test.go +++ b/internal/command/jsonformat/plan_test.go @@ -7127,6 +7127,236 @@ func TestOutputChanges(t *testing.T) { } } +func TestResourceChange_deferredActions(t *testing.T) { + color := &colorstring.Colorize{Colors: colorstring.DefaultColors, Disable: true} + providerAddr := addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + } + testCases := map[string]struct { + changes []*plans.DeferredResourceInstanceChange + output string + }{ + "deferred create action": { + changes: []*plans.DeferredResourceInstanceChange{ + { + DeferredReason: providers.DeferredReasonAbsentPrereq, + Change: &plans.ResourceInstanceChange{ + Addr: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "instance", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + ProviderAddr: providerAddr, + Change: plans.Change{ + Action: plans.Create, + Before: cty.NullVal(cty.DynamicPseudoType), + After: cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "ami": cty.StringVal("bar"), + "disk": cty.UnknownVal(cty.Object(map[string]cty.Type{ + "mount_point": cty.String, + "size": cty.Number, + })), + "root_block_device": cty.UnknownVal(cty.Object(map[string]cty.Type{ + "volume_type": cty.String, + })), + }), + }, + }, + }, + }, + output: `test_instance.instance was deferred: +Reason: A prerequisite for this resource is absent. + + resource "test_instance" "instance" { + + ami = "bar" + + disk = (known after apply) + + id = (known after apply) + + + root_block_device (known after apply) + }`, + }, + + "deferred create action unknown for_each": { + changes: []*plans.DeferredResourceInstanceChange{ + { + DeferredReason: providers.DeferredReasonInstanceCountUnknown, + Change: &plans.ResourceInstanceChange{ + Addr: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "instance", + }.Instance(addrs.WildcardKey).Absolute(addrs.RootModuleInstance), + ProviderAddr: providerAddr, + Change: plans.Change{ + Action: plans.Create, + Before: cty.NullVal(cty.DynamicPseudoType), + After: cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "ami": cty.StringVal("bar"), + "disk": cty.UnknownVal(cty.Object(map[string]cty.Type{ + "mount_point": cty.String, + "size": cty.Number, + })), + "root_block_device": cty.UnknownVal(cty.Object(map[string]cty.Type{ + "volume_type": cty.String, + })), + }), + }, + }, + }, + }, + output: `test_instance.instance[*] was deferred: +Reason: The number of resource instances is unknown. + + resource "test_instance" "instance" { + + ami = "bar" + + disk = (known after apply) + + id = (known after apply) + + + root_block_device (known after apply) + }`, + }, + + "deferred update action": { + changes: []*plans.DeferredResourceInstanceChange{ + { + DeferredReason: providers.DeferredReasonProviderConfigUnknown, + Change: &plans.ResourceInstanceChange{ + Addr: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "instance", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + ProviderAddr: providerAddr, + Change: plans.Change{ + Action: plans.Update, + Before: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("foo"), + "ami": cty.StringVal("bar"), + "disk": cty.NullVal(cty.Object(map[string]cty.Type{ + "mount_point": cty.String, + "size": cty.Number, + })), + "root_block_device": cty.NullVal(cty.Object(map[string]cty.Type{ + "volume_type": cty.String, + })), + }), + After: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("foo"), + "ami": cty.StringVal("baz"), + "disk": cty.NullVal(cty.Object(map[string]cty.Type{ + "mount_point": cty.String, + "size": cty.Number, + })), + "root_block_device": cty.NullVal(cty.Object(map[string]cty.Type{ + "volume_type": cty.String, + })), + }), + }, + }, + }, + }, + output: `test_instance.instance was deferred: +Reason: The provider configuration is unknown. + ~ resource "test_instance" "instance" { + ~ ami = "bar" -> "baz" + id = "foo" + }`, + }, + + "deferred destroy action": { + changes: []*plans.DeferredResourceInstanceChange{ + { + DeferredReason: providers.DeferredReasonResourceConfigUnknown, + Change: &plans.ResourceInstanceChange{ + Addr: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "instance", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + ProviderAddr: providerAddr, + Change: plans.Change{ + Action: plans.Update, + Before: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("foo"), + "ami": cty.StringVal("bar"), + "disk": cty.NullVal(cty.Object(map[string]cty.Type{ + "mount_point": cty.String, + "size": cty.Number, + })), + "root_block_device": cty.NullVal(cty.Object(map[string]cty.Type{ + "volume_type": cty.String, + })), + }), + After: cty.NullVal(cty.Object(map[string]cty.Type{ + "id": cty.String, + "ami": cty.String, + "disk": cty.Object(map[string]cty.Type{ + "mount_point": cty.String, + "size": cty.Number, + }), + "root_block_device": cty.Object(map[string]cty.Type{ + "volume_type": cty.String, + }), + })), + }, + }, + }, + }, + output: `test_instance.instance was deferred: +Reason: The resource configuration is unknown. + ~ resource "test_instance" "instance" { + - ami = "bar" -> null + - id = "foo" -> null + }`, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + blockSchema := testSchema(configschema.NestingSingle) + fullSchema := &terraform.Schemas{ + Providers: map[addrs.Provider]providers.ProviderSchema{ + providerAddr.Provider: { + ResourceTypes: map[string]providers.Schema{ + "test_instance": { + Block: blockSchema, + }, + }, + }, + }, + } + var changes []*plans.DeferredResourceInstanceChangeSrc + for _, change := range tc.changes { + changeSrc, err := change.Encode(blockSchema.ImpliedType()) + if err != nil { + t.Fatalf("Failed to encode change: %s", err) + } + changes = append(changes, changeSrc) + } + + deferredChanges, err := jsonplan.MarshalDeferredResourceChanges(changes, fullSchema) + if err != nil { + t.Fatalf("failed to marshal deferred changes: %s", err) + } + + renderer := Renderer{Colorize: color} + jsonschemas := jsonprovider.MarshalForRenderer(fullSchema) + diffs := precomputeDiffs(Plan{ + DeferredChanges: deferredChanges, + ProviderSchemas: jsonschemas, + }, plans.NormalMode) + + // TODO: Add diffing for outputs + // TODO: Make sure it's true and either make it a single entity in the test case or deal with a list here + output, _ := renderHumanDeferredDiff(renderer, diffs.deferred[0]) + if diff := cmp.Diff(tc.output, output); len(diff) > 0 { + t.Errorf("unexpected output\ngot:\n%s\nwant:\n%s\ndiff:\n%s", output, tc.output, diff) + } + }) + } +} + func outputChange(name string, before, after cty.Value, sensitive bool) *plans.OutputChangeSrc { addr := addrs.AbsOutputValue{ OutputValue: addrs.OutputValue{Name: name}, diff --git a/internal/command/jsonplan/plan.go b/internal/command/jsonplan/plan.go index 54216d2369..ddc87f6e32 100644 --- a/internal/command/jsonplan/plan.go +++ b/internal/command/jsonplan/plan.go @@ -292,7 +292,7 @@ func Marshal( } if p.DeferredResources != nil { - output.DeferredChanges, err = marshalDeferredResourceChanges(p.DeferredResources, schemas) + output.DeferredChanges, err = MarshalDeferredResourceChanges(p.DeferredResources, schemas) if err != nil { return nil, fmt.Errorf("error in marshaling deferred resource changes: %s", err) } @@ -583,10 +583,11 @@ func marshalResourceChange(rc *plans.ResourceInstanceChangeSrc, schemas *terrafo return r, nil } -// marshalDeferredResourceChanges converts the provided internal representation +// MarshalDeferredResourceChanges converts the provided internal representation // of DeferredResourceInstanceChangeSrc objects into the public structured JSON // changes. -func marshalDeferredResourceChanges(resources []*plans.DeferredResourceInstanceChangeSrc, schemas *terraform.Schemas) ([]DeferredResourceChange, error) { +// This is public to make testing easier. +func MarshalDeferredResourceChanges(resources []*plans.DeferredResourceInstanceChangeSrc, schemas *terraform.Schemas) ([]DeferredResourceChange, error) { var ret []DeferredResourceChange var sortedResources []*plans.DeferredResourceInstanceChangeSrc