github.com/kevinklinger/open_terraform@v1.3.6/noninternal/backend/local/backend_local.go (about)

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