github.com/opentofu/opentofu@v1.7.1/internal/cloud/backend_plan.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 "errors" 13 "fmt" 14 "io" 15 "log" 16 "os" 17 "path/filepath" 18 "strconv" 19 "strings" 20 "syscall" 21 "time" 22 23 tfe "github.com/hashicorp/go-tfe" 24 version "github.com/hashicorp/go-version" 25 26 "github.com/opentofu/opentofu/internal/backend" 27 "github.com/opentofu/opentofu/internal/cloud/cloudplan" 28 "github.com/opentofu/opentofu/internal/command/jsonformat" 29 "github.com/opentofu/opentofu/internal/configs" 30 "github.com/opentofu/opentofu/internal/genconfig" 31 "github.com/opentofu/opentofu/internal/plans" 32 "github.com/opentofu/opentofu/internal/tfdiags" 33 ) 34 35 var planConfigurationVersionsPollInterval = 500 * time.Millisecond 36 37 func (b *Cloud) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*tfe.Run, error) { 38 log.Printf("[INFO] cloud: starting Plan operation") 39 40 var diags tfdiags.Diagnostics 41 42 if !w.Permissions.CanQueueRun { 43 diags = diags.Append(tfdiags.Sourceless( 44 tfdiags.Error, 45 "Insufficient rights to generate a plan", 46 "The provided credentials have insufficient rights to generate a plan. In order "+ 47 "to generate plans, at least plan permissions on the workspace are required.", 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 != nil { 62 diags = diags.Append(tfdiags.Sourceless( 63 tfdiags.Error, 64 "Displaying a saved plan is currently not supported", 65 `Cloud backend currently requires configuration to be present and `+ 66 `does not accept an existing saved plan as an argument at this time.`, 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 `Plan requires configuration to be present. Planning 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 plan with the "-destroy" `+ 77 `flag or create a single empty configuration file. Otherwise, please create `+ 78 `a OpenTofu configuration file in the path being executed and try again.`, 79 )) 80 } 81 82 if len(op.GenerateConfigOut) > 0 { 83 diags = diags.Append(genconfig.ValidateTargetFile(op.GenerateConfigOut)) 84 } 85 86 // Return if there are any errors. 87 if diags.HasErrors() { 88 return nil, diags.Err() 89 } 90 91 // If the run errored, exit before checking whether to save a plan file 92 run, err := b.plan(stopCtx, cancelCtx, op, w) 93 if err != nil { 94 return nil, err 95 } 96 97 // Save plan file if -out <FILE> was specified 98 if op.PlanOutPath != "" { 99 bookmark := cloudplan.NewSavedPlanBookmark(run.ID, b.hostname) 100 err = bookmark.Save(op.PlanOutPath) 101 if err != nil { 102 return nil, err 103 } 104 } 105 106 // Everything succeded, so display next steps 107 op.View.PlanNextStep(op.PlanOutPath, op.GenerateConfigOut) 108 109 return run, nil 110 } 111 112 func (b *Cloud) plan(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*tfe.Run, error) { 113 if b.CLI != nil { 114 header := planDefaultHeader 115 if op.Type == backend.OperationTypeApply || op.Type == backend.OperationTypeRefresh { 116 header = applyDefaultHeader 117 } 118 b.CLI.Output(b.Colorize().Color(strings.TrimSpace(header) + "\n")) 119 } 120 121 // Plan-only means they ran tofu plan without -out. 122 provisional := op.PlanOutPath != "" 123 planOnly := op.Type == backend.OperationTypePlan && !provisional 124 125 configOptions := tfe.ConfigurationVersionCreateOptions{ 126 AutoQueueRuns: tfe.Bool(false), 127 Speculative: tfe.Bool(planOnly), 128 Provisional: tfe.Bool(provisional), 129 } 130 131 cv, err := b.client.ConfigurationVersions.Create(stopCtx, w.ID, configOptions) 132 if err != nil { 133 return nil, generalError("Failed to create configuration version", err) 134 } 135 136 var configDir string 137 if op.ConfigDir != "" { 138 // De-normalize the configuration directory path. 139 configDir, err = filepath.Abs(op.ConfigDir) 140 if err != nil { 141 return nil, generalError( 142 "Failed to get absolute path of the configuration directory: %v", err) 143 } 144 145 // Make sure to take the working directory into account by removing 146 // the working directory from the current path. This will result in 147 // a path that points to the expected root of the workspace. 148 configDir = filepath.Clean(strings.TrimSuffix( 149 filepath.Clean(configDir), 150 filepath.Clean(w.WorkingDirectory), 151 )) 152 153 // If the workspace has a subdirectory as its working directory then 154 // our configDir will be some parent directory of the current working 155 // directory. Users are likely to find that surprising, so we'll 156 // produce an explicit message about it to be transparent about what 157 // we are doing and why. 158 if w.WorkingDirectory != "" && filepath.Base(configDir) != w.WorkingDirectory { 159 if b.CLI != nil { 160 b.CLI.Output(fmt.Sprintf(strings.TrimSpace(` 161 The remote workspace is configured to work with configuration at 162 %s relative to the target repository. 163 164 OpenTofu will upload the contents of the following directory, 165 excluding files or directories as defined by a .terraformignore file 166 at %s/.terraformignore (if it is present), 167 in order to capture the filesystem context the remote workspace expects: 168 %s 169 `), w.WorkingDirectory, configDir, configDir) + "\n") 170 } 171 } 172 173 } else { 174 // We did a check earlier to make sure we either have a config dir, 175 // or the plan is run with -destroy. So this else clause will only 176 // be executed when we are destroying and doesn't need the config. 177 configDir, err = os.MkdirTemp("", "tf") 178 if err != nil { 179 return nil, generalError("Failed to create temporary directory", err) 180 } 181 defer os.RemoveAll(configDir) 182 183 // Make sure the configured working directory exists. 184 err = os.MkdirAll(filepath.Join(configDir, w.WorkingDirectory), 0700) 185 if err != nil { 186 return nil, generalError( 187 "Failed to create temporary working directory", err) 188 } 189 } 190 191 err = b.client.ConfigurationVersions.Upload(stopCtx, cv.UploadURL, configDir) 192 if err != nil { 193 return nil, generalError("Failed to upload configuration files", err) 194 } 195 196 uploaded := false 197 for i := 0; i < 60 && !uploaded; i++ { 198 select { 199 case <-stopCtx.Done(): 200 return nil, context.Canceled 201 case <-cancelCtx.Done(): 202 return nil, context.Canceled 203 case <-time.After(planConfigurationVersionsPollInterval): 204 cv, err = b.client.ConfigurationVersions.Read(stopCtx, cv.ID) 205 if err != nil { 206 return nil, generalError("Failed to retrieve configuration version", err) 207 } 208 209 if cv.Status == tfe.ConfigurationUploaded { 210 uploaded = true 211 } 212 } 213 } 214 215 if !uploaded { 216 return nil, generalError( 217 "Failed to upload configuration files", errors.New("operation timed out")) 218 } 219 220 runOptions := tfe.RunCreateOptions{ 221 ConfigurationVersion: cv, 222 Refresh: tfe.Bool(op.PlanRefresh), 223 Workspace: w, 224 AutoApply: tfe.Bool(op.AutoApprove), 225 SavePlan: tfe.Bool(op.PlanOutPath != ""), 226 } 227 228 switch op.PlanMode { 229 case plans.NormalMode: 230 // okay, but we don't need to do anything special for this 231 case plans.RefreshOnlyMode: 232 runOptions.RefreshOnly = tfe.Bool(true) 233 case plans.DestroyMode: 234 runOptions.IsDestroy = tfe.Bool(true) 235 default: 236 // Shouldn't get here because we should update this for each new 237 // plan mode we add, mapping it to the corresponding RunCreateOptions 238 // field. 239 return nil, generalError( 240 "Invalid plan mode", 241 fmt.Errorf("Cloud backend doesn't support %s", op.PlanMode), 242 ) 243 } 244 245 if len(op.Targets) != 0 { 246 runOptions.TargetAddrs = make([]string, 0, len(op.Targets)) 247 for _, addr := range op.Targets { 248 runOptions.TargetAddrs = append(runOptions.TargetAddrs, addr.String()) 249 } 250 } 251 252 if len(op.ForceReplace) != 0 { 253 runOptions.ReplaceAddrs = make([]string, 0, len(op.ForceReplace)) 254 for _, addr := range op.ForceReplace { 255 runOptions.ReplaceAddrs = append(runOptions.ReplaceAddrs, addr.String()) 256 } 257 } 258 259 config, _, configDiags := op.ConfigLoader.LoadConfigWithSnapshot(op.ConfigDir) 260 if configDiags.HasErrors() { 261 return nil, fmt.Errorf("error loading config with snapshot: %w", configDiags.Errs()[0]) 262 } 263 264 variables, varDiags := ParseCloudRunVariables(op.Variables, config.Module.Variables) 265 266 if varDiags.HasErrors() { 267 return nil, varDiags.Err() 268 } 269 270 runVariables := make([]*tfe.RunVariable, 0, len(variables)) 271 for name, value := range variables { 272 runVariables = append(runVariables, &tfe.RunVariable{ 273 Key: name, 274 Value: value, 275 }) 276 } 277 runOptions.Variables = runVariables 278 279 if len(op.GenerateConfigOut) > 0 { 280 runOptions.AllowConfigGeneration = tfe.Bool(true) 281 } 282 283 r, err := b.client.Runs.Create(stopCtx, runOptions) 284 if err != nil { 285 return r, generalError("Failed to create run", err) 286 } 287 288 // When the lock timeout is set, if the run is still pending and 289 // cancellable after that period, we attempt to cancel it. 290 if lockTimeout := op.StateLocker.Timeout(); lockTimeout > 0 { 291 go func() { 292 select { 293 case <-stopCtx.Done(): 294 return 295 case <-cancelCtx.Done(): 296 return 297 case <-time.After(lockTimeout): 298 // Retrieve the run to get its current status. 299 r, err := b.client.Runs.Read(cancelCtx, r.ID) 300 if err != nil { 301 log.Printf("[ERROR] error reading run: %v", err) 302 return 303 } 304 305 if r.Status == tfe.RunPending && r.Actions.IsCancelable { 306 if b.CLI != nil { 307 b.CLI.Output(b.Colorize().Color(strings.TrimSpace(lockTimeoutErr))) 308 } 309 310 // We abuse the auto aprove flag to indicate that we do not 311 // want to ask if the remote operation should be canceled. 312 op.AutoApprove = true 313 314 p, err := os.FindProcess(os.Getpid()) 315 if err != nil { 316 log.Printf("[ERROR] error searching process ID: %v", err) 317 return 318 } 319 p.Signal(syscall.SIGINT) 320 } 321 } 322 }() 323 } 324 325 if b.CLI != nil { 326 b.CLI.Output(b.Colorize().Color(strings.TrimSpace(fmt.Sprintf( 327 runHeader, b.hostname, b.organization, op.Workspace, r.ID)) + "\n")) 328 } 329 330 // Render any warnings that were raised during run creation 331 if err := b.renderRunWarnings(stopCtx, b.client, r.ID); err != nil { 332 return r, err 333 } 334 335 // Retrieve the run to get task stages. 336 // Task Stages are calculated upfront so we only need to call this once for the run. 337 taskStages, err := b.runTaskStages(stopCtx, b.client, r.ID) 338 if err != nil { 339 return r, err 340 } 341 342 if stage, ok := taskStages[tfe.PrePlan]; ok { 343 if err := b.waitTaskStage(stopCtx, cancelCtx, op, r, stage.ID, "Pre-plan Tasks"); err != nil { 344 return r, err 345 } 346 } 347 348 r, err = b.waitForRun(stopCtx, cancelCtx, op, "plan", r, w) 349 if err != nil { 350 return r, err 351 } 352 353 err = b.renderPlanLogs(stopCtx, op, r) 354 if err != nil { 355 return r, err 356 } 357 358 // Retrieve the run to get its current status. 359 r, err = b.client.Runs.Read(stopCtx, r.ID) 360 if err != nil { 361 return r, generalError("Failed to retrieve run", err) 362 } 363 364 // If the run is canceled or errored, we still continue to the 365 // cost-estimation and policy check phases to ensure we render any 366 // results available. In the case of a hard-failed policy check, the 367 // status of the run will be "errored", but there is still policy 368 // information which should be shown. 369 370 if stage, ok := taskStages[tfe.PostPlan]; ok { 371 if err := b.waitTaskStage(stopCtx, cancelCtx, op, r, stage.ID, "Post-plan Tasks"); err != nil { 372 return r, err 373 } 374 } 375 376 // Show any cost estimation output. 377 if r.CostEstimate != nil { 378 err = b.costEstimate(stopCtx, cancelCtx, op, r) 379 if err != nil { 380 return r, err 381 } 382 } 383 384 // Check any configured sentinel policies. 385 if len(r.PolicyChecks) > 0 { 386 err = b.checkPolicy(stopCtx, cancelCtx, op, r) 387 if err != nil { 388 return r, err 389 } 390 } 391 392 return r, nil 393 } 394 395 // AssertImportCompatible errors if the user is attempting to use configuration- 396 // driven import and the version of the agent or API is too low to support it. 397 func (b *Cloud) AssertImportCompatible(config *configs.Config) error { 398 // Check TFC_RUN_ID is populated, indicating we are running in a remote TFC 399 // execution environment. 400 if len(config.Module.Import) > 0 && os.Getenv("TFC_RUN_ID") != "" { 401 // First, check the remote API version is high enough. 402 currentAPIVersion, err := version.NewVersion(b.client.RemoteAPIVersion()) 403 if err != nil { 404 return fmt.Errorf("Error parsing remote API version. To proceed, please remove any import blocks from your config. Please report the following error to the OpenTofu team: %w", err) 405 } 406 desiredAPIVersion, _ := version.NewVersion("2.6") 407 if currentAPIVersion.LessThan(desiredAPIVersion) { 408 return fmt.Errorf("Import blocks are not supported in this version of the cloud backend. Please remove any import blocks from your config or upgrade the cloud backend.") 409 } 410 411 // Second, check the agent version is high enough. 412 agentEnv, isSet := os.LookupEnv("TFC_AGENT_VERSION") 413 if !isSet { 414 return fmt.Errorf("Error reading TFC agent version. To proceed, please remove any import blocks from your config. Please report the following error to the OpenTofu team: TFC_AGENT_VERSION not present.") 415 } 416 currentAgentVersion, err := version.NewVersion(agentEnv) 417 if err != nil { 418 return fmt.Errorf("Error parsing TFC agent version. To proceed, please remove any import blocks from your config. Please report the following error to the OpenTofu team: %w", err) 419 } 420 desiredAgentVersion, _ := version.NewVersion("1.10") 421 if currentAgentVersion.LessThan(desiredAgentVersion) { 422 return fmt.Errorf("Import blocks are not supported in this version of the cloud backend Agent. You are using agent version %s, but this feature requires version %s. Please remove any import blocks from your config or upgrade your agent.", currentAgentVersion, desiredAgentVersion) 423 } 424 } 425 return nil 426 } 427 428 // renderPlanLogs reads the streamed plan JSON logs and calls the JSON Plan renderer (jsonformat.RenderPlan) to 429 // render the plan output. The plan output is fetched from the redacted output endpoint. 430 func (b *Cloud) renderPlanLogs(ctx context.Context, op *backend.Operation, run *tfe.Run) error { 431 logs, err := b.client.Plans.Logs(ctx, run.Plan.ID) 432 if err != nil { 433 return err 434 } 435 436 if b.CLI != nil { 437 reader := bufio.NewReaderSize(logs, 64*1024) 438 439 for next := true; next; { 440 var l, line []byte 441 var err error 442 443 for isPrefix := true; isPrefix; { 444 l, isPrefix, err = reader.ReadLine() 445 if err != nil { 446 if err != io.EOF { 447 return generalError("Failed to read logs", err) 448 } 449 next = false 450 } 451 452 line = append(line, l...) 453 } 454 455 if next || len(line) > 0 { 456 log := &jsonformat.JSONLog{} 457 if err := json.Unmarshal(line, log); err != nil { 458 // If we can not parse the line as JSON, we will simply 459 // print the line. This maintains backwards compatibility for 460 // users who do not wish to enable structured output in their 461 // workspace. 462 b.CLI.Output(string(line)) 463 continue 464 } 465 466 // We will ignore plan output, change summary or outputs logs 467 // during the plan phase. 468 if log.Type == jsonformat.LogOutputs || 469 log.Type == jsonformat.LogChangeSummary || 470 log.Type == jsonformat.LogPlannedChange { 471 continue 472 } 473 474 if b.renderer != nil { 475 // Otherwise, we will print the log 476 err := b.renderer.RenderLog(log) 477 if err != nil { 478 return err 479 } 480 } 481 } 482 } 483 } 484 485 // Get the run's current status and include the workspace and plan. We will check if 486 // the run has errored, if structured output is enabled, and if the plan 487 run, err = b.client.Runs.ReadWithOptions(ctx, run.ID, &tfe.RunReadOptions{ 488 Include: []tfe.RunIncludeOpt{tfe.RunWorkspace, tfe.RunPlan}, 489 }) 490 if err != nil { 491 return err 492 } 493 494 // If the run was errored, canceled, or discarded we will not resume the rest 495 // of this logic and attempt to render the plan, except in certain special circumstances 496 // where the plan errored but successfully generated configuration during an 497 // import operation. In that case, we need to keep going so we can load the JSON plan 498 // and use it to write the generated config to the specified output file. 499 shouldGenerateConfig := shouldGenerateConfig(op.GenerateConfigOut, run) 500 shouldRenderPlan := shouldRenderPlan(run) 501 if !shouldRenderPlan && !shouldGenerateConfig { 502 // We won't return an error here since we need to resume the logic that 503 // follows after rendering the logs (run tasks, cost estimation, etc.) 504 return nil 505 } 506 507 // Fetch the redacted JSON plan if we need it for either rendering the plan 508 // or writing out generated configuration. 509 var redactedPlan *jsonformat.Plan 510 renderSRO, err := b.shouldRenderStructuredRunOutput(run) 511 if err != nil { 512 return err 513 } 514 if renderSRO || shouldGenerateConfig { 515 jsonBytes, err := readRedactedPlan(ctx, b.client.BaseURL(), b.token, run.Plan.ID) 516 if err != nil { 517 return generalError("Failed to read JSON plan", err) 518 } 519 redactedPlan, err = decodeRedactedPlan(jsonBytes) 520 if err != nil { 521 return generalError("Failed to decode JSON plan", err) 522 } 523 } 524 525 // Write any generated config before rendering the plan, so we can stop in case of errors 526 if shouldGenerateConfig { 527 diags := maybeWriteGeneratedConfig(redactedPlan, op.GenerateConfigOut) 528 if diags.HasErrors() { 529 return diags.Err() 530 } 531 } 532 533 // Only generate the human readable output from the plan if structured run output is 534 // enabled. Otherwise we risk duplicate plan output since plan output may also be 535 // shown in the streamed logs. 536 if shouldRenderPlan && renderSRO { 537 b.renderer.RenderHumanPlan(*redactedPlan, op.PlanMode) 538 } 539 540 return nil 541 } 542 543 // maybeWriteGeneratedConfig attempts to write any generated configuration from the JSON plan 544 // to the specified output file, if generated configuration exists and the correct flag was 545 // passed to the plan command. 546 func maybeWriteGeneratedConfig(plan *jsonformat.Plan, out string) (diags tfdiags.Diagnostics) { 547 if genconfig.ShouldWriteConfig(out) { 548 diags := genconfig.ValidateTargetFile(out) 549 if diags.HasErrors() { 550 return diags 551 } 552 553 var writer io.Writer 554 for _, c := range plan.ResourceChanges { 555 change := genconfig.Change{ 556 Addr: c.Address, 557 GeneratedConfig: c.Change.GeneratedConfig, 558 } 559 if c.Change.Importing != nil { 560 change.ImportID = c.Change.Importing.ID 561 } 562 563 var moreDiags tfdiags.Diagnostics 564 writer, _, moreDiags = change.MaybeWriteConfig(writer, out) 565 if moreDiags.HasErrors() { 566 return diags.Append(moreDiags) 567 } 568 } 569 } 570 571 return diags 572 } 573 574 // shouldRenderStructuredRunOutput ensures the remote workspace has structured 575 // run output enabled and, if using Terraform Enterprise, ensures it is a release 576 // that supports enabling SRO for CLI-driven runs. The plan output will have 577 // already been rendered when the logs were read if this wasn't the case. 578 func (b *Cloud) shouldRenderStructuredRunOutput(run *tfe.Run) (bool, error) { 579 if b.renderer == nil || !run.Workspace.StructuredRunOutputEnabled { 580 return false, nil 581 } 582 583 // If the cloud backend is configured against TFC, we only require that 584 // the workspace has structured run output enabled. 585 if b.client.IsCloud() && run.Workspace.StructuredRunOutputEnabled { 586 return true, nil 587 } 588 589 // If the cloud backend is configured against TFE, ensure the release version 590 // supports enabling SRO for CLI runs. 591 if b.client.IsEnterprise() { 592 tfeVersion := b.client.RemoteTFEVersion() 593 if tfeVersion != "" { 594 v := strings.Split(tfeVersion[1:], "-") 595 releaseDate, err := strconv.Atoi(v[0]) 596 if err != nil { 597 return false, err 598 } 599 600 // Any release older than 202302-1 will not support enabling SRO for 601 // CLI-driven runs 602 if releaseDate < 202302 { 603 return false, nil 604 } else if run.Workspace.StructuredRunOutputEnabled { 605 return true, nil 606 } 607 } 608 } 609 610 // Version of TFE is unknowable 611 return false, nil 612 } 613 614 func shouldRenderPlan(run *tfe.Run) bool { 615 return !(run.Status == tfe.RunErrored || run.Status == tfe.RunCanceled || 616 run.Status == tfe.RunDiscarded) 617 } 618 619 func shouldGenerateConfig(out string, run *tfe.Run) bool { 620 return (run.Plan.Status == tfe.PlanErrored || run.Plan.Status == tfe.PlanFinished) && 621 run.Plan.GeneratedConfiguration && len(out) > 0 622 } 623 624 const planDefaultHeader = ` 625 [reset][yellow]Running plan in cloud backend. Output will stream here. Pressing Ctrl-C 626 will stop streaming the logs, but will not stop the plan running remotely.[reset] 627 628 Preparing the remote plan... 629 ` 630 631 const runHeader = ` 632 [reset][yellow]To view this run in a browser, visit: 633 https://%s/app/%s/%s/runs/%s[reset] 634 ` 635 636 // The newline in this error is to make it look good in the CLI! 637 const lockTimeoutErr = ` 638 [reset][red]Lock timeout exceeded, sending interrupt to cancel the remote operation. 639 [reset] 640 `