diff --git a/internal/backend/backend.go b/internal/backend/backend.go index def4560d93..eb67acfddc 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -295,6 +295,11 @@ type Operation struct { // Workspace is the name of the workspace that this operation should run // in, which controls which named state is used. Workspace string + + // GenerateConfigOut tells the operation both that it should generate config + // for unmatched import targets and where any generated config should be + // written to. + GenerateConfigOut string } // HasConfig returns true if and only if the operation has a ConfigDir value diff --git a/internal/backend/local/backend_local.go b/internal/backend/local/backend_local.go index 9355e59ea5..d290743b53 100644 --- a/internal/backend/local/backend_local.go +++ b/internal/backend/local/backend_local.go @@ -190,11 +190,12 @@ func (b *Local) localRunDirect(op *backend.Operation, run *backend.LocalRun, cor } planOpts := &terraform.PlanOpts{ - Mode: op.PlanMode, - Targets: op.Targets, - ForceReplace: op.ForceReplace, - SetVariables: variables, - SkipRefresh: op.Type != backend.OperationTypeRefresh && !op.PlanRefresh, + Mode: op.PlanMode, + Targets: op.Targets, + ForceReplace: op.ForceReplace, + SetVariables: variables, + SkipRefresh: op.Type != backend.OperationTypeRefresh && !op.PlanRefresh, + GenerateConfig: len(op.GenerateConfigOut) > 0, } run.PlanOpts = planOpts diff --git a/internal/backend/local/backend_plan.go b/internal/backend/local/backend_plan.go index 553ab86567..d1c3122068 100644 --- a/internal/backend/local/backend_plan.go +++ b/internal/backend/local/backend_plan.go @@ -9,6 +9,7 @@ import ( "log" "github.com/hashicorp/terraform/internal/backend" + "github.com/hashicorp/terraform/internal/genconfig" "github.com/hashicorp/terraform/internal/logging" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/planfile" @@ -53,6 +54,24 @@ func (b *Local) opPlan( return } + if len(op.GenerateConfigOut) > 0 { + + if op.PlanMode != plans.NormalMode { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Invalid generate-config-out flag", + "Config can only be generated during a normal plan operation, and not during a refresh-only or destroy plan.")) + op.ReportResult(runningOp, diags) + return + } + + diags = diags.Append(genconfig.ValidateTargetFile(op.GenerateConfigOut)) + if diags.HasErrors() { + op.ReportResult(runningOp, diags) + return + } + } + if b.ContextOpts == nil { b.ContextOpts = new(terraform.ContextOpts) } @@ -171,6 +190,15 @@ func (b *Local) opPlan( op.ReportResult(runningOp, diags) return } + + // Write out any generated config, before we render the plan. + moreDiags = genconfig.MaybeWriteGeneratedConfig(plan, op.GenerateConfigOut) + diags = diags.Append(moreDiags) + if moreDiags.HasErrors() { + op.ReportResult(runningOp, diags) + return + } + op.View.Plan(plan, schemas) // If we've accumulated any diagnostics along the way then we'll show them diff --git a/internal/command/arguments/plan.go b/internal/command/arguments/plan.go index 9b83752cb9..1b7b0922f9 100644 --- a/internal/command/arguments/plan.go +++ b/internal/command/arguments/plan.go @@ -25,6 +25,11 @@ type Plan struct { // OutPath contains an optional path to store the plan file OutPath string + // GenerateConfigPath tells Terraform that config should be generated for + // unmatched import target paths and which path the generated file should + // be written to. + GenerateConfigPath string + // ViewType specifies which output format to use ViewType ViewType } @@ -44,6 +49,7 @@ func ParsePlan(args []string) (*Plan, tfdiags.Diagnostics) { cmdFlags.BoolVar(&plan.DetailedExitCode, "detailed-exitcode", false, "detailed-exitcode") cmdFlags.BoolVar(&plan.InputEnabled, "input", true, "input") cmdFlags.StringVar(&plan.OutPath, "out", "", "out") + cmdFlags.StringVar(&plan.GenerateConfigPath, "generate-config-out", "", "generate-config-out") var json bool cmdFlags.BoolVar(&json, "json", false, "json") diff --git a/internal/command/command_test.go b/internal/command/command_test.go index 8265c69906..7e634bec02 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -252,6 +252,24 @@ func testPlanFileNoop(t *testing.T) string { return testPlanFile(t, snap, state, plan) } +func testFileEquals(t *testing.T, got, want string) { + t.Helper() + + actual, err := os.ReadFile(got) + if err != nil { + t.Fatalf("error reading %s", got) + } + + expected, err := os.ReadFile(want) + if err != nil { + t.Fatalf("error reading %s", want) + } + + if diff := cmp.Diff(string(actual), string(expected)); len(diff) > 0 { + t.Fatalf("got:\n%s\nwant:\n%s\ndiff:\n%s", actual, expected, diff) + } +} + func testReadPlan(t *testing.T, path string) *plans.Plan { t.Helper() diff --git a/internal/command/plan.go b/internal/command/plan.go index 5985a26443..f6d815fbf4 100644 --- a/internal/command/plan.go +++ b/internal/command/plan.go @@ -75,7 +75,7 @@ func (c *PlanCommand) Run(rawArgs []string) int { } // Build the operation request - opReq, opDiags := c.OperationRequest(be, view, args.ViewType, args.Operation, args.OutPath) + opReq, opDiags := c.OperationRequest(be, view, args.ViewType, args.Operation, args.OutPath, args.GenerateConfigPath) diags = diags.Append(opDiags) if diags.HasErrors() { view.Diagnostics(diags) @@ -144,6 +144,7 @@ func (c *PlanCommand) OperationRequest( viewType arguments.ViewType, args *arguments.Operation, planOutPath string, + generateConfigOut string, ) (*backend.Operation, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics @@ -154,6 +155,7 @@ func (c *PlanCommand) OperationRequest( opReq.Hooks = view.Hooks() opReq.PlanRefresh = args.Refresh opReq.PlanOutPath = planOutPath + opReq.GenerateConfigOut = generateConfigOut opReq.Targets = args.Targets opReq.ForceReplace = args.ForceReplace opReq.Type = backend.OperationTypePlan diff --git a/internal/command/plan_test.go b/internal/command/plan_test.go index c0d2386a0e..7d83070246 100644 --- a/internal/command/plan_test.go +++ b/internal/command/plan_test.go @@ -193,6 +193,47 @@ func TestPlan_noState(t *testing.T) { } } +func TestPlan_generatedConfigPath(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath("plan-import-config-gen"), td) + defer testChdir(t, td)() + + genPath := filepath.Join(td, "generated.tf") + + p := planFixtureProvider() + view, done := testView(t) + + c := &PlanCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(p), + View: view, + }, + } + + p.ImportResourceStateResponse = &providers.ImportResourceStateResponse{ + ImportedResources: []providers.ImportedResource{ + { + TypeName: "test_instance", + State: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("bar"), + }), + Private: nil, + }, + }, + } + + args := []string{ + "-generate-config-out", genPath, + } + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) + } + + testFileEquals(t, genPath, filepath.Join(td, "generated.tf.expected")) +} + func TestPlan_outPath(t *testing.T) { td := t.TempDir() testCopyDir(t, testFixturePath("plan"), td) diff --git a/internal/command/testdata/plan-import-config-gen/generated.tf.expected b/internal/command/testdata/plan-import-config-gen/generated.tf.expected new file mode 100644 index 0000000000..bfdfc3ca69 --- /dev/null +++ b/internal/command/testdata/plan-import-config-gen/generated.tf.expected @@ -0,0 +1,8 @@ +# __generated__ by Terraform +# Please review these resources and move them into your main configuration files. + +# __generated__ by Terraform from "bar" +resource "test_instance" "foo" { + ami = null + id = "bar" +} diff --git a/internal/command/testdata/plan-import-config-gen/main.tf b/internal/command/testdata/plan-import-config-gen/main.tf new file mode 100644 index 0000000000..ea89e71b0f --- /dev/null +++ b/internal/command/testdata/plan-import-config-gen/main.tf @@ -0,0 +1,4 @@ +import { + id = "bar" + to = test_instance.foo +} diff --git a/internal/genconfig/generate_config_write.go b/internal/genconfig/generate_config_write.go new file mode 100644 index 0000000000..5a9e99e3e7 --- /dev/null +++ b/internal/genconfig/generate_config_write.go @@ -0,0 +1,81 @@ +package genconfig + +import ( + "fmt" + "io" + "os" + + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/tfdiags" +) + +func ValidateTargetFile(out string) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + if _, err := os.Stat(out); !os.IsNotExist(err) { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Target generated file already exists", + "Terraform can only write generated config into a new file. Either choose a different target location or move all existing configuration out of the target file, delete it and try again.")) + + } + return diags +} + +func MaybeWriteGeneratedConfig(plan *plans.Plan, out string) tfdiags.Diagnostics { + if len(out) == 0 { + // No specified out file, so don't write anything. + return nil + } + + diags := ValidateTargetFile(out) + if diags.HasErrors() { + return diags + } + + var writer io.Writer + + for _, change := range plan.Changes.Resources { + if len(change.GeneratedConfig) > 0 { + + if writer == nil { + // Lazily create the generated file, in case we have no + // generated config to create. + var err error + if writer, err = os.Create(out); err != nil { + if os.IsPermission(err) { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to create target generated file", + fmt.Sprintf("Terraform did not have permission to create the generated file (%s) in the target directory. Please modify permissions over the target directory, and try again.", out))) + return diags + } + + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Failed to create target generated file", + fmt.Sprintf("Terraform could not create the generated file (%s) in the target directory: %v. Depending on the error message, this may be a bug in Terraform itself. If so, please report it!", out, err))) + return diags + } + + header := "# __generated__ by Terraform\n# Please review these resources and move them into your main configuration files.\n" + // Missing the header from the file, isn't the end of the world + // so if this did return an error, then we will just ignore it. + _, _ = writer.Write([]byte(header)) + } + + header := "\n# __generated__ by Terraform" + if change.Importing != nil && len(change.Importing.ID) > 0 { + header += fmt.Sprintf(" from %q", change.Importing.ID) + } + header += "\n" + if _, err := writer.Write([]byte(fmt.Sprintf("%s%s\n", header, change.GeneratedConfig))); err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Warning, + "Failed to save generated config", + fmt.Sprintf("Terraform encountered an error while writing generated config: %v. The config for %s must be created manually before applying. Depending on the error message, this may be a bug in Terraform itself. If so, please report it!", err, change.Addr.String()))) + } + } + } + + return diags +} diff --git a/internal/terraform/context_plan.go b/internal/terraform/context_plan.go index 93bdc85345..9d7397472e 100644 --- a/internal/terraform/context_plan.go +++ b/internal/terraform/context_plan.go @@ -77,6 +77,10 @@ type PlanOpts struct { // ImportTargets is a list of target resources to import. These resources // will be added to the plan graph. ImportTargets []*ImportTarget + + // GenerateConfig tells Terraform to generate configuration for any + // ImportTargets that do not have configuration already. + GenerateConfig bool } // Plan generates an execution plan by comparing the given configuration @@ -630,6 +634,7 @@ func (c *Context) planGraph(config *configs.Config, prevRunState *states.State, preDestroyRefresh: opts.PreDestroyRefresh, Operation: walkPlan, ImportTargets: opts.ImportTargets, + GenerateConfig: opts.GenerateConfig, }).Build(addrs.RootModuleInstance) return graph, walkPlan, diags case plans.RefreshOnlyMode: diff --git a/internal/terraform/context_plan2_test.go b/internal/terraform/context_plan2_test.go index 8b119f5d38..9c009dac76 100644 --- a/internal/terraform/context_plan2_test.go +++ b/internal/terraform/context_plan2_test.go @@ -4512,7 +4512,10 @@ resource "test_object" "a" { }, } - plan, diags := ctx.Plan(m, states.NewState(), DefaultPlanOpts) + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + GenerateConfig: true, + }) if diags.HasErrors() { t.Fatalf("unexpected errors\n%s", diags.Err().Error()) } @@ -4569,7 +4572,10 @@ import { }, } - plan, diags := ctx.Plan(m, states.NewState(), DefaultPlanOpts) + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + GenerateConfig: true, + }) if diags.HasErrors() { t.Fatalf("unexpected errors\n%s", diags.Err().Error()) } @@ -4648,7 +4654,10 @@ import { }, } - plan, diags := ctx.Plan(m, states.NewState(), DefaultPlanOpts) + plan, diags := ctx.Plan(m, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + GenerateConfig: true, + }) if diags.HasErrors() { t.Fatalf("unexpected errors\n%s", diags.Err().Error()) } diff --git a/internal/terraform/graph_builder_plan.go b/internal/terraform/graph_builder_plan.go index 898a2ed1ca..587b3fa036 100644 --- a/internal/terraform/graph_builder_plan.go +++ b/internal/terraform/graph_builder_plan.go @@ -75,6 +75,10 @@ type PlanGraphBuilder struct { // ImportTargets are the list of resources to import. ImportTargets []*ImportTarget + + // GenerateConfig tells Terraform to generate config for any import targets + // that do not already have configuration. + GenerateConfig bool } // See GraphBuilder @@ -113,8 +117,7 @@ func (b *PlanGraphBuilder) Steps() []GraphTransformer { importTargets: b.ImportTargets, // We only want to generate config during a plan operation. - // TODO: add a dedicated flag for this? - generateConfigForImportTargets: b.Operation == walkPlan, + generateConfigForImportTargets: b.GenerateConfig, }, // Add dynamic values diff --git a/internal/terraform/node_resource_abstract.go b/internal/terraform/node_resource_abstract.go index d8114b658a..64b73279cb 100644 --- a/internal/terraform/node_resource_abstract.go +++ b/internal/terraform/node_resource_abstract.go @@ -78,6 +78,9 @@ type NodeAbstractResource struct { // This resource may expand into instances which need to be imported. importTargets []*ImportTarget + + // generateConfig tells this node that it's okay for it to generate config. + generateConfig bool } var ( diff --git a/internal/terraform/node_resource_abstract_instance.go b/internal/terraform/node_resource_abstract_instance.go index 4ce69f358f..8a6a21f25c 100644 --- a/internal/terraform/node_resource_abstract_instance.go +++ b/internal/terraform/node_resource_abstract_instance.go @@ -651,7 +651,6 @@ func (n *NodeAbstractResourceInstance) plan( currentState *states.ResourceInstanceObject, createBeforeDestroy bool, forceReplace []addrs.AbsResourceInstance, - generateConfig bool, ) (*plans.ResourceInstanceChange, *states.ResourceInstanceObject, instances.RepetitionData, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics var state *states.ResourceInstanceObject @@ -678,7 +677,7 @@ func (n *NodeAbstractResourceInstance) plan( // If we're importing and generating config, generate it now. var generatedHCL string - if generateConfig { + if n.generateConfig { var generatedDiags tfdiags.Diagnostics if n.Config != nil { @@ -722,6 +721,19 @@ func (n *NodeAbstractResourceInstance) plan( n.Config = generatedConfig } + if n.Config == nil { + // This shouldn't happen. A node that isn't generating config should + // have embedded config, and the rest of Terraform should enforce this. + // If, however, we didn't do things correctly the next line will panic, + // so let's not do that and return an error message with more context. + + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Resource has no configuration", + fmt.Sprintf("Terraform attempted to process a resource at %s that has no configuration. This is a bug in Terraform; please report it!", n.Addr.String()))) + return plan, state, keyData, diags + } + config := *n.Config checkRuleSeverity := tfdiags.Error diff --git a/internal/terraform/node_resource_apply_instance.go b/internal/terraform/node_resource_apply_instance.go index 8cf1bbb542..98d77d8aa1 100644 --- a/internal/terraform/node_resource_apply_instance.go +++ b/internal/terraform/node_resource_apply_instance.go @@ -274,7 +274,7 @@ func (n *NodeApplyableResourceInstance) managedResourceExecute(ctx EvalContext) // Make a new diff, in case we've learned new values in the state // during apply which we can now incorporate. - diffApply, _, repeatData, planDiags := n.plan(ctx, diff, state, false, n.forceReplace, false) + diffApply, _, repeatData, planDiags := n.plan(ctx, diff, state, false, n.forceReplace) diags = diags.Append(planDiags) if diags.HasErrors() { return diags diff --git a/internal/terraform/node_resource_plan.go b/internal/terraform/node_resource_plan.go index 61d9b5136b..f6d99606d0 100644 --- a/internal/terraform/node_resource_plan.go +++ b/internal/terraform/node_resource_plan.go @@ -336,6 +336,7 @@ func (n *nodeExpandPlannableResource) resourceInstanceSubgraph(ctx EvalContext, a.dependsOn = n.dependsOn a.Dependencies = n.dependencies a.preDestroyRefresh = n.preDestroyRefresh + a.generateConfig = n.generateConfig m = &NodePlannableResourceInstance{ NodeAbstractResourceInstance: a, diff --git a/internal/terraform/node_resource_plan_instance.go b/internal/terraform/node_resource_plan_instance.go index b83bfc5ec1..12691ee91c 100644 --- a/internal/terraform/node_resource_plan_instance.go +++ b/internal/terraform/node_resource_plan_instance.go @@ -8,14 +8,15 @@ import ( "log" "sort" + "github.com/hashicorp/hcl/v2" + "github.com/zclconf/go-cty/cty" + "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/instances" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/tfdiags" - - "github.com/zclconf/go-cty/cty" ) // NodePlannableResourceInstance represents a _single_ resource @@ -135,7 +136,6 @@ func (n *NodePlannableResourceInstance) managedResourceExecute(ctx EvalContext) var change *plans.ResourceInstanceChange var instanceRefreshState *states.ResourceInstanceObject - var generateConfig bool checkRuleSeverity := tfdiags.Error if n.skipPlanChanges || n.preDestroyRefresh { @@ -157,12 +157,32 @@ func (n *NodePlannableResourceInstance) managedResourceExecute(ctx EvalContext) importing := n.importTarget.ID != "" + if importing && n.Config == nil && !n.generateConfig { + // Then the user wrote an import target to a target that didn't exist. + if n.Addr.Module.IsRoot() { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Import block target does not exist", + Detail: "The target for the given import block does not exist. If you wish to automatically generate config for this resource, use the -generate-config-out option within terraform plan. Otherwise, make sure the target resource exists within your configuration. For example:\n\n terraform plan -generate-config-out=generated.tf", + Subject: n.importTarget.Config.DeclRange.Ptr(), + }) + } else { + // You can't generate config for a resource that is inside a + // module, so we will present a different error message for + // this case. + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Import block target does not exist", + Detail: "The target for the given import block does not exist. The specified target is within a module, and must be defined as a resource within that module before anything can be imported.", + Subject: n.importTarget.Config.DeclRange.Ptr(), + }) + } + return diags + } + // If the resource is to be imported, we now ask the provider for an Import // and a Refresh, and save the resulting state to instanceRefreshState. if importing { - if n.Config == nil || n.Config.Managed == nil { - generateConfig = true - } instanceRefreshState, diags = n.importState(ctx, addr, provider) } else { var readDiags tfdiags.Diagnostics @@ -245,7 +265,7 @@ func (n *NodePlannableResourceInstance) managedResourceExecute(ctx EvalContext) } change, instancePlanState, repeatData, planDiags := n.plan( - ctx, change, instanceRefreshState, n.ForceCreateBeforeDestroy, n.forceReplace, generateConfig, + ctx, change, instanceRefreshState, n.ForceCreateBeforeDestroy, n.forceReplace, ) diags = diags.Append(planDiags) if diags.HasErrors() { diff --git a/internal/terraform/transform_config.go b/internal/terraform/transform_config.go index 023dc63637..61ab629313 100644 --- a/internal/terraform/transform_config.go +++ b/internal/terraform/transform_config.go @@ -98,8 +98,13 @@ func (t *ConfigTransformer) transformSingle(g *Graph, config *configs.Config, ge } // Take a copy of the import targets, so we can edit them as we go. + // Only include import targets that are targeting the current module. var importTargets []*ImportTarget - importTargets = append(importTargets, t.importTargets...) + for _, target := range t.importTargets { + if targetModule := target.Addr.Module.Module(); targetModule.Equal(config.Path) { + importTargets = append(importTargets, target) + } + } for _, r := range allResources { relAddr := r.Addr() @@ -151,28 +156,32 @@ func (t *ConfigTransformer) transformSingle(g *Graph, config *configs.Config, ge g.Add(node) } - if generateConfig { - // If any import targets were not claimed by resources, then we will - // generate config for them. - for _, i := range importTargets { - if !i.Addr.Module.IsRoot() { - // We only generate config for resources imported into the root - // module. - continue - } - - abstract := &NodeAbstractResource{ - Addr: i.Addr.ConfigResource(), - importTargets: []*ImportTarget{i}, - } - - var node dag.Vertex = abstract - if f := t.Concrete; f != nil { - node = f(abstract) - } + // If any import targets were not claimed by resources, then let's add them + // into the graph now. + // + // We actually know that if any of the resources aren't claimed and + // generateConfig is false, then we have a problem. But, we can't raise a + // nice error message from this function. + // + // We'll add the nodes that we know will fail, and catch them again later + // in the processing when we are in a position to raise a much more helpful + // error message. + // + // TODO: We could actually catch and process these kind of problems earlier, + // this is something that could be done during the Validate process. + for _, i := range importTargets { + abstract := &NodeAbstractResource{ + Addr: i.Addr.ConfigResource(), + importTargets: []*ImportTarget{i}, + generateConfig: generateConfig, + } - g.Add(node) + var node dag.Vertex = abstract + if f := t.Concrete; f != nil { + node = f(abstract) } + + g.Add(node) } return nil