mirror of
https://github.com/opentofu/opentofu.git
synced 2024-12-24 16:10:46 -06:00
cc91bf25f5
Signed-off-by: James Humphries <james@james-humphries.co.uk>
549 lines
20 KiB
Go
549 lines
20 KiB
Go
// Copyright (c) The OpenTofu Authors
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
// Copyright (c) 2023 HashiCorp, Inc.
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
package local
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/hashicorp/hcl/v2"
|
|
"github.com/zclconf/go-cty/cty"
|
|
|
|
"github.com/opentofu/opentofu/internal/backend"
|
|
"github.com/opentofu/opentofu/internal/configs"
|
|
"github.com/opentofu/opentofu/internal/configs/configload"
|
|
"github.com/opentofu/opentofu/internal/plans/planfile"
|
|
"github.com/opentofu/opentofu/internal/states/statemgr"
|
|
"github.com/opentofu/opentofu/internal/tfdiags"
|
|
"github.com/opentofu/opentofu/internal/tofu"
|
|
"github.com/opentofu/opentofu/internal/tofumigrate"
|
|
)
|
|
|
|
// 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 tofu.ContextOpts
|
|
if v := b.ContextOpts; v != nil {
|
|
coreOpts = *v
|
|
}
|
|
coreOpts.UIInput = op.UIIn
|
|
coreOpts.Hooks = op.Hooks
|
|
coreOpts.Encryption = op.Encryption
|
|
|
|
var ctxDiags tfdiags.Diagnostics
|
|
var configSnap *configload.Snapshot
|
|
if op.PlanFile.IsCloud() {
|
|
diags = diags.Append(fmt.Errorf("error: using a saved cloud plan when executing OpenTofu locally is not supported"))
|
|
return nil, nil, nil, diags
|
|
}
|
|
|
|
if lp, ok := op.PlanFile.Local(); ok {
|
|
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, lp, 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 := tofu.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 *tofu.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, op.RootCall)
|
|
diags = diags.Append(configDiags)
|
|
if configDiags.HasErrors() {
|
|
return nil, nil, diags
|
|
}
|
|
run.Config = config
|
|
|
|
if errs := config.VerifyDependencySelections(op.DependencyLocks); len(errs) > 0 {
|
|
var buf strings.Builder
|
|
for _, err := range errs {
|
|
fmt.Fprintf(&buf, "\n - %s", err.Error())
|
|
}
|
|
var suggestion string
|
|
switch {
|
|
case op.DependencyLocks == nil:
|
|
// If we get here then it suggests that there's a caller that we
|
|
// didn't yet update to populate DependencyLocks, which is a bug.
|
|
suggestion = "This run has no dependency lock information provided at all, which is a bug in OpenTofu; please report it!"
|
|
case op.DependencyLocks.Empty():
|
|
suggestion = "To make the initial dependency selections that will initialize the dependency lock file, run:\n tofu init"
|
|
default:
|
|
suggestion = "To update the locked dependency selections to match a changed configuration, run:\n tofu init -upgrade"
|
|
}
|
|
diags = diags.Append(tfdiags.Sourceless(
|
|
tfdiags.Error,
|
|
"Inconsistent dependency lock file",
|
|
fmt.Sprintf(
|
|
"The following dependency selections recorded in the lock file are inconsistent with the current configuration:%s\n\n%s",
|
|
buf.String(), suggestion,
|
|
),
|
|
))
|
|
}
|
|
|
|
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 := &tofu.PlanOpts{
|
|
Mode: op.PlanMode,
|
|
Targets: op.Targets,
|
|
ForceReplace: op.ForceReplace,
|
|
SetVariables: variables,
|
|
SkipRefresh: op.Type != backend.OperationTypeRefresh && !op.PlanRefresh,
|
|
GenerateConfigPath: op.GenerateConfigOut,
|
|
}
|
|
run.PlanOpts = planOpts
|
|
|
|
// For a "direct" local run, the input state is the most recently stored
|
|
// snapshot, from the previous run.
|
|
state := s.State()
|
|
if state != nil {
|
|
migratedState, migrateDiags := tofumigrate.MigrateStateProviderAddresses(config, state)
|
|
diags = diags.Append(migrateDiags)
|
|
if migrateDiags.HasErrors() {
|
|
return nil, nil, diags
|
|
}
|
|
state = migratedState
|
|
}
|
|
run.InputState = state
|
|
|
|
tfCtx, moreDiags := tofu.NewContext(coreOpts)
|
|
diags = diags.Append(moreDiags)
|
|
if moreDiags.HasErrors() {
|
|
return nil, nil, diags
|
|
}
|
|
run.Core = tfCtx
|
|
return run, configSnap, diags
|
|
}
|
|
|
|
func (b *Local) localRunForPlanFile(op *backend.Operation, pf *planfile.Reader, run *backend.LocalRun, coreOpts *tofu.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
|
|
}
|
|
|
|
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
|
|
|
|
subCall := op.RootCall.WithVariables(func(variable *configs.Variable) (cty.Value, hcl.Diagnostics) {
|
|
var diags hcl.Diagnostics
|
|
|
|
name := variable.Name
|
|
v, ok := plan.VariableValues[name]
|
|
if !ok {
|
|
if variable.Required() {
|
|
// This should not happen...
|
|
return cty.DynamicVal, diags.Append(&hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: "Missing plan variable " + variable.Name,
|
|
})
|
|
}
|
|
return variable.Default, nil
|
|
}
|
|
|
|
parsed, parsedErr := v.Decode(cty.DynamicPseudoType)
|
|
if parsedErr != nil {
|
|
diags = diags.Append(&hcl.Diagnostic{
|
|
Severity: hcl.DiagError,
|
|
Summary: parsedErr.Error(),
|
|
})
|
|
}
|
|
return parsed, diags
|
|
})
|
|
|
|
loader := configload.NewLoaderFromSnapshot(snap)
|
|
config, configDiags := loader.LoadConfig(snap.Modules[""].Dir, subCall)
|
|
diags = diags.Append(configDiags)
|
|
if configDiags.HasErrors() {
|
|
return nil, snap, diags
|
|
}
|
|
run.Config = config
|
|
|
|
// NOTE: We're intentionally comparing the current locks with the
|
|
// configuration snapshot, rather than the lock snapshot in the plan file,
|
|
// because it's the current locks which dictate our plugin selections
|
|
// in coreOpts below. However, we'll also separately check that the
|
|
// plan file has identical locked plugins below, and thus we're effectively
|
|
// checking consistency with both here.
|
|
if errs := config.VerifyDependencySelections(op.DependencyLocks); len(errs) > 0 {
|
|
var buf strings.Builder
|
|
for _, err := range errs {
|
|
fmt.Fprintf(&buf, "\n - %s", err.Error())
|
|
}
|
|
diags = diags.Append(tfdiags.Sourceless(
|
|
tfdiags.Error,
|
|
"Inconsistent dependency lock file",
|
|
fmt.Sprintf(
|
|
"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.",
|
|
buf.String(),
|
|
),
|
|
))
|
|
}
|
|
|
|
// This check is an important complement to the check above: the locked
|
|
// dependencies in the configuration must match the configuration, and
|
|
// the locked dependencies in the plan must match the locked dependencies
|
|
// in the configuration, and so transitively we ensure that the locked
|
|
// dependencies in the plan match the configuration too. However, this
|
|
// additionally catches any inconsistency between the two sets of locks
|
|
// even if they both happen to be valid per the current configuration,
|
|
// which is one of several ways we try to catch the mistake of applying
|
|
// a saved plan file in a different place than where we created it.
|
|
depLocksFromPlan, moreDiags := pf.ReadDependencyLocks()
|
|
diags = diags.Append(moreDiags)
|
|
if depLocksFromPlan != nil && !op.DependencyLocks.Equal(depLocksFromPlan) {
|
|
diags = diags.Append(tfdiags.Sourceless(
|
|
tfdiags.Error,
|
|
"Inconsistent dependency lock file",
|
|
"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.",
|
|
))
|
|
}
|
|
|
|
// 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.)
|
|
|
|
// Because the plan always contains a state, even if it is empty, the
|
|
// first plan to be applied will have empty snapshot metadata. In this
|
|
// case we compare only the serial in order to provide a more correct
|
|
// error.
|
|
firstPlan := priorStateFile.Lineage == "" && priorStateFile.Serial == 0
|
|
|
|
switch {
|
|
case !firstPlan && priorStateFile.Lineage != currentStateMeta.Lineage:
|
|
diags = diags.Append(tfdiags.Sourceless(
|
|
tfdiags.Error,
|
|
"Saved plan does not match the given state",
|
|
"The given plan file can not be applied because it was created from a different state lineage.",
|
|
))
|
|
|
|
case priorStateFile.Serial != currentStateMeta.Serial:
|
|
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
|
|
|
|
tfCtx, moreDiags := tofu.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 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. OpenTofu
|
|
// 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 tofu.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, &tofu.InputOpts{
|
|
Id: fmt.Sprintf("var.%s", name),
|
|
Query: fmt.Sprintf("var.%s", name),
|
|
Description: vc.Description,
|
|
Secret: vc.Sensitive,
|
|
})
|
|
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 OpenTofu context for some ancillary functionality
|
|
// like "tofu console", "tofu 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) (*tofu.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 &tofu.InputValue{
|
|
Value: val,
|
|
SourceType: tofu.ValueFromInput,
|
|
}, diags
|
|
}
|
|
|
|
type unparsedUnknownVariableValue struct {
|
|
Name string
|
|
WantType cty.Type
|
|
}
|
|
|
|
var _ backend.UnparsedVariableValue = unparsedUnknownVariableValue{}
|
|
|
|
func (v unparsedUnknownVariableValue) ParseVariableValue(mode configs.VariableParsingMode) (*tofu.InputValue, tfdiags.Diagnostics) {
|
|
return &tofu.InputValue{
|
|
Value: cty.UnknownVal(v.WantType),
|
|
SourceType: tofu.ValueFromInput,
|
|
}, nil
|
|
}
|