github.com/kevinklinger/open_terraform@v1.3.6/noninternal/backend/local/backend_apply.go (about) 1 package local 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 "log" 8 9 "github.com/kevinklinger/open_terraform/noninternal/backend" 10 "github.com/kevinklinger/open_terraform/noninternal/command/views" 11 "github.com/kevinklinger/open_terraform/noninternal/logging" 12 "github.com/kevinklinger/open_terraform/noninternal/plans" 13 "github.com/kevinklinger/open_terraform/noninternal/states" 14 "github.com/kevinklinger/open_terraform/noninternal/states/statefile" 15 "github.com/kevinklinger/open_terraform/noninternal/states/statemgr" 16 "github.com/kevinklinger/open_terraform/noninternal/terraform" 17 "github.com/kevinklinger/open_terraform/noninternal/tfdiags" 18 ) 19 20 // test hook called between plan+apply during opApply 21 var testHookStopPlanApply func() 22 23 func (b *Local) opApply( 24 stopCtx context.Context, 25 cancelCtx context.Context, 26 op *backend.Operation, 27 runningOp *backend.RunningOperation) { 28 log.Printf("[INFO] backend/local: starting Apply operation") 29 30 var diags, moreDiags tfdiags.Diagnostics 31 32 // If we have a nil module at this point, then set it to an empty tree 33 // to avoid any potential crashes. 34 if op.PlanFile == nil && op.PlanMode != plans.DestroyMode && !op.HasConfig() { 35 diags = diags.Append(tfdiags.Sourceless( 36 tfdiags.Error, 37 "No configuration files", 38 "Apply requires configuration to be present. Applying without a configuration "+ 39 "would mark everything for destruction, which is normally not what is desired. "+ 40 "If you would like to destroy everything, run 'terraform destroy' instead.", 41 )) 42 op.ReportResult(runningOp, diags) 43 return 44 } 45 46 stateHook := new(StateHook) 47 op.Hooks = append(op.Hooks, stateHook) 48 49 // Get our context 50 lr, _, opState, contextDiags := b.localRun(op) 51 diags = diags.Append(contextDiags) 52 if contextDiags.HasErrors() { 53 op.ReportResult(runningOp, diags) 54 return 55 } 56 // the state was locked during successful context creation; unlock the state 57 // when the operation completes 58 defer func() { 59 diags := op.StateLocker.Unlock() 60 if diags.HasErrors() { 61 op.View.Diagnostics(diags) 62 runningOp.Result = backend.OperationFailure 63 } 64 }() 65 66 // We'll start off with our result being the input state, and replace it 67 // with the result state only if we eventually complete the apply 68 // operation. 69 runningOp.State = lr.InputState 70 71 schemas, moreDiags := lr.Core.Schemas(lr.Config, lr.InputState) 72 diags = diags.Append(moreDiags) 73 if moreDiags.HasErrors() { 74 op.ReportResult(runningOp, diags) 75 return 76 } 77 78 var plan *plans.Plan 79 // If we weren't given a plan, then we refresh/plan 80 if op.PlanFile == nil { 81 // Perform the plan 82 log.Printf("[INFO] backend/local: apply calling Plan") 83 plan, moreDiags = lr.Core.Plan(lr.Config, lr.InputState, lr.PlanOpts) 84 diags = diags.Append(moreDiags) 85 if moreDiags.HasErrors() { 86 op.ReportResult(runningOp, diags) 87 return 88 } 89 90 trivialPlan := !plan.CanApply() 91 hasUI := op.UIOut != nil && op.UIIn != nil 92 mustConfirm := hasUI && !op.AutoApprove && !trivialPlan 93 op.View.Plan(plan, schemas) 94 95 if testHookStopPlanApply != nil { 96 testHookStopPlanApply() 97 } 98 99 // Check if we've been stopped before going through confirmation, or 100 // skipping confirmation in the case of -auto-approve. 101 // This can currently happen if a single stop request was received 102 // during the final batch of resource plan calls, so no operations were 103 // forced to abort, and no errors were returned from Plan. 104 if stopCtx.Err() != nil { 105 diags = diags.Append(errors.New("execution halted")) 106 runningOp.Result = backend.OperationFailure 107 op.ReportResult(runningOp, diags) 108 return 109 } 110 111 if mustConfirm { 112 var desc, query string 113 switch op.PlanMode { 114 case plans.DestroyMode: 115 if op.Workspace != "default" { 116 query = "Do you really want to destroy all resources in workspace \"" + op.Workspace + "\"?" 117 } else { 118 query = "Do you really want to destroy all resources?" 119 } 120 desc = "Terraform will destroy all your managed infrastructure, as shown above.\n" + 121 "There is no undo. Only 'yes' will be accepted to confirm." 122 case plans.RefreshOnlyMode: 123 if op.Workspace != "default" { 124 query = "Would you like to update the Terraform state for \"" + op.Workspace + "\" to reflect these detected changes?" 125 } else { 126 query = "Would you like to update the Terraform state to reflect these detected changes?" 127 } 128 desc = "Terraform will write these changes to the state without modifying any real infrastructure.\n" + 129 "There is no undo. Only 'yes' will be accepted to confirm." 130 default: 131 if op.Workspace != "default" { 132 query = "Do you want to perform these actions in workspace \"" + op.Workspace + "\"?" 133 } else { 134 query = "Do you want to perform these actions?" 135 } 136 desc = "Terraform will perform the actions described above.\n" + 137 "Only 'yes' will be accepted to approve." 138 } 139 140 // We'll show any accumulated warnings before we display the prompt, 141 // so the user can consider them when deciding how to answer. 142 if len(diags) > 0 { 143 op.View.Diagnostics(diags) 144 diags = nil // reset so we won't show the same diagnostics again later 145 } 146 147 v, err := op.UIIn.Input(stopCtx, &terraform.InputOpts{ 148 Id: "approve", 149 Query: "\n" + query, 150 Description: desc, 151 }) 152 if err != nil { 153 diags = diags.Append(fmt.Errorf("error asking for approval: %w", err)) 154 op.ReportResult(runningOp, diags) 155 return 156 } 157 if v != "yes" { 158 op.View.Cancelled(op.PlanMode) 159 runningOp.Result = backend.OperationFailure 160 return 161 } 162 } 163 } else { 164 plan = lr.Plan 165 for _, change := range plan.Changes.Resources { 166 if change.Action != plans.NoOp { 167 op.View.PlannedChange(change) 168 } 169 } 170 } 171 172 // Set up our hook for continuous state updates 173 stateHook.StateMgr = opState 174 175 // Start the apply in a goroutine so that we can be interrupted. 176 var applyState *states.State 177 var applyDiags tfdiags.Diagnostics 178 doneCh := make(chan struct{}) 179 go func() { 180 defer logging.PanicHandler() 181 defer close(doneCh) 182 log.Printf("[INFO] backend/local: apply calling Apply") 183 applyState, applyDiags = lr.Core.Apply(plan, lr.Config) 184 }() 185 186 if b.opWait(doneCh, stopCtx, cancelCtx, lr.Core, opState, op.View) { 187 return 188 } 189 diags = diags.Append(applyDiags) 190 191 // Even on error with an empty state, the state value should not be nil. 192 // Return early here to prevent corrupting any existing state. 193 if diags.HasErrors() && applyState == nil { 194 log.Printf("[ERROR] backend/local: apply returned nil state") 195 op.ReportResult(runningOp, diags) 196 return 197 } 198 199 // Store the final state 200 runningOp.State = applyState 201 err := statemgr.WriteAndPersist(opState, applyState, schemas) 202 if err != nil { 203 // Export the state file from the state manager and assign the new 204 // state. This is needed to preserve the existing serial and lineage. 205 stateFile := statemgr.Export(opState) 206 if stateFile == nil { 207 stateFile = &statefile.File{} 208 } 209 stateFile.State = applyState 210 211 diags = diags.Append(b.backupStateForError(stateFile, err, op.View)) 212 op.ReportResult(runningOp, diags) 213 return 214 } 215 216 if applyDiags.HasErrors() { 217 op.ReportResult(runningOp, diags) 218 return 219 } 220 221 // If we've accumulated any warnings along the way then we'll show them 222 // here just before we show the summary and next steps. If we encountered 223 // errors then we would've returned early at some other point above. 224 op.View.Diagnostics(diags) 225 } 226 227 // backupStateForError is called in a scenario where we're unable to persist the 228 // state for some reason, and will attempt to save a backup copy of the state 229 // to local disk to help the user recover. This is a "last ditch effort" sort 230 // of thing, so we really don't want to end up in this codepath; we should do 231 // everything we possibly can to get the state saved _somewhere_. 232 func (b *Local) backupStateForError(stateFile *statefile.File, err error, view views.Operation) tfdiags.Diagnostics { 233 var diags tfdiags.Diagnostics 234 235 diags = diags.Append(tfdiags.Sourceless( 236 tfdiags.Error, 237 "Failed to save state", 238 fmt.Sprintf("Error saving state: %s", err), 239 )) 240 241 local := statemgr.NewFilesystem("errored.tfstate") 242 writeErr := local.WriteStateForMigration(stateFile, true) 243 if writeErr != nil { 244 diags = diags.Append(tfdiags.Sourceless( 245 tfdiags.Error, 246 "Failed to create local state file", 247 fmt.Sprintf("Error creating local state file for recovery: %s", writeErr), 248 )) 249 250 // To avoid leaving the user with no state at all, our last resort 251 // is to print the JSON state out onto the terminal. This is an awful 252 // UX, so we should definitely avoid doing this if at all possible, 253 // but at least the user has _some_ path to recover if we end up 254 // here for some reason. 255 if dumpErr := view.EmergencyDumpState(stateFile); dumpErr != nil { 256 diags = diags.Append(tfdiags.Sourceless( 257 tfdiags.Error, 258 "Failed to serialize state", 259 fmt.Sprintf(stateWriteFatalErrorFmt, dumpErr), 260 )) 261 } 262 263 diags = diags.Append(tfdiags.Sourceless( 264 tfdiags.Error, 265 "Failed to persist state to backend", 266 stateWriteConsoleFallbackError, 267 )) 268 return diags 269 } 270 271 diags = diags.Append(tfdiags.Sourceless( 272 tfdiags.Error, 273 "Failed to persist state to backend", 274 stateWriteBackedUpError, 275 )) 276 277 return diags 278 } 279 280 const stateWriteBackedUpError = `The error shown above has prevented Terraform from writing the updated state to the configured backend. To allow for recovery, the state has been written to the file "errored.tfstate" in the current working directory. 281 282 Running "terraform apply" again at this point will create a forked state, making it harder to recover. 283 284 To retry writing this state, use the following command: 285 terraform state push errored.tfstate 286 ` 287 288 const stateWriteConsoleFallbackError = `The errors shown above prevented Terraform from writing the updated state to 289 the configured backend and from creating a local backup file. As a fallback, 290 the raw state data is printed above as a JSON object. 291 292 To retry writing this state, copy the state data (from the first { to the last } inclusive) and save it into a local file called errored.tfstate, then run the following command: 293 terraform state push errored.tfstate 294 ` 295 296 const stateWriteFatalErrorFmt = `Failed to save state after apply. 297 298 Error serializing state: %s 299 300 A catastrophic error has prevented Terraform from persisting the state file or creating a backup. Unfortunately this means that the record of any resources created during this apply has been lost, and such resources may exist outside of Terraform's management. 301 302 For resources that support import, it is possible to recover by manually importing each resource using its id from the target system. 303 304 This is a serious bug in Terraform and should be reported. 305 `