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  `