write generated config when using the cloud integration

This commit is contained in:
CJ Horton 2023-05-30 00:17:02 -07:00
parent b88bae2ec4
commit cdce4c4a6d
6 changed files with 191 additions and 89 deletions

2
go.mod
View File

@ -1,6 +1,6 @@
module github.com/hashicorp/terraform
replace github.com/hashicorp/go-tfe v1.24.0 => github.com/hashicorp/go-tfe v1.25.2-0.20230526220312-15160638651d
replace github.com/hashicorp/go-tfe v1.24.0 => github.com/hashicorp/go-tfe v1.25.2-0.20230530060311-414a2bce2afa
require (
cloud.google.com/go/kms v1.6.0

4
go.sum
View File

@ -542,8 +542,8 @@ github.com/hashicorp/go-slug v0.11.1/go.mod h1:Ib+IWBYfEfJGI1ZyXMGNbu2BU+aa3Dzu4
github.com/hashicorp/go-sockaddr v1.0.0 h1:GeH6tui99pF4NJgfnhp+L6+FfobzVW3Ah46sLo0ICXs=
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-tfe v1.25.2-0.20230526220312-15160638651d h1:M2fcs6fQ6Nq7mmOzsH/VI0m66q8XTz65jk0DzQ74gmw=
github.com/hashicorp/go-tfe v1.25.2-0.20230526220312-15160638651d/go.mod h1:1Y6nsdMuJ14lYdc1VMLl/erlthvMzUsJn+WYWaAdSc4=
github.com/hashicorp/go-tfe v1.25.2-0.20230530060311-414a2bce2afa h1:MDW7I6XkDAypUklJjsUlsMg4nfZgBYucTY7BppEhJ4w=
github.com/hashicorp/go-tfe v1.25.2-0.20230530060311-414a2bce2afa/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=

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

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

@ -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 == true {
r.Plan.GeneratedConfiguration = true
}
w, ok := m.client.Workspaces.workspaceIDs[options.Workspace.ID]
if !ok {
return nil, tfe.ErrResourceNotFound

View File

@ -5,12 +5,18 @@ 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 {
if len(out) == 0 {
// No specified out file, so don't write anything.
return false
}
return true
}
func ValidateTargetFile(out string) (diags tfdiags.Diagnostics) {
if _, err := os.Stat(out); !os.IsNotExist(err) {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
@ -21,68 +27,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
}