diff --git a/go.mod b/go.mod index 11314f780d..a6b46911c2 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-plugin v1.4.3 github.com/hashicorp/go-retryablehttp v0.7.2 - github.com/hashicorp/go-tfe v1.24.0 + github.com/hashicorp/go-tfe v1.26.0 github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/go-version v1.6.0 github.com/hashicorp/hcl v1.0.0 diff --git a/go.sum b/go.sum index f22b9bf20c..6cdec3379f 100644 --- a/go.sum +++ b/go.sum @@ -616,8 +616,8 @@ github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerX github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-tfe v1.24.0 h1:pkAKnPwV1FmpxRAy9NE0F5piaG2KaS2mODQ1Fvzihiw= -github.com/hashicorp/go-tfe v1.24.0/go.mod h1:gyCSqwttvU8ro8vpJujGSHVT/tR/JrEDk75cx25aJHE= +github.com/hashicorp/go-tfe v1.26.0 h1:aacguqCENg6Z7ttfhAxdbbY2vm/jKrntl5sUUY0h6EM= +github.com/hashicorp/go-tfe v1.26.0/go.mod h1:1Y6nsdMuJ14lYdc1VMLl/erlthvMzUsJn+WYWaAdSc4= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= @@ -931,7 +931,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.194/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.588 h1:DYtBXB7sVc3EOW5horg8j55cLZynhsLYhHrvQ/jXKKM= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.588/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y= diff --git a/internal/backend/local/backend_plan.go b/internal/backend/local/backend_plan.go index 1699b508e8..79ff402d80 100644 --- a/internal/backend/local/backend_plan.go +++ b/internal/backend/local/backend_plan.go @@ -6,6 +6,7 @@ package local import ( "context" "fmt" + "io" "log" "github.com/hashicorp/terraform/internal/backend" @@ -191,7 +192,7 @@ func (b *Local) opPlan( } // Write out any generated config, before we render the plan. - wroteConfig, moreDiags := genconfig.MaybeWriteGeneratedConfig(plan, op.GenerateConfigOut) + wroteConfig, moreDiags := maybeWriteGeneratedConfig(plan, op.GenerateConfigOut) diags = diags.Append(moreDiags) if moreDiags.HasErrors() { op.ReportResult(runningOp, diags) @@ -215,3 +216,38 @@ func (b *Local) opPlan( } } } + +func maybeWriteGeneratedConfig(plan *plans.Plan, out string) (wroteConfig bool, diags tfdiags.Diagnostics) { + if genconfig.ShouldWriteConfig(out) { + diags := genconfig.ValidateTargetFile(out) + if diags.HasErrors() { + return false, diags + } + + var writer io.Writer + for _, c := range plan.Changes.Resources { + change := genconfig.Change{ + Addr: c.Addr.String(), + GeneratedConfig: c.GeneratedConfig, + } + if c.Importing != nil { + change.ImportID = c.Importing.ID + } + + var moreDiags tfdiags.Diagnostics + writer, wroteConfig, moreDiags = change.MaybeWriteConfig(writer, out) + if moreDiags.HasErrors() { + return false, diags.Append(moreDiags) + } + } + } + + if wroteConfig { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Warning, + "Config generation is experimental", + "Generating configuration during import is currently experimental, and the generated configuration format may change in future versions.")) + } + + return wroteConfig, diags +} diff --git a/internal/backend/remote/backend_plan.go b/internal/backend/remote/backend_plan.go index 79ba3088dd..40c5c080bb 100644 --- a/internal/backend/remote/backend_plan.go +++ b/internal/backend/remote/backend_plan.go @@ -69,6 +69,15 @@ func (b *Remote) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operatio )) } + if op.GenerateConfigOut != "" { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Generating configuration is not currently supported", + `The "remote" backend does not currently support generating resource configuration `+ + `as part of a plan.`, + )) + } + if b.hasExplicitVariableValues(op) { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, diff --git a/internal/backend/remote/backend_plan_test.go b/internal/backend/remote/backend_plan_test.go index 7aba624b27..90684923fa 100644 --- a/internal/backend/remote/backend_plan_test.go +++ b/internal/backend/remote/backend_plan_test.go @@ -1248,3 +1248,33 @@ func TestRemote_planOtherError(t *testing.T) { t.Fatalf("expected error message, got: %s", err.Error()) } } + +func TestRemote_planWithGenConfigOut(t *testing.T) { + b, bCleanup := testBackendDefault(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan") + defer configCleanup() + + op.GenerateConfigOut = "generated.tf" + op.Workspace = backend.DefaultStateName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + if !run.PlanEmpty { + t.Fatalf("expected plan to be empty") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "Generating configuration is not currently supported") { + t.Fatalf("expected error about config generation, got: %v", errOutput) + } +} diff --git a/internal/cloud/backend_plan.go b/internal/cloud/backend_plan.go index e4a4dcef93..f678738b67 100644 --- a/internal/cloud/backend_plan.go +++ b/internal/cloud/backend_plan.go @@ -22,6 +22,7 @@ import ( tfe "github.com/hashicorp/go-tfe" "github.com/hashicorp/terraform/internal/backend" "github.com/hashicorp/terraform/internal/command/jsonformat" + "github.com/hashicorp/terraform/internal/genconfig" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -82,6 +83,10 @@ func (b *Cloud) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation )) } + if len(op.GenerateConfigOut) > 0 { + diags = diags.Append(genconfig.ValidateTargetFile(op.GenerateConfigOut)) + } + // Return if there are any errors. if diags.HasErrors() { return nil, diags.Err() @@ -250,6 +255,10 @@ in order to capture the filesystem context the remote workspace expects: } runOptions.Variables = runVariables + if len(op.GenerateConfigOut) > 0 { + runOptions.AllowConfigGeneration = tfe.Bool(true) + } + r, err := b.client.Runs.Create(stopCtx, runOptions) if err != nil { return r, generalError("Failed to create run", err) @@ -419,43 +428,91 @@ func (b *Cloud) renderPlanLogs(ctx context.Context, op *backend.Operation, run * } } - // Get the run's current status and include the workspace. We will check if - // the run has errored and if structured output is enabled. + // Get the run's current status and include the workspace and plan. We will check if + // the run has errored, if structured output is enabled, and if the plan run, err = b.client.Runs.ReadWithOptions(ctx, run.ID, &tfe.RunReadOptions{ - Include: []tfe.RunIncludeOpt{tfe.RunWorkspace}, + Include: []tfe.RunIncludeOpt{tfe.RunWorkspace, tfe.RunPlan}, }) if err != nil { return err } // If the run was errored, canceled, or discarded we will not resume the rest - // of this logic and attempt to render the plan. - if run.Status == tfe.RunErrored || run.Status == tfe.RunCanceled || - run.Status == tfe.RunDiscarded { + // of this logic and attempt to render the plan, except in certain special circumstances + // where the plan errored but successfully generated configuration during an + // import operation. In that case, we need to keep going so we can load the JSON plan + // and use it to write the generated config to the specified output file. + shouldGenerateConfig := shouldGenerateConfig(op.GenerateConfigOut, run) + shouldRenderPlan := shouldRenderPlan(run) + if !shouldRenderPlan && !shouldGenerateConfig { // We won't return an error here since we need to resume the logic that // follows after rendering the logs (run tasks, cost estimation, etc.) return nil } - // Determine whether we should call the renderer to generate the plan output - // in human readable format. Otherwise we risk duplicate plan output since - // plan output may be contained in the streamed log file. - if ok, err := b.shouldRenderStructuredRunOutput(run); ok { - // Fetch the redacted plan. - redacted, err := readRedactedPlan(ctx, b.client.BaseURL(), b.token, run.Plan.ID) - if err != nil { - return err - } - - // Render plan output. - b.renderer.RenderHumanPlan(*redacted, op.PlanMode) - } else if err != nil { + // Fetch the redacted JSON plan if we need it for either rendering the plan + // or writing out generated configuration. + var redactedPlan *jsonformat.Plan + renderSRO, err := b.shouldRenderStructuredRunOutput(run) + if err != nil { return err } + if renderSRO || shouldGenerateConfig { + redactedPlan, err = readRedactedPlan(ctx, b.client.BaseURL(), b.token, run.Plan.ID) + if err != nil { + return generalError("Failed to read JSON plan", err) + } + } + + // Write any generated config before rendering the plan, so we can stop in case of errors + if shouldGenerateConfig { + diags := maybeWriteGeneratedConfig(redactedPlan, op.GenerateConfigOut) + if diags.HasErrors() { + return diags.Err() + } + } + + // Only generate the human readable output from the plan if structured run output is + // enabled. Otherwise we risk duplicate plan output since plan output may also be + // shown in the streamed logs. + if shouldRenderPlan && renderSRO { + b.renderer.RenderHumanPlan(*redactedPlan, op.PlanMode) + } return nil } +// maybeWriteGeneratedConfig attempts to write any generated configuration from the JSON plan +// to the specified output file, if generated configuration exists and the correct flag was +// passed to the plan command. +func maybeWriteGeneratedConfig(plan *jsonformat.Plan, out string) (diags tfdiags.Diagnostics) { + if genconfig.ShouldWriteConfig(out) { + diags := genconfig.ValidateTargetFile(out) + if diags.HasErrors() { + return diags + } + + var writer io.Writer + for _, c := range plan.ResourceChanges { + change := genconfig.Change{ + Addr: c.Address, + GeneratedConfig: c.Change.GeneratedConfig, + } + if c.Change.Importing != nil { + change.ImportID = c.Change.Importing.ID + } + + var moreDiags tfdiags.Diagnostics + writer, _, moreDiags = change.MaybeWriteConfig(writer, out) + if moreDiags.HasErrors() { + return diags.Append(moreDiags) + } + } + } + + return diags +} + // shouldRenderStructuredRunOutput ensures the remote workspace has structured // run output enabled and, if using Terraform Enterprise, ensures it is a release // that supports enabling SRO for CLI-driven runs. The plan output will have @@ -496,6 +553,16 @@ func (b *Cloud) shouldRenderStructuredRunOutput(run *tfe.Run) (bool, error) { return false, nil } +func shouldRenderPlan(run *tfe.Run) bool { + return !(run.Status == tfe.RunErrored || run.Status == tfe.RunCanceled || + run.Status == tfe.RunDiscarded) +} + +func shouldGenerateConfig(out string, run *tfe.Run) bool { + return (run.Plan.Status == tfe.PlanErrored || run.Plan.Status == tfe.PlanFinished) && + run.Plan.GeneratedConfiguration && len(out) > 0 +} + const planDefaultHeader = ` [reset][yellow]Running plan in Terraform Cloud. Output will stream here. Pressing Ctrl-C will stop streaming the logs, but will not stop the plan running remotely.[reset] diff --git a/internal/cloud/backend_plan_test.go b/internal/cloud/backend_plan_test.go index 9b18b9daa7..530ed0f4fb 100644 --- a/internal/cloud/backend_plan_test.go +++ b/internal/cloud/backend_plan_test.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "os/signal" + "path/filepath" "strings" "syscall" "testing" @@ -1255,6 +1256,132 @@ func TestCloud_planOtherError(t *testing.T) { } } +func TestCloud_planImportConfigGeneration(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + stream, close := terminal.StreamsForTesting(t) + + b.renderer = &jsonformat.Renderer{ + Streams: stream, + Colorize: mockColorize(), + } + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-import-config-gen") + defer configCleanup() + defer done(t) + + genPath := filepath.Join(op.ConfigDir, "generated.tf") + op.GenerateConfigOut = genPath + defer os.Remove(genPath) + + op.Workspace = testBackendSingleWorkspaceName + + mockSROWorkspace(t, b, op.Workspace) + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationSuccess { + t.Fatalf("operation failed: %s", b.CLI.(*cli.MockUi).ErrorWriter.String()) + } + if run.PlanEmpty { + t.Fatal("expected a non-empty plan") + } + outp := close(t) + gotOut := outp.Stdout() + + if !strings.Contains(gotOut, "1 to import, 0 to add, 0 to change, 0 to destroy") { + t.Fatalf("expected plan summary in output: %s", gotOut) + } + + stateMgr, _ := b.StateMgr(testBackendSingleWorkspaceName) + // An error suggests that the state was not unlocked after the operation finished + if _, err := stateMgr.Lock(statemgr.NewLockInfo()); err != nil { + t.Fatalf("unexpected error locking state after successful plan: %s", err.Error()) + } + + testFileEquals(t, genPath, filepath.Join(op.ConfigDir, "generated.tf.expected")) +} + +func TestCloud_planImportGenerateInvalidConfig(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + stream, close := terminal.StreamsForTesting(t) + + b.renderer = &jsonformat.Renderer{ + Streams: stream, + Colorize: mockColorize(), + } + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-import-config-gen-validation-error") + defer configCleanup() + defer done(t) + + genPath := filepath.Join(op.ConfigDir, "generated.tf") + op.GenerateConfigOut = genPath + defer os.Remove(genPath) + + op.Workspace = testBackendSingleWorkspaceName + + mockSROWorkspace(t, b, op.Workspace) + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + if run.Result != backend.OperationFailure { + t.Fatalf("expected operation to fail") + } + if run.Result.ExitStatus() != 1 { + t.Fatalf("expected exit code 1, got %d", run.Result.ExitStatus()) + } + + outp := close(t) + gotOut := outp.Stdout() + + if !strings.Contains(gotOut, "Conflicting configuration arguments") { + t.Fatalf("Expected error in output: %s", gotOut) + } + + testFileEquals(t, genPath, filepath.Join(op.ConfigDir, "generated.tf.expected")) +} + +func TestCloud_planInvalidGenConfigOutPath(t *testing.T) { + b, bCleanup := testBackendWithName(t) + defer bCleanup() + + op, configCleanup, done := testOperationPlan(t, "./testdata/plan-import-config-gen-exists") + defer configCleanup() + + genPath := filepath.Join(op.ConfigDir, "generated.tf") + op.GenerateConfigOut = genPath + + op.Workspace = testBackendSingleWorkspaceName + + run, err := b.Operation(context.Background(), op) + if err != nil { + t.Fatalf("error starting operation: %v", err) + } + + <-run.Done() + output := done(t) + if run.Result == backend.OperationSuccess { + t.Fatal("expected plan operation to fail") + } + + errOutput := output.Stderr() + if !strings.Contains(errOutput, "generated file already exists") { + t.Fatalf("expected configuration files error, got: %v", errOutput) + } +} + func TestCloud_planShouldRenderSRO(t *testing.T) { t.Run("when instance is TFC", func(t *testing.T) { handlers := map[string]func(http.ResponseWriter, *http.Request){ @@ -1371,3 +1498,21 @@ func assertSRORendered(t *testing.T, b *Cloud, r *tfe.Run, shouldRender bool) { t.Fatalf("expected SRO to be rendered: %t, got %t", shouldRender, got) } } + +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) + } +} diff --git a/internal/cloud/testdata/plan-import-config-gen-exists/generated.tf b/internal/cloud/testdata/plan-import-config-gen-exists/generated.tf new file mode 100644 index 0000000000..1efdb231af --- /dev/null +++ b/internal/cloud/testdata/plan-import-config-gen-exists/generated.tf @@ -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 "terraform_data" "foo" { + input = null + triggers_replace = null +} diff --git a/internal/cloud/testdata/plan-import-config-gen-exists/main.tf b/internal/cloud/testdata/plan-import-config-gen-exists/main.tf new file mode 100644 index 0000000000..8257ac5af6 --- /dev/null +++ b/internal/cloud/testdata/plan-import-config-gen-exists/main.tf @@ -0,0 +1,4 @@ +import { + id = "bar" + to = terraform_data.foo +} diff --git a/internal/cloud/testdata/plan-import-config-gen-validation-error/generated.tf.expected b/internal/cloud/testdata/plan-import-config-gen-validation-error/generated.tf.expected new file mode 100644 index 0000000000..1efdb231af --- /dev/null +++ b/internal/cloud/testdata/plan-import-config-gen-validation-error/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 "terraform_data" "foo" { + input = null + triggers_replace = null +} diff --git a/internal/cloud/testdata/plan-import-config-gen-validation-error/main.tf b/internal/cloud/testdata/plan-import-config-gen-validation-error/main.tf new file mode 100644 index 0000000000..8257ac5af6 --- /dev/null +++ b/internal/cloud/testdata/plan-import-config-gen-validation-error/main.tf @@ -0,0 +1,4 @@ +import { + id = "bar" + to = terraform_data.foo +} diff --git a/internal/cloud/testdata/plan-import-config-gen-validation-error/plan-redacted.json b/internal/cloud/testdata/plan-import-config-gen-validation-error/plan-redacted.json new file mode 100644 index 0000000000..9e24e22517 --- /dev/null +++ b/internal/cloud/testdata/plan-import-config-gen-validation-error/plan-redacted.json @@ -0,0 +1,127 @@ +{ + "format_version": "1.2", + "terraform_version": "1.5.0", + "planned_values": { + "root_module": { + "resources": [ + { + "address": "terraform_data.foo", + "mode": "managed", + "type": "terraform_data", + "name": "foo", + "provider_name": "terraform.io/builtin/terraform", + "schema_version": 0, + "values": { + "id": "bar", + "input": null, + "output": null, + "triggers_replace": null + }, + "sensitive_values": {} + } + ] + } + }, + "resource_changes": [ + { + "address": "terraform_data.foo", + "mode": "managed", + "type": "terraform_data", + "name": "foo", + "provider_name": "terraform.io/builtin/terraform", + "change": { + "actions": [ + "no-op" + ], + "before": { + "id": "bar", + "input": null, + "output": null, + "triggers_replace": null + }, + "after": { + "id": "bar", + "input": null, + "output": null, + "triggers_replace": null + }, + "after_unknown": {}, + "before_sensitive": {}, + "after_sensitive": {}, + "importing": { + "id": "bar" + }, + "generated_config": "resource \"terraform_data\" \"foo\" {\n input = null\n triggers_replace = null\n}" + } + } + ], + "prior_state": { + "format_version": "1.0", + "terraform_version": "1.6.0", + "values": { + "root_module": { + "resources": [ + { + "address": "terraform_data.foo", + "mode": "managed", + "type": "terraform_data", + "name": "foo", + "provider_name": "terraform.io/builtin/terraform", + "schema_version": 0, + "values": { + "id": "bar", + "input": null, + "output": null, + "triggers_replace": null + }, + "sensitive_values": {} + } + ] + } + } + }, + "configuration": { + "provider_config": { + "terraform": { + "name": "terraform", + "full_name": "terraform.io/builtin/terraform" + } + }, + "root_module": {} + }, + "provider_schemas": { + "terraform.io/builtin/terraform": { + "resource_schemas": { + "terraform_data": { + "version": 0, + "block": { + "attributes": { + "id": { + "type": "string", + "description_kind": "plain", + "computed": true + }, + "input": { + "type": "dynamic", + "description_kind": "plain", + "optional": true + }, + "output": { + "type": "dynamic", + "description_kind": "plain", + "computed": true + }, + "triggers_replace": { + "type": "dynamic", + "description_kind": "plain", + "optional": true + } + }, + "description_kind": "plain" + } + } + } + } + }, + "timestamp": "2023-05-30T03:34:55Z" +} \ No newline at end of file diff --git a/internal/cloud/testdata/plan-import-config-gen-validation-error/plan.log b/internal/cloud/testdata/plan-import-config-gen-validation-error/plan.log new file mode 100644 index 0000000000..192b2b801a --- /dev/null +++ b/internal/cloud/testdata/plan-import-config-gen-validation-error/plan.log @@ -0,0 +1,3 @@ +{"@level":"info","@message":"Terraform 1.5.0","@module":"terraform.ui","@timestamp":"2023-05-29T21:30:07.206963-07:00","terraform":"1.5.0","type":"version","ui":"1.1"} +{"@level":"info","@message":"Plan: 0 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","@timestamp":"2023-05-29T21:30:08.302799-07:00","changes":{"add":0,"change":0,"import":0,"remove":0,"operation":"plan"},"type":"change_summary"} +{"@level":"error","@message":"Error: Conflicting configuration arguments","@module":"terraform.ui","@timestamp":"2023-05-29T21:30:08.302847-07:00","diagnostic":{"severity":"error","summary":"Conflicting configuration arguments","detail":"Not allowed","address":"terraform_data.foo","range":{"filename":"generated.tf","start":{"line":22,"column":33,"byte":867},"end":{"line":22,"column":35,"byte":869}}},"type":"diagnostic"} \ No newline at end of file diff --git a/internal/cloud/testdata/plan-import-config-gen/generated.tf.expected b/internal/cloud/testdata/plan-import-config-gen/generated.tf.expected new file mode 100644 index 0000000000..1efdb231af --- /dev/null +++ b/internal/cloud/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 "terraform_data" "foo" { + input = null + triggers_replace = null +} diff --git a/internal/cloud/testdata/plan-import-config-gen/main.tf b/internal/cloud/testdata/plan-import-config-gen/main.tf new file mode 100644 index 0000000000..8257ac5af6 --- /dev/null +++ b/internal/cloud/testdata/plan-import-config-gen/main.tf @@ -0,0 +1,4 @@ +import { + id = "bar" + to = terraform_data.foo +} diff --git a/internal/cloud/testdata/plan-import-config-gen/plan-redacted.json b/internal/cloud/testdata/plan-import-config-gen/plan-redacted.json new file mode 100644 index 0000000000..9e24e22517 --- /dev/null +++ b/internal/cloud/testdata/plan-import-config-gen/plan-redacted.json @@ -0,0 +1,127 @@ +{ + "format_version": "1.2", + "terraform_version": "1.5.0", + "planned_values": { + "root_module": { + "resources": [ + { + "address": "terraform_data.foo", + "mode": "managed", + "type": "terraform_data", + "name": "foo", + "provider_name": "terraform.io/builtin/terraform", + "schema_version": 0, + "values": { + "id": "bar", + "input": null, + "output": null, + "triggers_replace": null + }, + "sensitive_values": {} + } + ] + } + }, + "resource_changes": [ + { + "address": "terraform_data.foo", + "mode": "managed", + "type": "terraform_data", + "name": "foo", + "provider_name": "terraform.io/builtin/terraform", + "change": { + "actions": [ + "no-op" + ], + "before": { + "id": "bar", + "input": null, + "output": null, + "triggers_replace": null + }, + "after": { + "id": "bar", + "input": null, + "output": null, + "triggers_replace": null + }, + "after_unknown": {}, + "before_sensitive": {}, + "after_sensitive": {}, + "importing": { + "id": "bar" + }, + "generated_config": "resource \"terraform_data\" \"foo\" {\n input = null\n triggers_replace = null\n}" + } + } + ], + "prior_state": { + "format_version": "1.0", + "terraform_version": "1.6.0", + "values": { + "root_module": { + "resources": [ + { + "address": "terraform_data.foo", + "mode": "managed", + "type": "terraform_data", + "name": "foo", + "provider_name": "terraform.io/builtin/terraform", + "schema_version": 0, + "values": { + "id": "bar", + "input": null, + "output": null, + "triggers_replace": null + }, + "sensitive_values": {} + } + ] + } + } + }, + "configuration": { + "provider_config": { + "terraform": { + "name": "terraform", + "full_name": "terraform.io/builtin/terraform" + } + }, + "root_module": {} + }, + "provider_schemas": { + "terraform.io/builtin/terraform": { + "resource_schemas": { + "terraform_data": { + "version": 0, + "block": { + "attributes": { + "id": { + "type": "string", + "description_kind": "plain", + "computed": true + }, + "input": { + "type": "dynamic", + "description_kind": "plain", + "optional": true + }, + "output": { + "type": "dynamic", + "description_kind": "plain", + "computed": true + }, + "triggers_replace": { + "type": "dynamic", + "description_kind": "plain", + "optional": true + } + }, + "description_kind": "plain" + } + } + } + } + }, + "timestamp": "2023-05-30T03:34:55Z" +} \ No newline at end of file diff --git a/internal/cloud/testdata/plan-import-config-gen/plan.log b/internal/cloud/testdata/plan-import-config-gen/plan.log new file mode 100644 index 0000000000..2771305567 --- /dev/null +++ b/internal/cloud/testdata/plan-import-config-gen/plan.log @@ -0,0 +1,3 @@ +{"@level":"info","@message":"Terraform 1.5.0","@module":"terraform.ui","@timestamp":"2023-05-29T20:30:14.113797-07:00","terraform":"1.5.0","type":"version","ui":"1.1"} +{"@level":"info","@message":"terraform_data.foo: Plan to import","@module":"terraform.ui","@timestamp":"2023-05-29T20:30:14.130354-07:00","change":{"resource":{"addr":"terraform_data.foo","module":"","resource":"terraform_data.foo","implied_provider":"terraform","resource_type":"terraform_data","resource_name":"foo","resource_key":null},"action":"import","importing":{"id":"bar"},"generated_config":"resource \"terraform_data\" \"foo\" {\n input = null\n triggers_replace = null\n}"},"type":"planned_change"} +{"@level":"info","@message":"Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.","@module":"terraform.ui","@timestamp":"2023-05-29T20:30:14.130392-07:00","changes":{"add":0,"change":0,"import":1,"remove":0,"operation":"plan"},"type":"change_summary"} \ No newline at end of file diff --git a/internal/cloud/tfe_client_mock.go b/internal/cloud/tfe_client_mock.go index 07653d9d5c..83878c7e47 100644 --- a/internal/cloud/tfe_client_mock.go +++ b/internal/cloud/tfe_client_mock.go @@ -1011,16 +1011,17 @@ func (m *MockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*t } r := &tfe.Run{ - ID: GenerateID("run-"), - Actions: &tfe.RunActions{IsCancelable: true}, - Apply: a, - CostEstimate: ce, - HasChanges: false, - Permissions: &tfe.RunPermissions{}, - Plan: p, - ReplaceAddrs: options.ReplaceAddrs, - Status: tfe.RunPending, - TargetAddrs: options.TargetAddrs, + ID: GenerateID("run-"), + Actions: &tfe.RunActions{IsCancelable: true}, + Apply: a, + CostEstimate: ce, + HasChanges: false, + Permissions: &tfe.RunPermissions{}, + Plan: p, + ReplaceAddrs: options.ReplaceAddrs, + Status: tfe.RunPending, + TargetAddrs: options.TargetAddrs, + AllowConfigGeneration: options.AllowConfigGeneration, } if options.Message != nil { @@ -1043,6 +1044,10 @@ func (m *MockRuns) Create(ctx context.Context, options tfe.RunCreateOptions) (*t r.RefreshOnly = *options.RefreshOnly } + if options.AllowConfigGeneration != nil && *options.AllowConfigGeneration { + r.Plan.GeneratedConfiguration = true + } + w, ok := m.client.Workspaces.workspaceIDs[options.Workspace.ID] if !ok { return nil, tfe.ErrResourceNotFound @@ -1105,17 +1110,21 @@ func (m *MockRuns) ReadWithOptions(ctx context.Context, runID string, _ *tfe.Run logs, _ := ioutil.ReadFile(m.client.Plans.logs[r.Plan.LogReadURL]) if r.Status == tfe.RunPlanning && r.Plan.Status == tfe.PlanFinished { - if r.IsDestroy || - bytes.Contains(logs, []byte("1 to add, 0 to change, 0 to destroy")) || - bytes.Contains(logs, []byte("1 to add, 1 to change, 0 to destroy")) { + hasChanges := r.IsDestroy || + bytes.Contains(logs, []byte("1 to add")) || + bytes.Contains(logs, []byte("1 to change")) || + bytes.Contains(logs, []byte("1 to import")) + if hasChanges { r.Actions.IsCancelable = false r.Actions.IsConfirmable = true r.HasChanges = true r.Permissions.CanApply = true } - if bytes.Contains(logs, []byte("null_resource.foo: 1 error")) || - bytes.Contains(logs, []byte("Error: Unsupported block type")) { + hasError := bytes.Contains(logs, []byte("null_resource.foo: 1 error")) || + bytes.Contains(logs, []byte("Error: Unsupported block type")) || + bytes.Contains(logs, []byte("Error: Conflicting configuration arguments")) + if hasError { r.Actions.IsCancelable = false r.HasChanges = false r.Status = tfe.RunErrored diff --git a/internal/genconfig/generate_config_write.go b/internal/genconfig/generate_config_write.go index c068c358c7..99231bd234 100644 --- a/internal/genconfig/generate_config_write.go +++ b/internal/genconfig/generate_config_write.go @@ -5,12 +5,15 @@ import ( "io" "os" - "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/tfdiags" ) -func ValidateTargetFile(out string) tfdiags.Diagnostics { - var diags tfdiags.Diagnostics +func ShouldWriteConfig(out string) bool { + // No specified out file, so don't write anything. + return len(out) != 0 +} + +func ValidateTargetFile(out string) (diags tfdiags.Diagnostics) { if _, err := os.Stat(out); !os.IsNotExist(err) { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, @@ -21,68 +24,56 @@ func ValidateTargetFile(out string) tfdiags.Diagnostics { return diags } -func MaybeWriteGeneratedConfig(plan *plans.Plan, out string) (wroteConfig bool, diags tfdiags.Diagnostics) { - if len(out) == 0 { - // No specified out file, so don't write anything. - return false, nil - } - - diags = ValidateTargetFile(out) - if diags.HasErrors() { - return false, 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 false, diags - } +type Change struct { + Addr string + ImportID string + GeneratedConfig string +} +func (c *Change) MaybeWriteConfig(writer io.Writer, out string) (io.Writer, bool, tfdiags.Diagnostics) { + var wroteConfig bool + var diags tfdiags.Diagnostics + if len(c.GeneratedConfig) > 0 { + if writer == nil { + // Lazily create the generated file, in case we have no + // generated config to create. + if w, 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 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 false, diags + 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 nil, false, 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()))) + 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 nil, false, diags + } else { + writer = w } - wroteConfig = true + + 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 len(c.ImportID) > 0 { + header += fmt.Sprintf(" from %q", c.ImportID) + } + header += "\n" + if _, err := writer.Write([]byte(fmt.Sprintf("%s%s\n", header, c.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, c.Addr))) + } + wroteConfig = true } - if wroteConfig { - diags = diags.Append(tfdiags.Sourceless( - tfdiags.Warning, - "Config generation is experimental", - "Generating configuration during import is currently experimental, and the generated configuration format may change in future versions.")) - } - - return wroteConfig, diags + return writer, wroteConfig, diags }