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 }