mirror of
https://github.com/opentofu/opentofu.git
synced 2025-02-25 18:45:20 -06:00
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:
commit
8213513e2b
2
go.mod
2
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
|
||||
|
6
go.sum
6
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=
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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]
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
8
internal/cloud/testdata/plan-import-config-gen-exists/generated.tf
vendored
Normal file
8
internal/cloud/testdata/plan-import-config-gen-exists/generated.tf
vendored
Normal 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
|
||||
}
|
4
internal/cloud/testdata/plan-import-config-gen-exists/main.tf
vendored
Normal file
4
internal/cloud/testdata/plan-import-config-gen-exists/main.tf
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
import {
|
||||
id = "bar"
|
||||
to = terraform_data.foo
|
||||
}
|
8
internal/cloud/testdata/plan-import-config-gen-validation-error/generated.tf.expected
vendored
Normal file
8
internal/cloud/testdata/plan-import-config-gen-validation-error/generated.tf.expected
vendored
Normal 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
|
||||
}
|
4
internal/cloud/testdata/plan-import-config-gen-validation-error/main.tf
vendored
Normal file
4
internal/cloud/testdata/plan-import-config-gen-validation-error/main.tf
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
import {
|
||||
id = "bar"
|
||||
to = terraform_data.foo
|
||||
}
|
127
internal/cloud/testdata/plan-import-config-gen-validation-error/plan-redacted.json
vendored
Normal file
127
internal/cloud/testdata/plan-import-config-gen-validation-error/plan-redacted.json
vendored
Normal 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"
|
||||
}
|
3
internal/cloud/testdata/plan-import-config-gen-validation-error/plan.log
vendored
Normal file
3
internal/cloud/testdata/plan-import-config-gen-validation-error/plan.log
vendored
Normal 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"}
|
8
internal/cloud/testdata/plan-import-config-gen/generated.tf.expected
vendored
Normal file
8
internal/cloud/testdata/plan-import-config-gen/generated.tf.expected
vendored
Normal 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
|
||||
}
|
4
internal/cloud/testdata/plan-import-config-gen/main.tf
vendored
Normal file
4
internal/cloud/testdata/plan-import-config-gen/main.tf
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
import {
|
||||
id = "bar"
|
||||
to = terraform_data.foo
|
||||
}
|
127
internal/cloud/testdata/plan-import-config-gen/plan-redacted.json
vendored
Normal file
127
internal/cloud/testdata/plan-import-config-gen/plan-redacted.json
vendored
Normal 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"
|
||||
}
|
3
internal/cloud/testdata/plan-import-config-gen/plan.log
vendored
Normal file
3
internal/cloud/testdata/plan-import-config-gen/plan.log
vendored
Normal 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"}
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user