// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 package remote import ( "bufio" "context" "fmt" "io" "log" tfe "github.com/hashicorp/go-tfe" version "github.com/hashicorp/go-version" "github.com/opentofu/opentofu/internal/backend" "github.com/opentofu/opentofu/internal/plans" "github.com/opentofu/opentofu/internal/tfdiags" "github.com/opentofu/opentofu/internal/tofu" ) func (b *Remote) opApply(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*tfe.Run, error) { log.Printf("[INFO] backend/remote: starting Apply operation") var diags tfdiags.Diagnostics // We should remove the `CanUpdate` part of this test, but for now // (to remain compatible with tfe.v2.1) we'll leave it in here. if !w.Permissions.CanUpdate && !w.Permissions.CanQueueApply { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Insufficient rights to apply changes", "The provided credentials have insufficient rights to apply changes. In order "+ "to apply changes at least write permissions on the workspace are required.", )) return nil, diags.Err() } if w.VCSRepo != nil { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Apply not allowed for workspaces with a VCS connection", "A workspace that is connected to a VCS requires the VCS-driven workflow "+ "to ensure that the VCS remains the single source of truth.", )) return nil, diags.Err() } if b.ContextOpts != nil && b.ContextOpts.Parallelism != defaultParallelism { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Custom parallelism values are currently not supported", `The "remote" backend does not support setting a custom parallelism `+ `value at this time.`, )) } if op.PlanFile != nil { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Applying a saved plan is currently not supported", `The "remote" backend currently requires configuration to be present and `+ `does not accept an existing saved plan as an argument at this time.`, )) } if b.hasExplicitVariableValues(op) { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Run variables are currently not supported", fmt.Sprintf( "The \"remote\" backend does not support setting run variables at this time. "+ "Currently the only to way to pass variables to the remote backend is by "+ "creating a '*.auto.tfvars' variables file. This file will automatically "+ "be loaded by the \"remote\" backend when the workspace is configured to use "+ "OpenTofu v0.10.0 or later.\n\nAdditionally you can also set variables on "+ "the workspace in the web UI:\nhttps://%s/app/%s/%s/variables", b.hostname, b.organization, op.Workspace, ), )) } if !op.HasConfig() && op.PlanMode != plans.DestroyMode { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "No configuration files found", `Apply requires configuration to be present. Applying without a configuration `+ `would mark everything for destruction, which is normally not what is desired. `+ `If you would like to destroy everything, please run 'tofu destroy' which `+ `does not require any configuration files.`, )) } // For API versions prior to 2.3, RemoteAPIVersion will return an empty string, // so if there's an error when parsing the RemoteAPIVersion, it's handled as // equivalent to an API version < 2.3. currentAPIVersion, parseErr := version.NewVersion(b.client.RemoteAPIVersion()) if !op.PlanRefresh { desiredAPIVersion, _ := version.NewVersion("2.4") if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Planning without refresh is not supported", fmt.Sprintf( `The host %s does not support the -refresh=false option for `+ `remote plans.`, b.hostname, ), )) } } if op.PlanMode == plans.RefreshOnlyMode { desiredAPIVersion, _ := version.NewVersion("2.4") if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Refresh-only mode is not supported", fmt.Sprintf( `The host %s does not support -refresh-only mode for `+ `remote plans.`, b.hostname, ), )) } } if len(op.ForceReplace) != 0 { desiredAPIVersion, _ := version.NewVersion("2.4") if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Planning resource replacements is not supported", fmt.Sprintf( `The host %s does not support the -replace option for `+ `remote plans.`, b.hostname, ), )) } } if len(op.Targets) != 0 { desiredAPIVersion, _ := version.NewVersion("2.3") if parseErr != nil || currentAPIVersion.LessThan(desiredAPIVersion) { diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Resource targeting is not supported", fmt.Sprintf( `The host %s does not support the -target option for `+ `remote plans.`, b.hostname, ), )) } } // Return if there are any errors. if diags.HasErrors() { return nil, diags.Err() } // Run the plan phase. r, err := b.plan(stopCtx, cancelCtx, op, w) if err != nil { return r, err } // This check is also performed in the plan method to determine if // the policies should be checked, but we need to check the values // here again to determine if we are done and should return. if !r.HasChanges || r.Status == tfe.RunCanceled || r.Status == tfe.RunErrored { return r, nil } // Retrieve the run to get its current status. r, err = b.client.Runs.Read(stopCtx, r.ID) if err != nil { return r, generalError("Failed to retrieve run", err) } // Return if the run cannot be confirmed. if !w.AutoApply && !r.Actions.IsConfirmable { return r, nil } // Since we already checked the permissions before creating the run // this should never happen. But it doesn't hurt to keep this in as // a safeguard for any unexpected situations. if !w.AutoApply && !r.Permissions.CanApply { // Make sure we discard the run if possible. if r.Actions.IsDiscardable { err = b.client.Runs.Discard(stopCtx, r.ID, tfe.RunDiscardOptions{}) if err != nil { switch op.PlanMode { case plans.DestroyMode: return r, generalError("Failed to discard destroy", err) default: return r, generalError("Failed to discard apply", err) } } } diags = diags.Append(tfdiags.Sourceless( tfdiags.Error, "Insufficient rights to approve the pending changes", fmt.Sprintf("There are pending changes, but the provided credentials have "+ "insufficient rights to approve them. The run will be discarded to prevent "+ "it from blocking the queue waiting for external approval. To queue a run "+ "that can be approved by someone else, please use the 'Queue Plan' button in "+ "the web UI:\nhttps://%s/app/%s/%s/runs", b.hostname, b.organization, op.Workspace), )) return r, diags.Err() } mustConfirm := (op.UIIn != nil && op.UIOut != nil) && !op.AutoApprove if !w.AutoApply { if mustConfirm { opts := &tofu.InputOpts{Id: "approve"} if op.PlanMode == plans.DestroyMode { opts.Query = "\nDo you really want to destroy all resources in workspace \"" + op.Workspace + "\"?" opts.Description = "OpenTofu will destroy all your managed infrastructure, as shown above.\n" + "There is no undo. Only 'yes' will be accepted to confirm." } else { opts.Query = "\nDo you want to perform these actions in workspace \"" + op.Workspace + "\"?" opts.Description = "OpenTofu will perform the actions described above.\n" + "Only 'yes' will be accepted to approve." } err = b.confirm(stopCtx, op, opts, r, "yes") if err != nil && err != errRunApproved { return r, err } } if err != errRunApproved { if err = b.client.Runs.Apply(stopCtx, r.ID, tfe.RunApplyOptions{}); err != nil { return r, generalError("Failed to approve the apply command", err) } } } // If we don't need to ask for confirmation, insert a blank // line to separate the ouputs. if w.AutoApply || !mustConfirm { if b.CLI != nil { b.CLI.Output("") } } r, err = b.waitForRun(stopCtx, cancelCtx, op, "apply", r, w) if err != nil { return r, err } logs, err := b.client.Applies.Logs(stopCtx, r.Apply.ID) if err != nil { return r, generalError("Failed to retrieve logs", err) } reader := bufio.NewReaderSize(logs, 64*1024) if b.CLI != nil { skip := 0 for next := true; next; { var l, line []byte for isPrefix := true; isPrefix; { l, isPrefix, err = reader.ReadLine() if err != nil { if err != io.EOF { return r, generalError("Failed to read logs", err) } next = false } line = append(line, l...) } // Skip the first 3 lines to prevent duplicate output. if skip < 3 { skip++ continue } if next || len(line) > 0 { b.CLI.Output(b.Colorize().Color(string(line))) } } } return r, nil } const applyDefaultHeader = ` [reset][yellow]Running apply in the remote backend. Output will stream here. Pressing Ctrl-C will cancel the remote apply if it's still pending. If the apply started it will stop streaming the logs, but will not stop the apply running remotely.[reset] Preparing the remote apply... `