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