github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/backend/local/backend_local.go (about) 1 // Copyright (c) HashiCorp, Inc. 2 // SPDX-License-Identifier: MPL-2.0 3 4 package local 5 6 import ( 7 "context" 8 "fmt" 9 "log" 10 "sort" 11 "strings" 12 13 "github.com/terramate-io/tf/backend" 14 "github.com/terramate-io/tf/configs" 15 "github.com/terramate-io/tf/configs/configload" 16 "github.com/terramate-io/tf/plans/planfile" 17 "github.com/terramate-io/tf/states/statemgr" 18 "github.com/terramate-io/tf/terraform" 19 "github.com/terramate-io/tf/tfdiags" 20 "github.com/zclconf/go-cty/cty" 21 ) 22 23 // backend.Local implementation. 24 func (b *Local) LocalRun(op *backend.Operation) (*backend.LocalRun, statemgr.Full, tfdiags.Diagnostics) { 25 // Make sure the type is invalid. We use this as a way to know not 26 // to ask for input/validate. We're modifying this through a pointer, 27 // so we're mutating an object that belongs to the caller here, which 28 // seems bad but we're preserving it for now until we have time to 29 // properly design this API, vs. just preserving whatever it currently 30 // happens to do. 31 op.Type = backend.OperationTypeInvalid 32 33 op.StateLocker = op.StateLocker.WithContext(context.Background()) 34 35 lr, _, stateMgr, diags := b.localRun(op) 36 return lr, stateMgr, diags 37 } 38 39 func (b *Local) localRun(op *backend.Operation) (*backend.LocalRun, *configload.Snapshot, statemgr.Full, tfdiags.Diagnostics) { 40 var diags tfdiags.Diagnostics 41 42 // Get the latest state. 43 log.Printf("[TRACE] backend/local: requesting state manager for workspace %q", op.Workspace) 44 s, err := b.StateMgr(op.Workspace) 45 if err != nil { 46 diags = diags.Append(fmt.Errorf("error loading state: %w", err)) 47 return nil, nil, nil, diags 48 } 49 log.Printf("[TRACE] backend/local: requesting state lock for workspace %q", op.Workspace) 50 if diags := op.StateLocker.Lock(s, op.Type.String()); diags.HasErrors() { 51 return nil, nil, nil, diags 52 } 53 54 defer func() { 55 // If we're returning with errors, and thus not producing a valid 56 // context, we'll want to avoid leaving the workspace locked. 57 if diags.HasErrors() { 58 diags = diags.Append(op.StateLocker.Unlock()) 59 } 60 }() 61 62 log.Printf("[TRACE] backend/local: reading remote state for workspace %q", op.Workspace) 63 if err := s.RefreshState(); err != nil { 64 diags = diags.Append(fmt.Errorf("error loading state: %w", err)) 65 return nil, nil, nil, diags 66 } 67 68 ret := &backend.LocalRun{} 69 70 // Initialize our context options 71 var coreOpts terraform.ContextOpts 72 if v := b.ContextOpts; v != nil { 73 coreOpts = *v 74 } 75 coreOpts.UIInput = op.UIIn 76 coreOpts.Hooks = op.Hooks 77 78 var ctxDiags tfdiags.Diagnostics 79 var configSnap *configload.Snapshot 80 if op.PlanFile.IsCloud() { 81 diags = diags.Append(fmt.Errorf("error: using a saved cloud plan when executing Terraform locally is not supported")) 82 return nil, nil, nil, diags 83 } 84 85 if lp, ok := op.PlanFile.Local(); ok { 86 var stateMeta *statemgr.SnapshotMeta 87 // If the statemgr implements our optional PersistentMeta interface then we'll 88 // additionally verify that the state snapshot in the plan file has 89 // consistent metadata, as an additional safety check. 90 if sm, ok := s.(statemgr.PersistentMeta); ok { 91 m := sm.StateSnapshotMeta() 92 stateMeta = &m 93 } 94 log.Printf("[TRACE] backend/local: populating backend.LocalRun from plan file") 95 ret, configSnap, ctxDiags = b.localRunForPlanFile(op, lp, ret, &coreOpts, stateMeta) 96 if ctxDiags.HasErrors() { 97 diags = diags.Append(ctxDiags) 98 return nil, nil, nil, diags 99 } 100 101 // Write sources into the cache of the main loader so that they are 102 // available if we need to generate diagnostic message snippets. 103 op.ConfigLoader.ImportSourcesFromSnapshot(configSnap) 104 } else { 105 log.Printf("[TRACE] backend/local: populating backend.LocalRun for current working directory") 106 ret, configSnap, ctxDiags = b.localRunDirect(op, ret, &coreOpts, s) 107 } 108 diags = diags.Append(ctxDiags) 109 if diags.HasErrors() { 110 return nil, nil, nil, diags 111 } 112 113 // If we have an operation, then we automatically do the input/validate 114 // here since every option requires this. 115 if op.Type != backend.OperationTypeInvalid { 116 // If input asking is enabled, then do that 117 if op.PlanFile == nil && b.OpInput { 118 mode := terraform.InputModeProvider 119 120 log.Printf("[TRACE] backend/local: requesting interactive input, if necessary") 121 inputDiags := ret.Core.Input(ret.Config, mode) 122 diags = diags.Append(inputDiags) 123 if inputDiags.HasErrors() { 124 return nil, nil, nil, diags 125 } 126 } 127 128 // If validation is enabled, validate 129 if b.OpValidation { 130 log.Printf("[TRACE] backend/local: running validation operation") 131 validateDiags := ret.Core.Validate(ret.Config) 132 diags = diags.Append(validateDiags) 133 } 134 } 135 136 return ret, configSnap, s, diags 137 } 138 139 func (b *Local) localRunDirect(op *backend.Operation, run *backend.LocalRun, coreOpts *terraform.ContextOpts, s statemgr.Full) (*backend.LocalRun, *configload.Snapshot, tfdiags.Diagnostics) { 140 var diags tfdiags.Diagnostics 141 142 // Load the configuration using the caller-provided configuration loader. 143 config, configSnap, configDiags := op.ConfigLoader.LoadConfigWithSnapshot(op.ConfigDir) 144 diags = diags.Append(configDiags) 145 if configDiags.HasErrors() { 146 return nil, nil, diags 147 } 148 run.Config = config 149 150 if errs := config.VerifyDependencySelections(op.DependencyLocks); len(errs) > 0 { 151 var buf strings.Builder 152 for _, err := range errs { 153 fmt.Fprintf(&buf, "\n - %s", err.Error()) 154 } 155 var suggestion string 156 switch { 157 case op.DependencyLocks == nil: 158 // If we get here then it suggests that there's a caller that we 159 // didn't yet update to populate DependencyLocks, which is a bug. 160 suggestion = "This run has no dependency lock information provided at all, which is a bug in Terraform; please report it!" 161 case op.DependencyLocks.Empty(): 162 suggestion = "To make the initial dependency selections that will initialize the dependency lock file, run:\n terraform init" 163 default: 164 suggestion = "To update the locked dependency selections to match a changed configuration, run:\n terraform init -upgrade" 165 } 166 diags = diags.Append(tfdiags.Sourceless( 167 tfdiags.Error, 168 "Inconsistent dependency lock file", 169 fmt.Sprintf( 170 "The following dependency selections recorded in the lock file are inconsistent with the current configuration:%s\n\n%s", 171 buf.String(), suggestion, 172 ), 173 )) 174 } 175 176 var rawVariables map[string]backend.UnparsedVariableValue 177 if op.AllowUnsetVariables { 178 // Rather than prompting for input, we'll just stub out the required 179 // but unset variables with unknown values to represent that they are 180 // placeholders for values the user would need to provide for other 181 // operations. 182 rawVariables = b.stubUnsetRequiredVariables(op.Variables, config.Module.Variables) 183 } else { 184 // If interactive input is enabled, we might gather some more variable 185 // values through interactive prompts. 186 // TODO: Need to route the operation context through into here, so that 187 // the interactive prompts can be sensitive to its timeouts/etc. 188 rawVariables = b.interactiveCollectVariables(context.TODO(), op.Variables, config.Module.Variables, op.UIIn) 189 } 190 191 variables, varDiags := backend.ParseVariableValues(rawVariables, config.Module.Variables) 192 diags = diags.Append(varDiags) 193 if diags.HasErrors() { 194 return nil, nil, diags 195 } 196 197 planOpts := &terraform.PlanOpts{ 198 Mode: op.PlanMode, 199 Targets: op.Targets, 200 ForceReplace: op.ForceReplace, 201 SetVariables: variables, 202 SkipRefresh: op.Type != backend.OperationTypeRefresh && !op.PlanRefresh, 203 GenerateConfigPath: op.GenerateConfigOut, 204 } 205 run.PlanOpts = planOpts 206 207 // For a "direct" local run, the input state is the most recently stored 208 // snapshot, from the previous run. 209 run.InputState = s.State() 210 211 tfCtx, moreDiags := terraform.NewContext(coreOpts) 212 diags = diags.Append(moreDiags) 213 if moreDiags.HasErrors() { 214 return nil, nil, diags 215 } 216 run.Core = tfCtx 217 return run, configSnap, diags 218 } 219 220 func (b *Local) localRunForPlanFile(op *backend.Operation, pf *planfile.Reader, run *backend.LocalRun, coreOpts *terraform.ContextOpts, currentStateMeta *statemgr.SnapshotMeta) (*backend.LocalRun, *configload.Snapshot, tfdiags.Diagnostics) { 221 var diags tfdiags.Diagnostics 222 223 const errSummary = "Invalid plan file" 224 225 // A plan file has a snapshot of configuration embedded inside it, which 226 // is used instead of whatever configuration might be already present 227 // in the filesystem. 228 snap, err := pf.ReadConfigSnapshot() 229 if err != nil { 230 diags = diags.Append(tfdiags.Sourceless( 231 tfdiags.Error, 232 errSummary, 233 fmt.Sprintf("Failed to read configuration snapshot from plan file: %s.", err), 234 )) 235 return nil, snap, diags 236 } 237 loader := configload.NewLoaderFromSnapshot(snap) 238 config, configDiags := loader.LoadConfig(snap.Modules[""].Dir) 239 diags = diags.Append(configDiags) 240 if configDiags.HasErrors() { 241 return nil, snap, diags 242 } 243 run.Config = config 244 245 // NOTE: We're intentionally comparing the current locks with the 246 // configuration snapshot, rather than the lock snapshot in the plan file, 247 // because it's the current locks which dictate our plugin selections 248 // in coreOpts below. However, we'll also separately check that the 249 // plan file has identical locked plugins below, and thus we're effectively 250 // checking consistency with both here. 251 if errs := config.VerifyDependencySelections(op.DependencyLocks); len(errs) > 0 { 252 var buf strings.Builder 253 for _, err := range errs { 254 fmt.Fprintf(&buf, "\n - %s", err.Error()) 255 } 256 diags = diags.Append(tfdiags.Sourceless( 257 tfdiags.Error, 258 "Inconsistent dependency lock file", 259 fmt.Sprintf( 260 "The following dependency selections recorded in the lock file are inconsistent with the configuration in the saved plan:%s\n\nA saved plan can be applied only to the same configuration it was created from. Create a new plan from the updated configuration.", 261 buf.String(), 262 ), 263 )) 264 } 265 266 // This check is an important complement to the check above: the locked 267 // dependencies in the configuration must match the configuration, and 268 // the locked dependencies in the plan must match the locked dependencies 269 // in the configuration, and so transitively we ensure that the locked 270 // dependencies in the plan match the configuration too. However, this 271 // additionally catches any inconsistency between the two sets of locks 272 // even if they both happen to be valid per the current configuration, 273 // which is one of several ways we try to catch the mistake of applying 274 // a saved plan file in a different place than where we created it. 275 depLocksFromPlan, moreDiags := pf.ReadDependencyLocks() 276 diags = diags.Append(moreDiags) 277 if depLocksFromPlan != nil && !op.DependencyLocks.Equal(depLocksFromPlan) { 278 diags = diags.Append(tfdiags.Sourceless( 279 tfdiags.Error, 280 "Inconsistent dependency lock file", 281 "The given plan file was created with a different set of external dependency selections than the current configuration. A saved plan can be applied only to the same configuration it was created from.\n\nCreate a new plan from the updated configuration.", 282 )) 283 } 284 285 // A plan file also contains a snapshot of the prior state the changes 286 // are intended to apply to. 287 priorStateFile, err := pf.ReadStateFile() 288 if err != nil { 289 diags = diags.Append(tfdiags.Sourceless( 290 tfdiags.Error, 291 errSummary, 292 fmt.Sprintf("Failed to read prior state snapshot from plan file: %s.", err), 293 )) 294 return nil, snap, diags 295 } 296 297 if currentStateMeta != nil { 298 // If the caller sets this, we require that the stored prior state 299 // has the same metadata, which is an extra safety check that nothing 300 // has changed since the plan was created. (All of the "real-world" 301 // state manager implementations support this, but simpler test backends 302 // may not.) 303 304 // Because the plan always contains a state, even if it is empty, the 305 // first plan to be applied will have empty snapshot metadata. In this 306 // case we compare only the serial in order to provide a more correct 307 // error. 308 firstPlan := priorStateFile.Lineage == "" && priorStateFile.Serial == 0 309 310 switch { 311 case !firstPlan && priorStateFile.Lineage != currentStateMeta.Lineage: 312 diags = diags.Append(tfdiags.Sourceless( 313 tfdiags.Error, 314 "Saved plan does not match the given state", 315 "The given plan file can not be applied because it was created from a different state lineage.", 316 )) 317 318 case priorStateFile.Serial != currentStateMeta.Serial: 319 diags = diags.Append(tfdiags.Sourceless( 320 tfdiags.Error, 321 "Saved plan is stale", 322 "The given plan file can no longer be applied because the state was changed by another operation after the plan was created.", 323 )) 324 } 325 } 326 // When we're applying a saved plan, the input state is the "prior state" 327 // recorded in the plan, which incorporates the result of all of the 328 // refreshing we did while building the plan. 329 run.InputState = priorStateFile.State 330 331 plan, err := pf.ReadPlan() 332 if err != nil { 333 diags = diags.Append(tfdiags.Sourceless( 334 tfdiags.Error, 335 errSummary, 336 fmt.Sprintf("Failed to read plan from plan file: %s.", err), 337 )) 338 return nil, snap, diags 339 } 340 // When we're applying a saved plan, we populate Plan instead of PlanOpts, 341 // because a plan object incorporates the subset of data from PlanOps that 342 // we need to apply the plan. 343 run.Plan = plan 344 345 tfCtx, moreDiags := terraform.NewContext(coreOpts) 346 diags = diags.Append(moreDiags) 347 if moreDiags.HasErrors() { 348 return nil, nil, diags 349 } 350 run.Core = tfCtx 351 return run, snap, diags 352 } 353 354 // interactiveCollectVariables attempts to complete the given existing 355 // map of variables by interactively prompting for any variables that are 356 // declared as required but not yet present. 357 // 358 // If interactive input is disabled for this backend instance then this is 359 // a no-op. If input is enabled but fails for some reason, the resulting 360 // map will be incomplete. For these reasons, the caller must still validate 361 // that the result is complete and valid. 362 // 363 // This function does not modify the map given in "existing", but may return 364 // it unchanged if no modifications are required. If modifications are required, 365 // the result is a new map with all of the elements from "existing" plus 366 // additional elements as appropriate. 367 // 368 // Interactive prompting is a "best effort" thing for first-time user UX and 369 // not something we expect folks to be relying on for routine use. Terraform 370 // is primarily a non-interactive tool and so we prefer to report in error 371 // messages that variables are not set rather than reporting that input failed: 372 // the primary resolution to missing variables is to provide them by some other 373 // means. 374 func (b *Local) interactiveCollectVariables(ctx context.Context, existing map[string]backend.UnparsedVariableValue, vcs map[string]*configs.Variable, uiInput terraform.UIInput) map[string]backend.UnparsedVariableValue { 375 var needed []string 376 if b.OpInput && uiInput != nil { 377 for name, vc := range vcs { 378 if !vc.Required() { 379 continue // We only prompt for required variables 380 } 381 if _, exists := existing[name]; !exists { 382 needed = append(needed, name) 383 } 384 } 385 } else { 386 log.Print("[DEBUG] backend/local: Skipping interactive prompts for variables because input is disabled") 387 } 388 if len(needed) == 0 { 389 return existing 390 } 391 392 log.Printf("[DEBUG] backend/local: will prompt for input of unset required variables %s", needed) 393 394 // If we get here then we're planning to prompt for at least one additional 395 // variable's value. 396 sort.Strings(needed) // prompt in lexical order 397 ret := make(map[string]backend.UnparsedVariableValue, len(vcs)) 398 for k, v := range existing { 399 ret[k] = v 400 } 401 for _, name := range needed { 402 vc := vcs[name] 403 rawValue, err := uiInput.Input(ctx, &terraform.InputOpts{ 404 Id: fmt.Sprintf("var.%s", name), 405 Query: fmt.Sprintf("var.%s", name), 406 Description: vc.Description, 407 Secret: vc.Sensitive, 408 }) 409 if err != nil { 410 // Since interactive prompts are best-effort, we'll just continue 411 // here and let subsequent validation report this as a variable 412 // not specified. 413 log.Printf("[WARN] backend/local: Failed to request user input for variable %q: %s", name, err) 414 continue 415 } 416 ret[name] = unparsedInteractiveVariableValue{Name: name, RawValue: rawValue} 417 } 418 return ret 419 } 420 421 // stubUnsetVariables ensures that all required variables defined in the 422 // configuration exist in the resulting map, by adding new elements as necessary. 423 // 424 // The stubbed value of any additions will be an unknown variable conforming 425 // to the variable's configured type constraint, meaning that no particular 426 // value is known and that one must be provided by the user in order to get 427 // a complete result. 428 // 429 // Unset optional attributes (those with default values) will not be populated 430 // by this function, under the assumption that a later step will handle those. 431 // In this sense, stubUnsetRequiredVariables is essentially a non-interactive, 432 // non-error-producing variant of interactiveCollectVariables that creates 433 // placeholders for values the user would be prompted for interactively on 434 // other operations. 435 // 436 // This function should be used only in situations where variables values 437 // will not be directly used and the variables map is being constructed only 438 // to produce a complete Terraform context for some ancillary functionality 439 // like "terraform console", "terraform state ...", etc. 440 // 441 // This function is guaranteed not to modify the given map, but it may return 442 // the given map unchanged if no additions are required. If additions are 443 // required then the result will be a new map containing everything in the 444 // given map plus additional elements. 445 func (b *Local) stubUnsetRequiredVariables(existing map[string]backend.UnparsedVariableValue, vcs map[string]*configs.Variable) map[string]backend.UnparsedVariableValue { 446 var missing bool // Do we need to add anything? 447 for name, vc := range vcs { 448 if !vc.Required() { 449 continue // We only stub required variables 450 } 451 if _, exists := existing[name]; !exists { 452 missing = true 453 } 454 } 455 if !missing { 456 return existing 457 } 458 459 // If we get down here then there's at least one variable value to add. 460 ret := make(map[string]backend.UnparsedVariableValue, len(vcs)) 461 for k, v := range existing { 462 ret[k] = v 463 } 464 for name, vc := range vcs { 465 if !vc.Required() { 466 continue 467 } 468 if _, exists := existing[name]; !exists { 469 ret[name] = unparsedUnknownVariableValue{Name: name, WantType: vc.Type} 470 } 471 } 472 return ret 473 } 474 475 type unparsedInteractiveVariableValue struct { 476 Name, RawValue string 477 } 478 479 var _ backend.UnparsedVariableValue = unparsedInteractiveVariableValue{} 480 481 func (v unparsedInteractiveVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) { 482 var diags tfdiags.Diagnostics 483 val, valDiags := mode.Parse(v.Name, v.RawValue) 484 diags = diags.Append(valDiags) 485 if diags.HasErrors() { 486 return nil, diags 487 } 488 return &terraform.InputValue{ 489 Value: val, 490 SourceType: terraform.ValueFromInput, 491 }, diags 492 } 493 494 type unparsedUnknownVariableValue struct { 495 Name string 496 WantType cty.Type 497 } 498 499 var _ backend.UnparsedVariableValue = unparsedUnknownVariableValue{} 500 501 func (v unparsedUnknownVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) { 502 return &terraform.InputValue{ 503 Value: cty.UnknownVal(v.WantType), 504 SourceType: terraform.ValueFromInput, 505 }, nil 506 }