opentofu/internal/backend/local/backend_local.go
Martin Atkins 89b05050ec core: Functional-style API for terraform.Context
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.
2021-08-30 13:59:14 -07:00

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
}