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