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 }