github.com/terraform-linters/tflint@v0.51.2-0.20240520175844-3750771571b6/terraform/evaluator.go (about)

     1  package terraform
     2  
     3  import (
     4  	"fmt"
     5  	"os"
     6  	"path/filepath"
     7  	"strings"
     8  
     9  	"github.com/agext/levenshtein"
    10  	"github.com/hashicorp/hcl/v2"
    11  	"github.com/terraform-linters/tflint-plugin-sdk/hclext"
    12  	"github.com/terraform-linters/tflint-plugin-sdk/terraform/lang/marks"
    13  	"github.com/terraform-linters/tflint/terraform/addrs"
    14  	"github.com/terraform-linters/tflint/terraform/lang"
    15  	"github.com/zclconf/go-cty/cty"
    16  	"github.com/zclconf/go-cty/cty/convert"
    17  )
    18  
    19  type ContextMeta struct {
    20  	Env                string
    21  	OriginalWorkingDir string
    22  }
    23  
    24  type CallStack struct {
    25  	addrs map[string]addrs.Reference
    26  	stack []string
    27  }
    28  
    29  func NewCallStack() *CallStack {
    30  	return &CallStack{
    31  		addrs: make(map[string]addrs.Reference),
    32  		stack: make([]string, 0),
    33  	}
    34  }
    35  
    36  func (g *CallStack) Push(addr addrs.Reference) hcl.Diagnostics {
    37  	g.stack = append(g.stack, addr.Subject.String())
    38  
    39  	if _, exists := g.addrs[addr.Subject.String()]; exists {
    40  		return hcl.Diagnostics{
    41  			{
    42  				Severity: hcl.DiagError,
    43  				Summary:  "circular reference found",
    44  				Detail:   g.String(),
    45  				Subject:  addr.SourceRange.Ptr(),
    46  			},
    47  		}
    48  	}
    49  	g.addrs[addr.Subject.String()] = addr
    50  	return hcl.Diagnostics{}
    51  }
    52  
    53  func (g *CallStack) Pop() {
    54  	if g.Empty() {
    55  		panic("cannot pop from empty stack")
    56  	}
    57  
    58  	addr := g.stack[len(g.stack)-1]
    59  	g.stack = g.stack[:len(g.stack)-1]
    60  	delete(g.addrs, addr)
    61  }
    62  
    63  func (g *CallStack) String() string {
    64  	return strings.Join(g.stack, " -> ")
    65  }
    66  
    67  func (g *CallStack) Empty() bool {
    68  	return len(g.stack) == 0
    69  }
    70  
    71  func (g *CallStack) Clear() {
    72  	g.addrs = make(map[string]addrs.Reference)
    73  	g.stack = make([]string, 0)
    74  }
    75  
    76  type Evaluator struct {
    77  	Meta           *ContextMeta
    78  	ModulePath     addrs.ModuleInstance
    79  	Config         *Config
    80  	VariableValues map[string]map[string]cty.Value
    81  	CallStack      *CallStack
    82  }
    83  
    84  // EvaluateExpr takes the given HCL expression and evaluates it to produce a value.
    85  func (e *Evaluator) EvaluateExpr(expr hcl.Expression, wantType cty.Type) (cty.Value, hcl.Diagnostics) {
    86  	if e == nil {
    87  		panic("evaluator must not be nil")
    88  	}
    89  	return e.scope().EvalExpr(expr, wantType)
    90  }
    91  
    92  // ExpandBlock expands "dynamic" blocks and resources/modules with count/for_each.
    93  //
    94  // In the expanded body, the content can be retrieved with the HCL API without
    95  // being aware of the differences in the dynamic block schema. Also, the number
    96  // of blocks and attribute values will be the same as the expanded result.
    97  func (e *Evaluator) ExpandBlock(body hcl.Body, schema *hclext.BodySchema) (hcl.Body, hcl.Diagnostics) {
    98  	if e == nil {
    99  		return body, nil
   100  	}
   101  	return e.scope().ExpandBlock(body, schema)
   102  }
   103  
   104  func (e *Evaluator) scope() *lang.Scope {
   105  	return &lang.Scope{
   106  		Data: &evaluationData{
   107  			Evaluator:  e,
   108  			ModulePath: e.ModulePath,
   109  		},
   110  	}
   111  }
   112  
   113  type evaluationData struct {
   114  	Evaluator  *Evaluator
   115  	ModulePath addrs.ModuleInstance
   116  }
   117  
   118  var _ lang.Data = (*evaluationData)(nil)
   119  
   120  func (d *evaluationData) GetCountAttr(addr addrs.CountAttr, rng hcl.Range) (cty.Value, hcl.Diagnostics) {
   121  	// Note that the actual evaluation of count.index is not done here.
   122  	// count.index is already evaluated when expanded by ExpandBlock,
   123  	// and the value is bound to the expanded body.
   124  	//
   125  	// Although, there are cases where count.index is evaluated as-is,
   126  	// such as when not expanding the body. In that case, evaluate it
   127  	// as an unknown and skip further checks.
   128  	return cty.UnknownVal(cty.Number), nil
   129  }
   130  
   131  func (d *evaluationData) GetForEachAttr(addr addrs.ForEachAttr, rng hcl.Range) (cty.Value, hcl.Diagnostics) {
   132  	// Note that the actual evaluation of each.key/each.value is not done here.
   133  	// each.key/each.value is already evaluated when expanded by ExpandBlock,
   134  	// and the value is bound to the expanded body.
   135  	//
   136  	// Although, there are cases where each.key/each.value is evaluated as-is,
   137  	// such as when not expanding the body. In that case, evaluate it
   138  	// as an unknown and skip further checks.
   139  	return cty.DynamicVal, nil
   140  }
   141  
   142  func (d *evaluationData) GetInputVariable(addr addrs.InputVariable, rng hcl.Range) (cty.Value, hcl.Diagnostics) {
   143  	var diags hcl.Diagnostics
   144  
   145  	moduleConfig := d.Evaluator.Config.DescendentForInstance(d.ModulePath)
   146  	if moduleConfig == nil {
   147  		// should never happen, since we can't be evaluating in a module
   148  		// that wasn't mentioned in configuration.
   149  		panic(fmt.Sprintf("input variable read from %s, which has no configuration", d.ModulePath))
   150  	}
   151  
   152  	config := moduleConfig.Module.Variables[addr.Name]
   153  	if config == nil {
   154  		var suggestions []string
   155  		for k := range moduleConfig.Module.Variables {
   156  			suggestions = append(suggestions, k)
   157  		}
   158  		suggestion := nameSuggestion(addr.Name, suggestions)
   159  		if suggestion != "" {
   160  			suggestion = fmt.Sprintf(" Did you mean %q?", suggestion)
   161  		} else {
   162  			suggestion = fmt.Sprintf(" This variable can be declared with a variable %q {} block.", addr.Name)
   163  		}
   164  
   165  		diags = diags.Append(&hcl.Diagnostic{
   166  			Severity: hcl.DiagError,
   167  			Summary:  `Reference to undeclared input variable`,
   168  			Detail:   fmt.Sprintf(`An input variable with the name %q has not been declared.%s`, addr.Name, suggestion),
   169  			Subject:  rng.Ptr(),
   170  		})
   171  		return cty.DynamicVal, diags
   172  	}
   173  
   174  	moduleAddrStr := d.ModulePath.String()
   175  	vals := d.Evaluator.VariableValues[moduleAddrStr]
   176  	if vals == nil {
   177  		return cty.UnknownVal(config.Type), diags
   178  	}
   179  
   180  	// In Terraform, it is the responsibility of the VariableTransformer
   181  	// to convert the variable to the "final value", including the type conversion.
   182  	// However, since TFLint does not preprocess variables by Graph Builder,
   183  	// type conversion and default value are applied by Evaluator as in Terraform v1.1.
   184  	//
   185  	// This has some restrictions on the representation of dynamic variables compared
   186  	// to Terraform, but since TFLint is intended for static analysis, this is often enough.
   187  	val, isSet := vals[addr.Name]
   188  	switch {
   189  	case !isSet:
   190  		// The config loader will ensure there is a default if the value is not
   191  		// set at all.
   192  		val = config.Default
   193  
   194  	case val.IsNull() && !config.Nullable && config.Default != cty.NilVal:
   195  		// If nullable=false a null value will use the configured default.
   196  		val = config.Default
   197  	}
   198  
   199  	// Apply defaults from the variable's type constraint to the value,
   200  	// unless the value is null. We do not apply defaults to top-level
   201  	// null values, as doing so could prevent assigning null to a nullable
   202  	// variable.
   203  	if config.TypeDefaults != nil && !val.IsNull() {
   204  		val = config.TypeDefaults.Apply(val)
   205  	}
   206  
   207  	var err error
   208  	val, err = convert.Convert(val, config.ConstraintType)
   209  	if err != nil {
   210  		diags = diags.Append(&hcl.Diagnostic{
   211  			Severity: hcl.DiagError,
   212  			Summary:  `Incorrect variable type`,
   213  			Detail:   fmt.Sprintf(`The resolved value of variable %q is not appropriate: %s.`, addr.Name, err),
   214  			Subject:  &config.DeclRange,
   215  		})
   216  		val = cty.UnknownVal(config.Type)
   217  	}
   218  
   219  	// Mark if sensitive
   220  	if config.Sensitive {
   221  		val = val.Mark(marks.Sensitive)
   222  	}
   223  
   224  	return val, diags
   225  }
   226  
   227  func (d *evaluationData) GetLocalValue(addr addrs.LocalValue, rng hcl.Range) (cty.Value, hcl.Diagnostics) {
   228  	var diags hcl.Diagnostics
   229  
   230  	// First we'll make sure the requested value is declared in configuration,
   231  	// so we can produce a nice message if not.
   232  	moduleConfig := d.Evaluator.Config.DescendentForInstance(d.ModulePath)
   233  	if moduleConfig == nil {
   234  		// should never happen, since we can't be evaluating in a module
   235  		// that wasn't mentioned in configuration.
   236  		panic(fmt.Sprintf("local value read from %s, which has no configuration", d.ModulePath))
   237  	}
   238  
   239  	config := moduleConfig.Module.Locals[addr.Name]
   240  	if config == nil {
   241  		var suggestions []string
   242  		for k := range moduleConfig.Module.Locals {
   243  			suggestions = append(suggestions, k)
   244  		}
   245  		suggestion := nameSuggestion(addr.Name, suggestions)
   246  		if suggestion != "" {
   247  			suggestion = fmt.Sprintf(" Did you mean %q?", suggestion)
   248  		}
   249  
   250  		diags = diags.Append(&hcl.Diagnostic{
   251  			Severity: hcl.DiagError,
   252  			Summary:  `Reference to undeclared local value`,
   253  			Detail:   fmt.Sprintf(`A local value with the name %q has not been declared.%s`, addr.Name, suggestion),
   254  			Subject:  rng.Ptr(),
   255  		})
   256  		return cty.DynamicVal, diags
   257  	}
   258  
   259  	// Build a call stack for circular reference detection only when getting a local value.
   260  	if diags := d.Evaluator.CallStack.Push(addrs.Reference{Subject: addr, SourceRange: rng}); diags.HasErrors() {
   261  		return cty.UnknownVal(cty.DynamicPseudoType), diags
   262  	}
   263  
   264  	val, diags := d.Evaluator.EvaluateExpr(config.Expr, cty.DynamicPseudoType)
   265  
   266  	d.Evaluator.CallStack.Pop()
   267  	return val, diags
   268  }
   269  
   270  func (d *evaluationData) GetPathAttr(addr addrs.PathAttr, rng hcl.Range) (cty.Value, hcl.Diagnostics) {
   271  	var diags hcl.Diagnostics
   272  	switch addr.Name {
   273  
   274  	case "cwd":
   275  		var err error
   276  		var wd string
   277  		if d.Evaluator.Meta != nil {
   278  			// Meta is always non-nil in the normal case, but some test cases
   279  			// are not so realistic.
   280  			wd = d.Evaluator.Meta.OriginalWorkingDir
   281  		}
   282  		if wd == "" {
   283  			wd, err = os.Getwd()
   284  			if err != nil {
   285  				diags = diags.Append(&hcl.Diagnostic{
   286  					Severity: hcl.DiagError,
   287  					Summary:  `Failed to get working directory`,
   288  					Detail:   fmt.Sprintf(`The value for path.cwd cannot be determined due to a system error: %s`, err),
   289  					Subject:  rng.Ptr(),
   290  				})
   291  				return cty.DynamicVal, diags
   292  			}
   293  		}
   294  		// The current working directory should always be absolute, whether we
   295  		// just looked it up or whether we were relying on ContextMeta's
   296  		// (possibly non-normalized) path.
   297  		wd, err = filepath.Abs(wd)
   298  		if err != nil {
   299  			diags = diags.Append(&hcl.Diagnostic{
   300  				Severity: hcl.DiagError,
   301  				Summary:  `Failed to get working directory`,
   302  				Detail:   fmt.Sprintf(`The value for path.cwd cannot be determined due to a system error: %s`, err),
   303  				Subject:  rng.Ptr(),
   304  			})
   305  			return cty.DynamicVal, diags
   306  		}
   307  
   308  		return cty.StringVal(filepath.ToSlash(wd)), diags
   309  
   310  	case "module":
   311  		moduleConfig := d.Evaluator.Config.DescendentForInstance(d.ModulePath)
   312  		if moduleConfig == nil {
   313  			// should never happen, since we can't be evaluating in a module
   314  			// that wasn't mentioned in configuration.
   315  			panic(fmt.Sprintf("module.path read from module %s, which has no configuration", d.ModulePath))
   316  		}
   317  		sourceDir := moduleConfig.Module.SourceDir
   318  		return cty.StringVal(filepath.ToSlash(sourceDir)), diags
   319  
   320  	case "root":
   321  		sourceDir := d.Evaluator.Config.Module.SourceDir
   322  		return cty.StringVal(filepath.ToSlash(sourceDir)), diags
   323  
   324  	default:
   325  		suggestion := nameSuggestion(addr.Name, []string{"cwd", "module", "root"})
   326  		if suggestion != "" {
   327  			suggestion = fmt.Sprintf(" Did you mean %q?", suggestion)
   328  		}
   329  		diags = diags.Append(&hcl.Diagnostic{
   330  			Severity: hcl.DiagError,
   331  			Summary:  `Invalid "path" attribute`,
   332  			Detail:   fmt.Sprintf(`The "path" object does not have an attribute named %q.%s`, addr.Name, suggestion),
   333  			Subject:  rng.Ptr(),
   334  		})
   335  		return cty.DynamicVal, diags
   336  	}
   337  }
   338  
   339  func (d *evaluationData) GetTerraformAttr(addr addrs.TerraformAttr, rng hcl.Range) (cty.Value, hcl.Diagnostics) {
   340  	var diags hcl.Diagnostics
   341  	switch addr.Name {
   342  
   343  	case "workspace":
   344  		workspaceName := d.Evaluator.Meta.Env
   345  		return cty.StringVal(workspaceName), diags
   346  
   347  	case "env":
   348  		// Prior to Terraform 0.12 there was an attribute "env", which was
   349  		// an alias name for "workspace". This was deprecated and is now
   350  		// removed.
   351  		diags = diags.Append(&hcl.Diagnostic{
   352  			Severity: hcl.DiagError,
   353  			Summary:  `Invalid "terraform" attribute`,
   354  			Detail:   `The terraform.env attribute was deprecated in v0.10 and removed in v0.12. The "state environment" concept was renamed to "workspace" in v0.12, and so the workspace name can now be accessed using the terraform.workspace attribute.`,
   355  			Subject:  rng.Ptr(),
   356  		})
   357  		return cty.DynamicVal, diags
   358  
   359  	default:
   360  		diags = diags.Append(&hcl.Diagnostic{
   361  			Severity: hcl.DiagError,
   362  			Summary:  `Invalid "terraform" attribute`,
   363  			Detail:   fmt.Sprintf(`The "terraform" object does not have an attribute named %q. The only supported attribute is terraform.workspace, the name of the currently-selected workspace.`, addr.Name),
   364  			Subject:  rng.Ptr(),
   365  		})
   366  		return cty.DynamicVal, diags
   367  	}
   368  }
   369  
   370  // nameSuggestion tries to find a name from the given slice of suggested names
   371  // that is close to the given name and returns it if found. If no suggestion
   372  // is close enough, returns the empty string.
   373  //
   374  // The suggestions are tried in order, so earlier suggestions take precedence
   375  // if the given string is similar to two or more suggestions.
   376  //
   377  // This function is intended to be used with a relatively-small number of
   378  // suggestions. It's not optimized for hundreds or thousands of them.
   379  func nameSuggestion(given string, suggestions []string) string {
   380  	for _, suggestion := range suggestions {
   381  		dist := levenshtein.Distance(given, suggestion, nil)
   382  		if dist < 3 { // threshold determined experimentally
   383  			return suggestion
   384  		}
   385  	}
   386  	return ""
   387  }