github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/backend/local/backend_plan.go (about)

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: MPL-2.0
     3  
     4  package local
     5  
     6  import (
     7  	"context"
     8  	"fmt"
     9  	"io"
    10  	"log"
    11  
    12  	"github.com/terramate-io/tf/backend"
    13  	"github.com/terramate-io/tf/genconfig"
    14  	"github.com/terramate-io/tf/logging"
    15  	"github.com/terramate-io/tf/plans"
    16  	"github.com/terramate-io/tf/plans/planfile"
    17  	"github.com/terramate-io/tf/states/statefile"
    18  	"github.com/terramate-io/tf/states/statemgr"
    19  	"github.com/terramate-io/tf/terraform"
    20  	"github.com/terramate-io/tf/tfdiags"
    21  )
    22  
    23  func (b *Local) opPlan(
    24  	stopCtx context.Context,
    25  	cancelCtx context.Context,
    26  	op *backend.Operation,
    27  	runningOp *backend.RunningOperation) {
    28  
    29  	log.Printf("[INFO] backend/local: starting Plan operation")
    30  
    31  	var diags tfdiags.Diagnostics
    32  
    33  	if op.PlanFile != nil {
    34  		diags = diags.Append(tfdiags.Sourceless(
    35  			tfdiags.Error,
    36  			"Can't re-plan a saved plan",
    37  			"The plan command was given a saved plan file as its input. This command generates "+
    38  				"a new plan, and so it requires a configuration directory as its argument.",
    39  		))
    40  		op.ReportResult(runningOp, diags)
    41  		return
    42  	}
    43  
    44  	// Local planning requires a config, unless we're planning to destroy.
    45  	if op.PlanMode != plans.DestroyMode && !op.HasConfig() {
    46  		diags = diags.Append(tfdiags.Sourceless(
    47  			tfdiags.Error,
    48  			"No configuration files",
    49  			"Plan requires configuration to be present. Planning without a configuration would "+
    50  				"mark everything for destruction, which is normally not what is desired. If you "+
    51  				"would like to destroy everything, run plan with the -destroy option. Otherwise, "+
    52  				"create a Terraform configuration file (.tf file) and try again.",
    53  		))
    54  		op.ReportResult(runningOp, diags)
    55  		return
    56  	}
    57  
    58  	if len(op.GenerateConfigOut) > 0 {
    59  		if op.PlanMode != plans.NormalMode {
    60  			diags = diags.Append(tfdiags.Sourceless(
    61  				tfdiags.Error,
    62  				"Invalid generate-config-out flag",
    63  				"Config can only be generated during a normal plan operation, and not during a refresh-only or destroy plan."))
    64  			op.ReportResult(runningOp, diags)
    65  			return
    66  		}
    67  
    68  		diags = diags.Append(genconfig.ValidateTargetFile(op.GenerateConfigOut))
    69  		if diags.HasErrors() {
    70  			op.ReportResult(runningOp, diags)
    71  			return
    72  		}
    73  	}
    74  
    75  	if b.ContextOpts == nil {
    76  		b.ContextOpts = new(terraform.ContextOpts)
    77  	}
    78  
    79  	// Get our context
    80  	lr, configSnap, opState, ctxDiags := b.localRun(op)
    81  	diags = diags.Append(ctxDiags)
    82  	if ctxDiags.HasErrors() {
    83  		op.ReportResult(runningOp, diags)
    84  		return
    85  	}
    86  	// the state was locked during succesfull context creation; unlock the state
    87  	// when the operation completes
    88  	defer func() {
    89  		diags := op.StateLocker.Unlock()
    90  		if diags.HasErrors() {
    91  			op.View.Diagnostics(diags)
    92  			runningOp.Result = backend.OperationFailure
    93  		}
    94  	}()
    95  
    96  	// Since planning doesn't immediately change the persisted state, the
    97  	// resulting state is always just the input state.
    98  	runningOp.State = lr.InputState
    99  
   100  	// Perform the plan in a goroutine so we can be interrupted
   101  	var plan *plans.Plan
   102  	var planDiags tfdiags.Diagnostics
   103  	doneCh := make(chan struct{})
   104  	go func() {
   105  		defer logging.PanicHandler()
   106  		defer close(doneCh)
   107  		log.Printf("[INFO] backend/local: plan calling Plan")
   108  		plan, planDiags = lr.Core.Plan(lr.Config, lr.InputState, lr.PlanOpts)
   109  	}()
   110  
   111  	if b.opWait(doneCh, stopCtx, cancelCtx, lr.Core, opState, op.View) {
   112  		// If we get in here then the operation was cancelled, which is always
   113  		// considered to be a failure.
   114  		log.Printf("[INFO] backend/local: plan operation was force-cancelled by interrupt")
   115  		runningOp.Result = backend.OperationFailure
   116  		return
   117  	}
   118  	log.Printf("[INFO] backend/local: plan operation completed")
   119  
   120  	// NOTE: We intentionally don't stop here on errors because we always want
   121  	// to try to present a partial plan report and, if the user chose to,
   122  	// generate a partial saved plan file for external analysis.
   123  	diags = diags.Append(planDiags)
   124  
   125  	// Even if there are errors we need to handle anything that may be
   126  	// contained within the plan, so only exit if there is no data at all.
   127  	if plan == nil {
   128  		runningOp.PlanEmpty = true
   129  		op.ReportResult(runningOp, diags)
   130  		return
   131  	}
   132  
   133  	// Record whether this plan includes any side-effects that could be applied.
   134  	runningOp.PlanEmpty = !plan.CanApply()
   135  
   136  	// Save the plan to disk
   137  	if path := op.PlanOutPath; path != "" {
   138  		if op.PlanOutBackend == nil {
   139  			// This is always a bug in the operation caller; it's not valid
   140  			// to set PlanOutPath without also setting PlanOutBackend.
   141  			diags = diags.Append(fmt.Errorf(
   142  				"PlanOutPath set without also setting PlanOutBackend (this is a bug in Terraform)"),
   143  			)
   144  			op.ReportResult(runningOp, diags)
   145  			return
   146  		}
   147  		plan.Backend = *op.PlanOutBackend
   148  
   149  		// We may have updated the state in the refresh step above, but we
   150  		// will freeze that updated state in the plan file for now and
   151  		// only write it if this plan is subsequently applied.
   152  		plannedStateFile := statemgr.PlannedStateUpdate(opState, plan.PriorState)
   153  
   154  		// We also include a file containing the state as it existed before
   155  		// we took any action at all, but this one isn't intended to ever
   156  		// be saved to the backend (an equivalent snapshot should already be
   157  		// there) and so we just use a stub state file header in this case.
   158  		// NOTE: This won't be exactly identical to the latest state snapshot
   159  		// in the backend because it's still been subject to state upgrading
   160  		// to make it consumable by the current Terraform version, and
   161  		// intentionally doesn't preserve the header info.
   162  		prevStateFile := &statefile.File{
   163  			State: plan.PrevRunState,
   164  		}
   165  
   166  		log.Printf("[INFO] backend/local: writing plan output to: %s", path)
   167  		err := planfile.Create(path, planfile.CreateArgs{
   168  			ConfigSnapshot:       configSnap,
   169  			PreviousRunStateFile: prevStateFile,
   170  			StateFile:            plannedStateFile,
   171  			Plan:                 plan,
   172  			DependencyLocks:      op.DependencyLocks,
   173  		})
   174  		if err != nil {
   175  			diags = diags.Append(tfdiags.Sourceless(
   176  				tfdiags.Error,
   177  				"Failed to write plan file",
   178  				fmt.Sprintf("The plan file could not be written: %s.", err),
   179  			))
   180  			op.ReportResult(runningOp, diags)
   181  			return
   182  		}
   183  	}
   184  
   185  	// Render the plan, if we produced one.
   186  	// (This might potentially be a partial plan with Errored set to true)
   187  	schemas, moreDiags := lr.Core.Schemas(lr.Config, lr.InputState)
   188  	diags = diags.Append(moreDiags)
   189  	if moreDiags.HasErrors() {
   190  		op.ReportResult(runningOp, diags)
   191  		return
   192  	}
   193  
   194  	// Write out any generated config, before we render the plan.
   195  	wroteConfig, moreDiags := maybeWriteGeneratedConfig(plan, op.GenerateConfigOut)
   196  	diags = diags.Append(moreDiags)
   197  	if moreDiags.HasErrors() {
   198  		op.ReportResult(runningOp, diags)
   199  		return
   200  	}
   201  
   202  	op.View.Plan(plan, schemas)
   203  
   204  	// If we've accumulated any diagnostics along the way then we'll show them
   205  	// here just before we show the summary and next steps. This can potentially
   206  	// include errors, because we intentionally try to show a partial plan
   207  	// above even if Terraform Core encountered an error partway through
   208  	// creating it.
   209  	op.ReportResult(runningOp, diags)
   210  
   211  	if !runningOp.PlanEmpty {
   212  		if wroteConfig {
   213  			op.View.PlanNextStep(op.PlanOutPath, op.GenerateConfigOut)
   214  		} else {
   215  			op.View.PlanNextStep(op.PlanOutPath, "")
   216  		}
   217  	}
   218  }
   219  
   220  func maybeWriteGeneratedConfig(plan *plans.Plan, out string) (wroteConfig bool, diags tfdiags.Diagnostics) {
   221  	if genconfig.ShouldWriteConfig(out) {
   222  		diags := genconfig.ValidateTargetFile(out)
   223  		if diags.HasErrors() {
   224  			return false, diags
   225  		}
   226  
   227  		var writer io.Writer
   228  		for _, c := range plan.Changes.Resources {
   229  			change := genconfig.Change{
   230  				Addr:            c.Addr.String(),
   231  				GeneratedConfig: c.GeneratedConfig,
   232  			}
   233  			if c.Importing != nil {
   234  				change.ImportID = c.Importing.ID
   235  			}
   236  
   237  			var moreDiags tfdiags.Diagnostics
   238  			writer, wroteConfig, moreDiags = change.MaybeWriteConfig(writer, out)
   239  			if moreDiags.HasErrors() {
   240  				return false, diags.Append(moreDiags)
   241  			}
   242  		}
   243  	}
   244  
   245  	if wroteConfig {
   246  		diags = diags.Append(tfdiags.Sourceless(
   247  			tfdiags.Warning,
   248  			"Config generation is experimental",
   249  			"Generating configuration during import is currently experimental, and the generated configuration format may change in future versions."))
   250  	}
   251  
   252  	return wroteConfig, diags
   253  }