mirror of
https://github.com/opentofu/opentofu.git
synced 2025-01-18 04:32:59 -06:00
89b05050ec
Previously terraform.Context was built in an unfortunate way where all of the data was provided up front in terraform.NewContext and then mutated directly by subsequent operations. That made the data flow hard to follow, commonly leading to bugs, and also meant that we were forced to take various actions too early in terraform.NewContext, rather than waiting until a more appropriate time during an operation. This (enormous) commit changes terraform.Context so that its fields are broadly just unchanging data about the execution context (current workspace name, available plugins, etc) whereas the main data Terraform works with arrives via individual method arguments and is returned in return values. Specifically, this means that terraform.Context no longer "has-a" config, state, and "planned changes", instead holding on to those only temporarily during an operation. The caller is responsible for propagating the outcome of one step into the next step so that the data flow between operations is actually visible. However, since that's a change to the main entry points in the "terraform" package, this commit also touches every file in the codebase which interacted with those APIs. Most of the noise here is in updating tests to take the same actions using the new API style, but this also affects the main-code callers in the backends and in the command package. My goal here was to refactor without changing observable behavior, but in practice there are a couple externally-visible behavior variations here that seemed okay in service of the broader goal: - The "terraform graph" command is no longer hooked directly into the core graph builders, because that's no longer part of the public API. However, I did include a couple new Context functions whose contract is to produce a UI-oriented graph, and _for now_ those continue to return the physical graph we use for those operations. There's no exported API for generating the "validate" and "eval" graphs, because neither is particularly interesting in its own right, and so "terraform graph" no longer supports those graph types. - terraform.NewContext no longer has the responsibility for collecting all of the provider schemas up front. Instead, we wait until we need them. However, that means that some of our error messages now have a slightly different shape due to unwinding through a differently-shaped call stack. As of this commit we also end up reloading the schemas multiple times in some cases, which is functionally acceptable but likely represents a performance regression. I intend to rework this to use caching, but I'm saving that for a later commit because this one is big enough already. The proximal reason for this change is to resolve the chicken/egg problem whereby there was previously no single point where we could apply "moved" statements to the previous run state before creating a plan. With this change in place, we can now do that as part of Context.Plan, prior to forking the input state into the three separate state artifacts we use during planning. However, this is at least the third project in a row where the previous API design led to piling more functionality into terraform.NewContext and then working around the incorrect order of operations that produces, so I intend that by paying the cost/risk of this large diff now we can in turn reduce the cost/risk of future projects that relate to our main workflow actions.
421 lines
15 KiB
Go
421 lines
15 KiB
Go
package local
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"sort"
|
|
|
|
"github.com/hashicorp/terraform/internal/backend"
|
|
"github.com/hashicorp/terraform/internal/configs"
|
|
"github.com/hashicorp/terraform/internal/configs/configload"
|
|
"github.com/hashicorp/terraform/internal/plans/planfile"
|
|
"github.com/hashicorp/terraform/internal/states/statemgr"
|
|
"github.com/hashicorp/terraform/internal/terraform"
|
|
"github.com/hashicorp/terraform/internal/tfdiags"
|
|
"github.com/zclconf/go-cty/cty"
|
|
)
|
|
|
|
// backend.Local implementation.
|
|
func (b *Local) LocalRun(op *backend.Operation) (*backend.LocalRun, statemgr.Full, tfdiags.Diagnostics) {
|
|
// Make sure the type is invalid. We use this as a way to know not
|
|
// to ask for input/validate. We're modifying this through a pointer,
|
|
// so we're mutating an object that belongs to the caller here, which
|
|
// seems bad but we're preserving it for now until we have time to
|
|
// properly design this API, vs. just preserving whatever it currently
|
|
// happens to do.
|
|
op.Type = backend.OperationTypeInvalid
|
|
|
|
op.StateLocker = op.StateLocker.WithContext(context.Background())
|
|
|
|
lr, _, stateMgr, diags := b.localRun(op)
|
|
return lr, stateMgr, diags
|
|
}
|
|
|
|
func (b *Local) localRun(op *backend.Operation) (*backend.LocalRun, *configload.Snapshot, statemgr.Full, tfdiags.Diagnostics) {
|
|
var diags tfdiags.Diagnostics
|
|
|
|
// Get the latest state.
|
|
log.Printf("[TRACE] backend/local: requesting state manager for workspace %q", op.Workspace)
|
|
s, err := b.StateMgr(op.Workspace)
|
|
if err != nil {
|
|
diags = diags.Append(fmt.Errorf("error loading state: %w", err))
|
|
return nil, nil, nil, diags
|
|
}
|
|
log.Printf("[TRACE] backend/local: requesting state lock for workspace %q", op.Workspace)
|
|
if diags := op.StateLocker.Lock(s, op.Type.String()); diags.HasErrors() {
|
|
return nil, nil, nil, diags
|
|
}
|
|
|
|
defer func() {
|
|
// If we're returning with errors, and thus not producing a valid
|
|
// context, we'll want to avoid leaving the workspace locked.
|
|
if diags.HasErrors() {
|
|
diags = diags.Append(op.StateLocker.Unlock())
|
|
}
|
|
}()
|
|
|
|
log.Printf("[TRACE] backend/local: reading remote state for workspace %q", op.Workspace)
|
|
if err := s.RefreshState(); err != nil {
|
|
diags = diags.Append(fmt.Errorf("error loading state: %w", err))
|
|
return nil, nil, nil, diags
|
|
}
|
|
|
|
ret := &backend.LocalRun{}
|
|
|
|
// Initialize our context options
|
|
var coreOpts terraform.ContextOpts
|
|
if v := b.ContextOpts; v != nil {
|
|
coreOpts = *v
|
|
}
|
|
coreOpts.UIInput = op.UIIn
|
|
coreOpts.Hooks = op.Hooks
|
|
|
|
var ctxDiags tfdiags.Diagnostics
|
|
var configSnap *configload.Snapshot
|
|
if op.PlanFile != nil {
|
|
var stateMeta *statemgr.SnapshotMeta
|
|
// If the statemgr implements our optional PersistentMeta interface then we'll
|
|
// additionally verify that the state snapshot in the plan file has
|
|
// consistent metadata, as an additional safety check.
|
|
if sm, ok := s.(statemgr.PersistentMeta); ok {
|
|
m := sm.StateSnapshotMeta()
|
|
stateMeta = &m
|
|
}
|
|
log.Printf("[TRACE] backend/local: populating backend.LocalRun from plan file")
|
|
ret, configSnap, ctxDiags = b.localRunForPlanFile(op.PlanFile, ret, &coreOpts, stateMeta)
|
|
if ctxDiags.HasErrors() {
|
|
diags = diags.Append(ctxDiags)
|
|
return nil, nil, nil, diags
|
|
}
|
|
|
|
// Write sources into the cache of the main loader so that they are
|
|
// available if we need to generate diagnostic message snippets.
|
|
op.ConfigLoader.ImportSourcesFromSnapshot(configSnap)
|
|
} else {
|
|
log.Printf("[TRACE] backend/local: populating backend.LocalRun for current working directory")
|
|
ret, configSnap, ctxDiags = b.localRunDirect(op, ret, &coreOpts, s)
|
|
}
|
|
diags = diags.Append(ctxDiags)
|
|
if diags.HasErrors() {
|
|
return nil, nil, nil, diags
|
|
}
|
|
|
|
// If we have an operation, then we automatically do the input/validate
|
|
// here since every option requires this.
|
|
if op.Type != backend.OperationTypeInvalid {
|
|
// If input asking is enabled, then do that
|
|
if op.PlanFile == nil && b.OpInput {
|
|
mode := terraform.InputModeProvider
|
|
|
|
log.Printf("[TRACE] backend/local: requesting interactive input, if necessary")
|
|
inputDiags := ret.Core.Input(ret.Config, mode)
|
|
diags = diags.Append(inputDiags)
|
|
if inputDiags.HasErrors() {
|
|
return nil, nil, nil, diags
|
|
}
|
|
}
|
|
|
|
// If validation is enabled, validate
|
|
if b.OpValidation {
|
|
log.Printf("[TRACE] backend/local: running validation operation")
|
|
validateDiags := ret.Core.Validate(ret.Config)
|
|
diags = diags.Append(validateDiags)
|
|
}
|
|
}
|
|
|
|
return ret, configSnap, s, diags
|
|
}
|
|
|
|
func (b *Local) localRunDirect(op *backend.Operation, run *backend.LocalRun, coreOpts *terraform.ContextOpts, s statemgr.Full) (*backend.LocalRun, *configload.Snapshot, tfdiags.Diagnostics) {
|
|
var diags tfdiags.Diagnostics
|
|
|
|
// Load the configuration using the caller-provided configuration loader.
|
|
config, configSnap, configDiags := op.ConfigLoader.LoadConfigWithSnapshot(op.ConfigDir)
|
|
diags = diags.Append(configDiags)
|
|
if configDiags.HasErrors() {
|
|
return nil, nil, diags
|
|
}
|
|
run.Config = config
|
|
|
|
var rawVariables map[string]backend.UnparsedVariableValue
|
|
if op.AllowUnsetVariables {
|
|
// Rather than prompting for input, we'll just stub out the required
|
|
// but unset variables with unknown values to represent that they are
|
|
// placeholders for values the user would need to provide for other
|
|
// operations.
|
|
rawVariables = b.stubUnsetRequiredVariables(op.Variables, config.Module.Variables)
|
|
} else {
|
|
// If interactive input is enabled, we might gather some more variable
|
|
// values through interactive prompts.
|
|
// TODO: Need to route the operation context through into here, so that
|
|
// the interactive prompts can be sensitive to its timeouts/etc.
|
|
rawVariables = b.interactiveCollectVariables(context.TODO(), op.Variables, config.Module.Variables, op.UIIn)
|
|
}
|
|
|
|
variables, varDiags := backend.ParseVariableValues(rawVariables, config.Module.Variables)
|
|
diags = diags.Append(varDiags)
|
|
if diags.HasErrors() {
|
|
return nil, nil, diags
|
|
}
|
|
|
|
planOpts := &terraform.PlanOpts{
|
|
Mode: op.PlanMode,
|
|
Targets: op.Targets,
|
|
ForceReplace: op.ForceReplace,
|
|
SetVariables: variables,
|
|
SkipRefresh: op.Type != backend.OperationTypeRefresh && !op.PlanRefresh,
|
|
}
|
|
run.PlanOpts = planOpts
|
|
|
|
// For a "direct" local run, the input state is the most recently stored
|
|
// snapshot, from the previous run.
|
|
run.InputState = s.State()
|
|
|
|
tfCtx, moreDiags := terraform.NewContext(coreOpts)
|
|
diags = diags.Append(moreDiags)
|
|
if moreDiags.HasErrors() {
|
|
return nil, nil, diags
|
|
}
|
|
run.Core = tfCtx
|
|
return run, configSnap, diags
|
|
}
|
|
|
|
func (b *Local) localRunForPlanFile(pf *planfile.Reader, run *backend.LocalRun, coreOpts *terraform.ContextOpts, currentStateMeta *statemgr.SnapshotMeta) (*backend.LocalRun, *configload.Snapshot, tfdiags.Diagnostics) {
|
|
var diags tfdiags.Diagnostics
|
|
|
|
const errSummary = "Invalid plan file"
|
|
|
|
// A plan file has a snapshot of configuration embedded inside it, which
|
|
// is used instead of whatever configuration might be already present
|
|
// in the filesystem.
|
|
snap, err := pf.ReadConfigSnapshot()
|
|
if err != nil {
|
|
diags = diags.Append(tfdiags.Sourceless(
|
|
tfdiags.Error,
|
|
errSummary,
|
|
fmt.Sprintf("Failed to read configuration snapshot from plan file: %s.", err),
|
|
))
|
|
return nil, snap, diags
|
|
}
|
|
loader := configload.NewLoaderFromSnapshot(snap)
|
|
config, configDiags := loader.LoadConfig(snap.Modules[""].Dir)
|
|
diags = diags.Append(configDiags)
|
|
if configDiags.HasErrors() {
|
|
return nil, snap, diags
|
|
}
|
|
run.Config = config
|
|
|
|
// A plan file also contains a snapshot of the prior state the changes
|
|
// are intended to apply to.
|
|
priorStateFile, err := pf.ReadStateFile()
|
|
if err != nil {
|
|
diags = diags.Append(tfdiags.Sourceless(
|
|
tfdiags.Error,
|
|
errSummary,
|
|
fmt.Sprintf("Failed to read prior state snapshot from plan file: %s.", err),
|
|
))
|
|
return nil, snap, diags
|
|
}
|
|
if currentStateMeta != nil {
|
|
// If the caller sets this, we require that the stored prior state
|
|
// has the same metadata, which is an extra safety check that nothing
|
|
// has changed since the plan was created. (All of the "real-world"
|
|
// state manager implementations support this, but simpler test backends
|
|
// may not.)
|
|
if currentStateMeta.Lineage != "" && priorStateFile.Lineage != "" {
|
|
if priorStateFile.Serial != currentStateMeta.Serial || priorStateFile.Lineage != currentStateMeta.Lineage {
|
|
diags = diags.Append(tfdiags.Sourceless(
|
|
tfdiags.Error,
|
|
"Saved plan is stale",
|
|
"The given plan file can no longer be applied because the state was changed by another operation after the plan was created.",
|
|
))
|
|
}
|
|
}
|
|
}
|
|
// When we're applying a saved plan, the input state is the "prior state"
|
|
// recorded in the plan, which incorporates the result of all of the
|
|
// refreshing we did while building the plan.
|
|
run.InputState = priorStateFile.State
|
|
|
|
plan, err := pf.ReadPlan()
|
|
if err != nil {
|
|
diags = diags.Append(tfdiags.Sourceless(
|
|
tfdiags.Error,
|
|
errSummary,
|
|
fmt.Sprintf("Failed to read plan from plan file: %s.", err),
|
|
))
|
|
return nil, snap, diags
|
|
}
|
|
// When we're applying a saved plan, we populate Plan instead of PlanOpts,
|
|
// because a plan object incorporates the subset of data from PlanOps that
|
|
// we need to apply the plan.
|
|
run.Plan = plan
|
|
|
|
// When we're applying a saved plan, our context must verify that all of
|
|
// the providers it ends up using are identical to those which created
|
|
// the plan.
|
|
coreOpts.ProviderSHA256s = plan.ProviderSHA256s
|
|
|
|
tfCtx, moreDiags := terraform.NewContext(coreOpts)
|
|
diags = diags.Append(moreDiags)
|
|
if moreDiags.HasErrors() {
|
|
return nil, nil, diags
|
|
}
|
|
run.Core = tfCtx
|
|
return run, snap, diags
|
|
}
|
|
|
|
// interactiveCollectVariables attempts to complete the given existing
|
|
// map of variables by interactively prompting for any variables that are
|
|
// declared as required but not yet present.
|
|
//
|
|
// If interactive input is disabled for this backend instance then this is
|
|
// a no-op. If input is enabled but fails for some reason, the resulting
|
|
// map will be incomplete. For these reasons, the caller must still validate
|
|
// that the result is complete and valid.
|
|
//
|
|
// This function does not modify the map given in "existing", but may return
|
|
// it unchanged if no modifications are required. If modifications are required,
|
|
// the result is a new map with all of the elements from "existing" plus
|
|
// additional elements as appropriate.
|
|
//
|
|
// Interactive prompting is a "best effort" thing for first-time user UX and
|
|
// not something we expect folks to be relying on for routine use. Terraform
|
|
// is primarily a non-interactive tool and so we prefer to report in error
|
|
// messages that variables are not set rather than reporting that input failed:
|
|
// the primary resolution to missing variables is to provide them by some other
|
|
// means.
|
|
func (b *Local) interactiveCollectVariables(ctx context.Context, existing map[string]backend.UnparsedVariableValue, vcs map[string]*configs.Variable, uiInput terraform.UIInput) map[string]backend.UnparsedVariableValue {
|
|
var needed []string
|
|
if b.OpInput && uiInput != nil {
|
|
for name, vc := range vcs {
|
|
if !vc.Required() {
|
|
continue // We only prompt for required variables
|
|
}
|
|
if _, exists := existing[name]; !exists {
|
|
needed = append(needed, name)
|
|
}
|
|
}
|
|
} else {
|
|
log.Print("[DEBUG] backend/local: Skipping interactive prompts for variables because input is disabled")
|
|
}
|
|
if len(needed) == 0 {
|
|
return existing
|
|
}
|
|
|
|
log.Printf("[DEBUG] backend/local: will prompt for input of unset required variables %s", needed)
|
|
|
|
// If we get here then we're planning to prompt for at least one additional
|
|
// variable's value.
|
|
sort.Strings(needed) // prompt in lexical order
|
|
ret := make(map[string]backend.UnparsedVariableValue, len(vcs))
|
|
for k, v := range existing {
|
|
ret[k] = v
|
|
}
|
|
for _, name := range needed {
|
|
vc := vcs[name]
|
|
rawValue, err := uiInput.Input(ctx, &terraform.InputOpts{
|
|
Id: fmt.Sprintf("var.%s", name),
|
|
Query: fmt.Sprintf("var.%s", name),
|
|
Description: vc.Description,
|
|
})
|
|
if err != nil {
|
|
// Since interactive prompts are best-effort, we'll just continue
|
|
// here and let subsequent validation report this as a variable
|
|
// not specified.
|
|
log.Printf("[WARN] backend/local: Failed to request user input for variable %q: %s", name, err)
|
|
continue
|
|
}
|
|
ret[name] = unparsedInteractiveVariableValue{Name: name, RawValue: rawValue}
|
|
}
|
|
return ret
|
|
}
|
|
|
|
// stubUnsetVariables ensures that all required variables defined in the
|
|
// configuration exist in the resulting map, by adding new elements as necessary.
|
|
//
|
|
// The stubbed value of any additions will be an unknown variable conforming
|
|
// to the variable's configured type constraint, meaning that no particular
|
|
// value is known and that one must be provided by the user in order to get
|
|
// a complete result.
|
|
//
|
|
// Unset optional attributes (those with default values) will not be populated
|
|
// by this function, under the assumption that a later step will handle those.
|
|
// In this sense, stubUnsetRequiredVariables is essentially a non-interactive,
|
|
// non-error-producing variant of interactiveCollectVariables that creates
|
|
// placeholders for values the user would be prompted for interactively on
|
|
// other operations.
|
|
//
|
|
// This function should be used only in situations where variables values
|
|
// will not be directly used and the variables map is being constructed only
|
|
// to produce a complete Terraform context for some ancillary functionality
|
|
// like "terraform console", "terraform state ...", etc.
|
|
//
|
|
// This function is guaranteed not to modify the given map, but it may return
|
|
// the given map unchanged if no additions are required. If additions are
|
|
// required then the result will be a new map containing everything in the
|
|
// given map plus additional elements.
|
|
func (b *Local) stubUnsetRequiredVariables(existing map[string]backend.UnparsedVariableValue, vcs map[string]*configs.Variable) map[string]backend.UnparsedVariableValue {
|
|
var missing bool // Do we need to add anything?
|
|
for name, vc := range vcs {
|
|
if !vc.Required() {
|
|
continue // We only stub required variables
|
|
}
|
|
if _, exists := existing[name]; !exists {
|
|
missing = true
|
|
}
|
|
}
|
|
if !missing {
|
|
return existing
|
|
}
|
|
|
|
// If we get down here then there's at least one variable value to add.
|
|
ret := make(map[string]backend.UnparsedVariableValue, len(vcs))
|
|
for k, v := range existing {
|
|
ret[k] = v
|
|
}
|
|
for name, vc := range vcs {
|
|
if !vc.Required() {
|
|
continue
|
|
}
|
|
if _, exists := existing[name]; !exists {
|
|
ret[name] = unparsedUnknownVariableValue{Name: name, WantType: vc.Type}
|
|
}
|
|
}
|
|
return ret
|
|
}
|
|
|
|
type unparsedInteractiveVariableValue struct {
|
|
Name, RawValue string
|
|
}
|
|
|
|
var _ backend.UnparsedVariableValue = unparsedInteractiveVariableValue{}
|
|
|
|
func (v unparsedInteractiveVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) {
|
|
var diags tfdiags.Diagnostics
|
|
val, valDiags := mode.Parse(v.Name, v.RawValue)
|
|
diags = diags.Append(valDiags)
|
|
if diags.HasErrors() {
|
|
return nil, diags
|
|
}
|
|
return &terraform.InputValue{
|
|
Value: val,
|
|
SourceType: terraform.ValueFromInput,
|
|
}, diags
|
|
}
|
|
|
|
type unparsedUnknownVariableValue struct {
|
|
Name string
|
|
WantType cty.Type
|
|
}
|
|
|
|
var _ backend.UnparsedVariableValue = unparsedUnknownVariableValue{}
|
|
|
|
func (v unparsedUnknownVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*terraform.InputValue, tfdiags.Diagnostics) {
|
|
return &terraform.InputValue{
|
|
Value: cty.UnknownVal(v.WantType),
|
|
SourceType: terraform.ValueFromInput,
|
|
}, nil
|
|
}
|