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 `