Merge pull request #33278 from hashicorp/radditude/cloud-config-generation

plannable import: allow writing generated config when using the cloud integration
This commit is contained in:
CJ Horton 2023-05-31 12:00:37 -07:00 committed by GitHub
commit 8213513e2b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 678 additions and 95 deletions

2
go.mod
View File

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

6
go.sum
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 "terraform_data" "foo" {
input = null
triggers_replace = null
}

View File

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

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 "terraform_data" "foo" {
input = null
triggers_replace = null
}

View File

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

View File

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

View File

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

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 "terraform_data" "foo" {
input = null
triggers_replace = null
}

View File

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

View File

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

View File

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

View File

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

View File

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