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  }