github.com/cycloidio/terraform@v1.1.10-0.20220513142504-76d5c768dc63/cloud/backend_plan.go (about) 1 package cloud 2 3 import ( 4 "bufio" 5 "context" 6 "errors" 7 "fmt" 8 "io" 9 "io/ioutil" 10 "log" 11 "os" 12 "path/filepath" 13 "strings" 14 "syscall" 15 "time" 16 17 tfe "github.com/hashicorp/go-tfe" 18 "github.com/cycloidio/terraform/backend" 19 "github.com/cycloidio/terraform/plans" 20 "github.com/cycloidio/terraform/tfdiags" 21 ) 22 23 var planConfigurationVersionsPollInterval = 500 * time.Millisecond 24 25 func (b *Cloud) opPlan(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*tfe.Run, error) { 26 log.Printf("[INFO] cloud: starting Plan operation") 27 28 var diags tfdiags.Diagnostics 29 30 if !w.Permissions.CanQueueRun { 31 diags = diags.Append(tfdiags.Sourceless( 32 tfdiags.Error, 33 "Insufficient rights to generate a plan", 34 "The provided credentials have insufficient rights to generate a plan. In order "+ 35 "to generate plans, at least plan permissions on the workspace are required.", 36 )) 37 return nil, diags.Err() 38 } 39 40 if b.ContextOpts != nil && b.ContextOpts.Parallelism != defaultParallelism { 41 diags = diags.Append(tfdiags.Sourceless( 42 tfdiags.Error, 43 "Custom parallelism values are currently not supported", 44 `Terraform Cloud does not support setting a custom parallelism `+ 45 `value at this time.`, 46 )) 47 } 48 49 if op.PlanFile != nil { 50 diags = diags.Append(tfdiags.Sourceless( 51 tfdiags.Error, 52 "Displaying a saved plan is currently not supported", 53 `Terraform Cloud currently requires configuration to be present and `+ 54 `does not accept an existing saved plan as an argument at this time.`, 55 )) 56 } 57 58 if op.PlanOutPath != "" { 59 diags = diags.Append(tfdiags.Sourceless( 60 tfdiags.Error, 61 "Saving a generated plan is currently not supported", 62 `Terraform Cloud does not support saving the generated execution `+ 63 `plan locally at this time.`, 64 )) 65 } 66 67 if !op.HasConfig() && op.PlanMode != plans.DestroyMode { 68 diags = diags.Append(tfdiags.Sourceless( 69 tfdiags.Error, 70 "No configuration files found", 71 `Plan requires configuration to be present. Planning without a configuration `+ 72 `would mark everything for destruction, which is normally not what is desired. `+ 73 `If you would like to destroy everything, please run plan with the "-destroy" `+ 74 `flag or create a single empty configuration file. Otherwise, please create `+ 75 `a Terraform configuration file in the path being executed and try again.`, 76 )) 77 } 78 79 // Return if there are any errors. 80 if diags.HasErrors() { 81 return nil, diags.Err() 82 } 83 84 return b.plan(stopCtx, cancelCtx, op, w) 85 } 86 87 func (b *Cloud) plan(stopCtx, cancelCtx context.Context, op *backend.Operation, w *tfe.Workspace) (*tfe.Run, error) { 88 if b.CLI != nil { 89 header := planDefaultHeader 90 if op.Type == backend.OperationTypeApply || op.Type == backend.OperationTypeRefresh { 91 header = applyDefaultHeader 92 } 93 b.CLI.Output(b.Colorize().Color(strings.TrimSpace(header) + "\n")) 94 } 95 96 configOptions := tfe.ConfigurationVersionCreateOptions{ 97 AutoQueueRuns: tfe.Bool(false), 98 Speculative: tfe.Bool(op.Type == backend.OperationTypePlan), 99 } 100 101 cv, err := b.client.ConfigurationVersions.Create(stopCtx, w.ID, configOptions) 102 if err != nil { 103 return nil, generalError("Failed to create configuration version", err) 104 } 105 106 var configDir string 107 if op.ConfigDir != "" { 108 // De-normalize the configuration directory path. 109 configDir, err = filepath.Abs(op.ConfigDir) 110 if err != nil { 111 return nil, generalError( 112 "Failed to get absolute path of the configuration directory: %v", err) 113 } 114 115 // Make sure to take the working directory into account by removing 116 // the working directory from the current path. This will result in 117 // a path that points to the expected root of the workspace. 118 configDir = filepath.Clean(strings.TrimSuffix( 119 filepath.Clean(configDir), 120 filepath.Clean(w.WorkingDirectory), 121 )) 122 123 // If the workspace has a subdirectory as its working directory then 124 // our configDir will be some parent directory of the current working 125 // directory. Users are likely to find that surprising, so we'll 126 // produce an explicit message about it to be transparent about what 127 // we are doing and why. 128 if w.WorkingDirectory != "" && filepath.Base(configDir) != w.WorkingDirectory { 129 if b.CLI != nil { 130 b.CLI.Output(fmt.Sprintf(strings.TrimSpace(` 131 The remote workspace is configured to work with configuration at 132 %s relative to the target repository. 133 134 Terraform will upload the contents of the following directory, 135 excluding files or directories as defined by a .terraformignore file 136 at %s/.terraformignore (if it is present), 137 in order to capture the filesystem context the remote workspace expects: 138 %s 139 `), w.WorkingDirectory, configDir, configDir) + "\n") 140 } 141 } 142 143 } else { 144 // We did a check earlier to make sure we either have a config dir, 145 // or the plan is run with -destroy. So this else clause will only 146 // be executed when we are destroying and doesn't need the config. 147 configDir, err = ioutil.TempDir("", "tf") 148 if err != nil { 149 return nil, generalError("Failed to create temporary directory", err) 150 } 151 defer os.RemoveAll(configDir) 152 153 // Make sure the configured working directory exists. 154 err = os.MkdirAll(filepath.Join(configDir, w.WorkingDirectory), 0700) 155 if err != nil { 156 return nil, generalError( 157 "Failed to create temporary working directory", err) 158 } 159 } 160 161 err = b.client.ConfigurationVersions.Upload(stopCtx, cv.UploadURL, configDir) 162 if err != nil { 163 return nil, generalError("Failed to upload configuration files", err) 164 } 165 166 uploaded := false 167 for i := 0; i < 60 && !uploaded; i++ { 168 select { 169 case <-stopCtx.Done(): 170 return nil, context.Canceled 171 case <-cancelCtx.Done(): 172 return nil, context.Canceled 173 case <-time.After(planConfigurationVersionsPollInterval): 174 cv, err = b.client.ConfigurationVersions.Read(stopCtx, cv.ID) 175 if err != nil { 176 return nil, generalError("Failed to retrieve configuration version", err) 177 } 178 179 if cv.Status == tfe.ConfigurationUploaded { 180 uploaded = true 181 } 182 } 183 } 184 185 if !uploaded { 186 return nil, generalError( 187 "Failed to upload configuration files", errors.New("operation timed out")) 188 } 189 190 runOptions := tfe.RunCreateOptions{ 191 ConfigurationVersion: cv, 192 Refresh: tfe.Bool(op.PlanRefresh), 193 Workspace: w, 194 AutoApply: tfe.Bool(op.AutoApprove), 195 } 196 197 switch op.PlanMode { 198 case plans.NormalMode: 199 // okay, but we don't need to do anything special for this 200 case plans.RefreshOnlyMode: 201 runOptions.RefreshOnly = tfe.Bool(true) 202 case plans.DestroyMode: 203 runOptions.IsDestroy = tfe.Bool(true) 204 default: 205 // Shouldn't get here because we should update this for each new 206 // plan mode we add, mapping it to the corresponding RunCreateOptions 207 // field. 208 return nil, generalError( 209 "Invalid plan mode", 210 fmt.Errorf("Terraform Cloud doesn't support %s", op.PlanMode), 211 ) 212 } 213 214 if len(op.Targets) != 0 { 215 runOptions.TargetAddrs = make([]string, 0, len(op.Targets)) 216 for _, addr := range op.Targets { 217 runOptions.TargetAddrs = append(runOptions.TargetAddrs, addr.String()) 218 } 219 } 220 221 if len(op.ForceReplace) != 0 { 222 runOptions.ReplaceAddrs = make([]string, 0, len(op.ForceReplace)) 223 for _, addr := range op.ForceReplace { 224 runOptions.ReplaceAddrs = append(runOptions.ReplaceAddrs, addr.String()) 225 } 226 } 227 228 config, _, configDiags := op.ConfigLoader.LoadConfigWithSnapshot(op.ConfigDir) 229 if configDiags.HasErrors() { 230 return nil, fmt.Errorf("error loading config with snapshot: %w", configDiags.Errs()[0]) 231 } 232 variables, varDiags := ParseCloudRunVariables(op.Variables, config.Module.Variables) 233 234 if varDiags.HasErrors() { 235 return nil, varDiags.Err() 236 } 237 238 runVariables := make([]*tfe.RunVariable, len(variables)) 239 for name, value := range variables { 240 runVariables = append(runVariables, &tfe.RunVariable{ 241 Key: name, 242 Value: value, 243 }) 244 } 245 runOptions.Variables = runVariables 246 247 r, err := b.client.Runs.Create(stopCtx, runOptions) 248 if err != nil { 249 return r, generalError("Failed to create run", err) 250 } 251 252 // When the lock timeout is set, if the run is still pending and 253 // cancellable after that period, we attempt to cancel it. 254 if lockTimeout := op.StateLocker.Timeout(); lockTimeout > 0 { 255 go func() { 256 select { 257 case <-stopCtx.Done(): 258 return 259 case <-cancelCtx.Done(): 260 return 261 case <-time.After(lockTimeout): 262 // Retrieve the run to get its current status. 263 r, err := b.client.Runs.Read(cancelCtx, r.ID) 264 if err != nil { 265 log.Printf("[ERROR] error reading run: %v", err) 266 return 267 } 268 269 if r.Status == tfe.RunPending && r.Actions.IsCancelable { 270 if b.CLI != nil { 271 b.CLI.Output(b.Colorize().Color(strings.TrimSpace(lockTimeoutErr))) 272 } 273 274 // We abuse the auto aprove flag to indicate that we do not 275 // want to ask if the remote operation should be canceled. 276 op.AutoApprove = true 277 278 p, err := os.FindProcess(os.Getpid()) 279 if err != nil { 280 log.Printf("[ERROR] error searching process ID: %v", err) 281 return 282 } 283 p.Signal(syscall.SIGINT) 284 } 285 } 286 }() 287 } 288 289 if b.CLI != nil { 290 b.CLI.Output(b.Colorize().Color(strings.TrimSpace(fmt.Sprintf( 291 runHeader, b.hostname, b.organization, op.Workspace, r.ID)) + "\n")) 292 } 293 294 r, err = b.waitForRun(stopCtx, cancelCtx, op, "plan", r, w) 295 if err != nil { 296 return r, err 297 } 298 299 logs, err := b.client.Plans.Logs(stopCtx, r.Plan.ID) 300 if err != nil { 301 return r, generalError("Failed to retrieve logs", err) 302 } 303 reader := bufio.NewReaderSize(logs, 64*1024) 304 305 if b.CLI != nil { 306 for next := true; next; { 307 var l, line []byte 308 309 for isPrefix := true; isPrefix; { 310 l, isPrefix, err = reader.ReadLine() 311 if err != nil { 312 if err != io.EOF { 313 return r, generalError("Failed to read logs", err) 314 } 315 next = false 316 } 317 line = append(line, l...) 318 } 319 320 if next || len(line) > 0 { 321 b.CLI.Output(b.Colorize().Color(string(line))) 322 } 323 } 324 } 325 326 // Retrieve the run to get its current status. 327 runID := r.ID 328 r, err = b.client.Runs.ReadWithOptions(stopCtx, runID, &tfe.RunReadOptions{ 329 Include: "task_stages", 330 }) 331 if err != nil { 332 // This error would be expected for older versions of TFE that do not allow 333 // fetching task_stages. 334 if strings.HasSuffix(err.Error(), "Invalid include parameter") { 335 r, err = b.client.Runs.Read(stopCtx, runID) 336 } 337 338 if err != nil { 339 return r, generalError("Failed to retrieve run", err) 340 } 341 } 342 343 // If the run is canceled or errored, we still continue to the 344 // cost-estimation and policy check phases to ensure we render any 345 // results available. In the case of a hard-failed policy check, the 346 // status of the run will be "errored", but there is still policy 347 // information which should be shown. 348 349 // Await post-plan run tasks 350 integration := &IntegrationContext{ 351 B: b, 352 StopContext: stopCtx, 353 CancelContext: cancelCtx, 354 Op: op, 355 Run: r, 356 } 357 358 if stageID := getTaskStageIDByName(r.TaskStages, tfe.PostPlan); stageID != nil { 359 err = b.runTasks(integration, integration.BeginOutput("Run Tasks (post-plan)"), *stageID) 360 if err != nil { 361 return r, err 362 } 363 } 364 365 // Show any cost estimation output. 366 if r.CostEstimate != nil { 367 err = b.costEstimate(stopCtx, cancelCtx, op, r) 368 if err != nil { 369 return r, err 370 } 371 } 372 373 // Check any configured sentinel policies. 374 if len(r.PolicyChecks) > 0 { 375 err = b.checkPolicy(stopCtx, cancelCtx, op, r) 376 if err != nil { 377 return r, err 378 } 379 } 380 381 return r, nil 382 } 383 384 func getTaskStageIDByName(stages []*tfe.TaskStage, stageName tfe.Stage) *string { 385 if len(stages) == 0 { 386 return nil 387 } 388 389 for _, stage := range stages { 390 if stage.Stage == stageName { 391 return &stage.ID 392 } 393 } 394 return nil 395 } 396 397 const planDefaultHeader = ` 398 [reset][yellow]Running plan in Terraform Cloud. Output will stream here. Pressing Ctrl-C 399 will stop streaming the logs, but will not stop the plan running remotely.[reset] 400 401 Preparing the remote plan... 402 ` 403 404 const runHeader = ` 405 [reset][yellow]To view this run in a browser, visit: 406 https://%s/app/%s/%s/runs/%s[reset] 407 ` 408 409 // The newline in this error is to make it look good in the CLI! 410 const lockTimeoutErr = ` 411 [reset][red]Lock timeout exceeded, sending interrupt to cancel the remote operation. 412 [reset] 413 `