github.com/opentofu/opentofu@v1.7.1/internal/cloud/backend_apply.go (about)

     1  // Copyright (c) The OpenTofu Authors
     2  // SPDX-License-Identifier: MPL-2.0
     3  // Copyright (c) 2023 HashiCorp, Inc.
     4  // SPDX-License-Identifier: MPL-2.0
     5  
     6  package cloud
     7  
     8  import (
     9  	"bufio"
    10  	"context"
    11  	"encoding/json"
    12  	"fmt"
    13  	"io"
    14  	"log"
    15  	"strings"
    16  
    17  	tfe "github.com/hashicorp/go-tfe"
    18  	"github.com/opentofu/opentofu/internal/backend"
    19  	"github.com/opentofu/opentofu/internal/command/jsonformat"
    20  	"github.com/opentofu/opentofu/internal/plans"
    21  	"github.com/opentofu/opentofu/internal/tfdiags"
    22  	"github.com/opentofu/opentofu/internal/tofu"
    23  )
    24  
    25  func (b *Cloud) opApply(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*tfe.Run, error) {
    26  	log.Printf("[INFO] cloud: starting Apply operation")
    27  
    28  	var diags tfdiags.Diagnostics
    29  
    30  	// We should remove the `CanUpdate` part of this test, but for now
    31  	// (to remain compatible with tfe.v2.1) we'll leave it in here.
    32  	if !w.Permissions.CanUpdate && !w.Permissions.CanQueueApply {
    33  		diags = diags.Append(tfdiags.Sourceless(
    34  			tfdiags.Error,
    35  			"Insufficient rights to apply changes",
    36  			"The provided credentials have insufficient rights to apply changes. In order "+
    37  				"to apply changes at least write permissions on the workspace are required.",
    38  		))
    39  		return nil, diags.Err()
    40  	}
    41  
    42  	if w.VCSRepo != nil {
    43  		diags = diags.Append(tfdiags.Sourceless(
    44  			tfdiags.Error,
    45  			"Apply not allowed for workspaces with a VCS connection",
    46  			"A workspace that is connected to a VCS requires the VCS-driven workflow "+
    47  				"to ensure that the VCS remains the single source of truth.",
    48  		))
    49  		return nil, diags.Err()
    50  	}
    51  
    52  	if b.ContextOpts != nil && b.ContextOpts.Parallelism != defaultParallelism {
    53  		diags = diags.Append(tfdiags.Sourceless(
    54  			tfdiags.Error,
    55  			"Custom parallelism values are currently not supported",
    56  			`Cloud backend does not support setting a custom parallelism `+
    57  				`value at this time.`,
    58  		))
    59  	}
    60  
    61  	if op.PlanFile.IsLocal() {
    62  		diags = diags.Append(tfdiags.Sourceless(
    63  			tfdiags.Error,
    64  			"Applying a saved local plan is not supported",
    65  			`Cloud backend can apply a saved cloud plan, or create a new plan when `+
    66  				`configuration is present. It cannot apply a saved local plan.`,
    67  		))
    68  	}
    69  
    70  	if !op.HasConfig() && op.PlanMode != plans.DestroyMode {
    71  		diags = diags.Append(tfdiags.Sourceless(
    72  			tfdiags.Error,
    73  			"No configuration files found",
    74  			`Apply requires configuration to be present. Applying without a configuration `+
    75  				`would mark everything for destruction, which is normally not what is desired. `+
    76  				`If you would like to destroy everything, please run 'tofu destroy' which `+
    77  				`does not require any configuration files.`,
    78  		))
    79  	}
    80  
    81  	// Return if there are any errors.
    82  	if diags.HasErrors() {
    83  		return nil, diags.Err()
    84  	}
    85  
    86  	var r *tfe.Run
    87  	var err error
    88  
    89  	if cp, ok := op.PlanFile.Cloud(); ok {
    90  		log.Printf("[TRACE] Loading saved cloud plan for apply")
    91  		// Check hostname first, for a more actionable error than a generic 404 later
    92  		if cp.Hostname != b.hostname {
    93  			diags = diags.Append(tfdiags.Sourceless(
    94  				tfdiags.Error,
    95  				"Saved plan is for a different hostname",
    96  				fmt.Sprintf("The given saved plan refers to a run on %s, but the currently configured cloud backend instance is %s.", cp.Hostname, b.hostname),
    97  			))
    98  			return r, diags.Err()
    99  		}
   100  		// Fetch the run referenced in the saved plan bookmark.
   101  		r, err = b.client.Runs.ReadWithOptions(stopCtx, cp.RunID, &tfe.RunReadOptions{
   102  			Include: []tfe.RunIncludeOpt{tfe.RunWorkspace},
   103  		})
   104  
   105  		if err != nil {
   106  			return r, err
   107  		}
   108  
   109  		if r.Workspace.ID != w.ID {
   110  			diags = diags.Append(tfdiags.Sourceless(
   111  				tfdiags.Error,
   112  				"Saved plan is for a different workspace",
   113  				fmt.Sprintf("The given saved plan does not refer to a run in the current workspace (%s/%s), so it cannot currently be applied. For more details, view this run in a browser at:\n%s", w.Organization.Name, w.Name, runURL(b.hostname, r.Workspace.Organization.Name, r.Workspace.Name, r.ID)),
   114  			))
   115  			return r, diags.Err()
   116  		}
   117  
   118  		if !r.Actions.IsConfirmable {
   119  			url := runURL(b.hostname, b.organization, op.Workspace, r.ID)
   120  			return r, unusableSavedPlanError(r.Status, url)
   121  		}
   122  
   123  		// Since we're not calling plan(), we need to print a run header ourselves:
   124  		if b.CLI != nil {
   125  			b.CLI.Output(b.Colorize().Color(strings.TrimSpace(applySavedHeader) + "\n"))
   126  			b.CLI.Output(b.Colorize().Color(strings.TrimSpace(fmt.Sprintf(
   127  				runHeader, b.hostname, b.organization, r.Workspace.Name, r.ID)) + "\n"))
   128  		}
   129  	} else {
   130  		log.Printf("[TRACE] Running new cloud plan for apply")
   131  		// Run the plan phase.
   132  		r, err = b.plan(stopCtx, cancelCtx, op, w)
   133  
   134  		if err != nil {
   135  			return r, err
   136  		}
   137  
   138  		// This check is also performed in the plan method to determine if
   139  		// the policies should be checked, but we need to check the values
   140  		// here again to determine if we are done and should return.
   141  		if !r.HasChanges || r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored {
   142  			return r, nil
   143  		}
   144  
   145  		// Retrieve the run to get its current status.
   146  		r, err = b.client.Runs.Read(stopCtx, r.ID)
   147  		if err != nil {
   148  			return r, generalError("Failed to retrieve run", err)
   149  		}
   150  
   151  		// Return if the run cannot be confirmed.
   152  		if !op.AutoApprove && !r.Actions.IsConfirmable {
   153  			return r, nil
   154  		}
   155  
   156  		mustConfirm := (op.UIIn != nil && op.UIOut != nil) && !op.AutoApprove
   157  
   158  		if mustConfirm && b.input {
   159  			opts := &tofu.InputOpts{Id: "approve"}
   160  
   161  			if op.PlanMode == plans.DestroyMode {
   162  				opts.Query = "\nDo you really want to destroy all resources in workspace \"" + op.Workspace + "\"?"
   163  				opts.Description = "OpenTofu will destroy all your managed infrastructure, as shown above.\n" +
   164  					"There is no undo. Only 'yes' will be accepted to confirm."
   165  			} else {
   166  				opts.Query = "\nDo you want to perform these actions in workspace \"" + op.Workspace + "\"?"
   167  				opts.Description = "OpenTofu will perform the actions described above.\n" +
   168  					"Only 'yes' will be accepted to approve."
   169  			}
   170  
   171  			err = b.confirm(stopCtx, op, opts, r, "yes")
   172  			if err != nil && err != errRunApproved {
   173  				return r, err
   174  			}
   175  		} else if mustConfirm && !b.input {
   176  			return r, errApplyNeedsUIConfirmation
   177  		} else {
   178  			// If we don't need to ask for confirmation, insert a blank
   179  			// line to separate the ouputs.
   180  			if b.CLI != nil {
   181  				b.CLI.Output("")
   182  			}
   183  		}
   184  	}
   185  
   186  	// Do the apply!
   187  	if !op.AutoApprove && err != errRunApproved {
   188  		if err = b.client.Runs.Apply(stopCtx, r.ID, tfe.RunApplyOptions{}); err != nil {
   189  			return r, generalError("Failed to approve the apply command", err)
   190  		}
   191  	}
   192  
   193  	// Retrieve the run to get task stages.
   194  	// Task Stages are calculated upfront so we only need to call this once for the run.
   195  	taskStages, err := b.runTaskStages(stopCtx, b.client, r.ID)
   196  	if err != nil {
   197  		return r, err
   198  	}
   199  
   200  	if stage, ok := taskStages[tfe.PreApply]; ok {
   201  		if err := b.waitTaskStage(stopCtx, cancelCtx, op, r, stage.ID, "Pre-apply Tasks"); err != nil {
   202  			return r, err
   203  		}
   204  	}
   205  
   206  	r, err = b.waitForRun(stopCtx, cancelCtx, op, "apply", r, w)
   207  	if err != nil {
   208  		return r, err
   209  	}
   210  
   211  	err = b.renderApplyLogs(stopCtx, r)
   212  	if err != nil {
   213  		return r, err
   214  	}
   215  
   216  	return r, nil
   217  }
   218  
   219  func (b *Cloud) renderApplyLogs(ctx context.Context, run *tfe.Run) error {
   220  	logs, err := b.client.Applies.Logs(ctx, run.Apply.ID)
   221  	if err != nil {
   222  		return err
   223  	}
   224  
   225  	if b.CLI != nil {
   226  		reader := bufio.NewReaderSize(logs, 64*1024)
   227  		skip := 0
   228  
   229  		for next := true; next; {
   230  			var l, line []byte
   231  			var err error
   232  
   233  			for isPrefix := true; isPrefix; {
   234  				l, isPrefix, err = reader.ReadLine()
   235  				if err != nil {
   236  					if err != io.EOF {
   237  						return generalError("Failed to read logs", err)
   238  					}
   239  					next = false
   240  				}
   241  
   242  				line = append(line, l...)
   243  			}
   244  
   245  			// Apply logs show the same Terraform info logs as shown in the plan logs
   246  			// (which contain version and os/arch information), we therefore skip to prevent duplicate output.
   247  			if skip < 3 {
   248  				skip++
   249  				continue
   250  			}
   251  
   252  			if next || len(line) > 0 {
   253  				log := &jsonformat.JSONLog{}
   254  				if err := json.Unmarshal(line, log); err != nil {
   255  					// If we can not parse the line as JSON, we will simply
   256  					// print the line. This maintains backwards compatibility for
   257  					// users who do not wish to enable structured output in their
   258  					// workspace.
   259  					b.CLI.Output(string(line))
   260  					continue
   261  				}
   262  
   263  				if b.renderer != nil {
   264  					// Otherwise, we will print the log
   265  					err := b.renderer.RenderLog(log)
   266  					if err != nil {
   267  						return err
   268  					}
   269  				}
   270  			}
   271  		}
   272  	}
   273  
   274  	return nil
   275  }
   276  
   277  func runURL(hostname, orgName, wsName, runID string) string {
   278  	return fmt.Sprintf("https://%s/app/%s/%s/runs/%s", hostname, orgName, wsName, runID)
   279  }
   280  
   281  func unusableSavedPlanError(status tfe.RunStatus, url string) error {
   282  	var diags tfdiags.Diagnostics
   283  	var summary, reason string
   284  
   285  	switch status {
   286  	case tfe.RunApplied:
   287  		summary = "Saved plan is already applied"
   288  		reason = "The given plan file was already successfully applied, and cannot be applied again."
   289  	case tfe.RunApplying, tfe.RunApplyQueued, tfe.RunConfirmed:
   290  		summary = "Saved plan is already confirmed"
   291  		reason = "The given plan file is already being applied, and cannot be applied again."
   292  	case tfe.RunCanceled:
   293  		summary = "Saved plan is canceled"
   294  		reason = "The given plan file can no longer be applied because the run was canceled via the cloud backend UI or API."
   295  	case tfe.RunDiscarded:
   296  		summary = "Saved plan is discarded"
   297  		reason = "The given plan file can no longer be applied; either another run was applied first, or a user discarded it via the cloud backend UI or API."
   298  	case tfe.RunErrored:
   299  		summary = "Saved plan is errored"
   300  		reason = "The given plan file refers to a plan that had errors and did not complete successfully. It cannot be applied."
   301  	case tfe.RunPlannedAndFinished:
   302  		// Note: planned and finished can also indicate a plan-only run, but
   303  		// tofu plan can't create a saved plan for a plan-only run, so we
   304  		// know it's no-changes in this case.
   305  		summary = "Saved plan has no changes"
   306  		reason = "The given plan file contains no changes, so it cannot be applied."
   307  	case tfe.RunPolicyOverride:
   308  		summary = "Saved plan requires policy override"
   309  		reason = "The given plan file has soft policy failures, and cannot be applied until a user with appropriate permissions overrides the policy check."
   310  	default:
   311  		summary = "Saved plan cannot be applied"
   312  		reason = "Cloud backend cannot apply the given plan file. This may mean the plan and checks have not yet completed, or may indicate another problem."
   313  	}
   314  
   315  	diags = diags.Append(tfdiags.Sourceless(
   316  		tfdiags.Error,
   317  		summary,
   318  		fmt.Sprintf("%s For more details, view this run in a browser at:\n%s", reason, url),
   319  	))
   320  	return diags.Err()
   321  }
   322  
   323  const applyDefaultHeader = `
   324  [reset][yellow]Running apply in cloud backend. Output will stream here. Pressing Ctrl-C
   325  will cancel the remote apply if it's still pending. If the apply started it
   326  will stop streaming the logs, but will not stop the apply running remotely.[reset]
   327  
   328  Preparing the remote apply...
   329  `
   330  
   331  const applySavedHeader = `
   332  [reset][yellow]Running apply in cloud backend. Output will stream here. Pressing Ctrl-C
   333  will stop streaming the logs, but will not stop the apply running remotely.[reset]
   334  
   335  Preparing the remote apply...
   336  `