plannable import: write generated config to out flag (#33186)

* plannable import: write generated config to out flag

* Add example command to diagnostic
This commit is contained in:
Liam Cervante 2023-05-13 00:05:00 +02:00 committed by GitHub
parent 2b71e9edf3
commit d5fed58fc5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 299 additions and 43 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -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"
}

View File

@ -0,0 +1,4 @@
import {
id = "bar"
to = test_instance.foo
}

View File

@ -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
}

View File

@ -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:

View File

@ -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())
}

View File

@ -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

View File

@ -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 (

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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() {

View File

@ -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)
}
g.Add(node)
// 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,
}
var node dag.Vertex = abstract
if f := t.Concrete; f != nil {
node = f(abstract)
}
g.Add(node)
}
return nil