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