mirror of
https://github.com/opentffoundation/opentf.git
synced 2025-12-19 17:59:05 -05:00
modules: Start of decoding the declarations in a module
For now this is only for input variables, and only far enough to get their names and detect duplicates. More to come in future commits. Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
This commit is contained in:
140
internal/modules/by_example_test.go
Normal file
140
internal/modules/by_example_test.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
// Copyright (c) The OpenTofu Authors
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
// Copyright (c) 2023 HashiCorp, Inc.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package modules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/davecgh/go-spew/spew"
|
||||||
|
"github.com/hashicorp/hcl/v2"
|
||||||
|
|
||||||
|
"github.com/opentofu/opentofu/internal/tfdiags"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestByExample attempts to load each of the modules in subdirectories of
|
||||||
|
// testdata/by-example, expecting them to either be valid or to have expected
|
||||||
|
// errors/warnings marked by end-of-line comments.
|
||||||
|
//
|
||||||
|
// There's more information on how this is intended to work in
|
||||||
|
// testdata/by-example/README.
|
||||||
|
//
|
||||||
|
// This is intended only for broad regression testing, focused on whether
|
||||||
|
// previously-valid things remain valid and previously-failing things
|
||||||
|
// continue to fail with similar errors. Most language features should also be
|
||||||
|
// covered by more specific unit tests, although it's okay to reuse some
|
||||||
|
// of the modules under testdata/by-example for those more specific tests to
|
||||||
|
// ease maintenence.
|
||||||
|
func TestByExample(t *testing.T) {
|
||||||
|
baseDir := "testdata/by-example"
|
||||||
|
exampleDirs, err := os.ReadDir(baseDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, entry := range exampleDirs {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Run(entry.Name(), func(t *testing.T) {
|
||||||
|
dir := filepath.Join(baseDir, entry.Name())
|
||||||
|
wantDiags := collectExpectedDiagnostics(t, dir)
|
||||||
|
_, gotDiags := LoadModuleFromDir(dir)
|
||||||
|
for _, got := range gotDiags {
|
||||||
|
want, ok := wantDiags.ExpectedDiagnostic(got)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("unexpected diagnostic: %s", spew.Sdump(got))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if want.Severity != got.Severity() {
|
||||||
|
t.Errorf("wrong severity in diagnostic\nwant: %s\ngot: %s", want.Severity, spew.Sdump(got))
|
||||||
|
}
|
||||||
|
if want.Summary != got.Description().Summary {
|
||||||
|
t.Errorf("wrong summary in diagnostic\nwant: %s\ngot: %s", want.Summary, spew.Sdump(got))
|
||||||
|
}
|
||||||
|
// We remove our wantDiags entry for each match, so that
|
||||||
|
// any leftovers for the end are missing expected diagostics.
|
||||||
|
wantDiags.ForgetMatchingExpected(got)
|
||||||
|
}
|
||||||
|
for key, diag := range wantDiags {
|
||||||
|
t.Errorf("missing expected diagnostic\nwant: %s %s at %s:%d", diag.Severity, diag.Summary, key.Filename, key.Line)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type expectedDiagnostic struct {
|
||||||
|
Severity tfdiags.Severity
|
||||||
|
Summary string
|
||||||
|
}
|
||||||
|
|
||||||
|
type expectedDiagnosticKey struct {
|
||||||
|
Filename string
|
||||||
|
Line int
|
||||||
|
}
|
||||||
|
|
||||||
|
type expectedDiagnostics map[expectedDiagnosticKey]expectedDiagnostic
|
||||||
|
|
||||||
|
func collectExpectedDiagnostics(t *testing.T, dir string) expectedDiagnostics {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("can't read %s: %s", dir, err)
|
||||||
|
}
|
||||||
|
ret := make(expectedDiagnostics)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filename := filepath.Join(dir, entry.Name())
|
||||||
|
src, err := os.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("can't read %s: %s", filename, err)
|
||||||
|
}
|
||||||
|
sc := hcl.NewRangeScanner(src, filename, bufio.ScanLines)
|
||||||
|
for sc.Scan() {
|
||||||
|
const errorPrefix = "ERROR:"
|
||||||
|
const warningPrefix = "WARNING:"
|
||||||
|
|
||||||
|
line := string(sc.Bytes())
|
||||||
|
rng := sc.Range()
|
||||||
|
if idx := strings.Index(line, errorPrefix); idx != -1 {
|
||||||
|
wantSummary := strings.TrimSpace(line[idx+len(errorPrefix):])
|
||||||
|
key := expectedDiagnosticKey{rng.Filename, rng.Start.Line}
|
||||||
|
ret[key] = expectedDiagnostic{tfdiags.Error, wantSummary}
|
||||||
|
}
|
||||||
|
if idx := strings.Index(line, warningPrefix); idx != -1 {
|
||||||
|
wantSummary := strings.TrimSpace(line[idx+len(warningPrefix):])
|
||||||
|
key := expectedDiagnosticKey{rng.Filename, rng.Start.Line}
|
||||||
|
ret[key] = expectedDiagnostic{tfdiags.Warning, wantSummary}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
t.Fatalf("can't read %s: %s", filename, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s expectedDiagnostics) ExpectedDiagnostic(diag tfdiags.Diagnostic) (expectedDiagnostic, bool) {
|
||||||
|
rng := diag.Source().Subject
|
||||||
|
if rng == nil {
|
||||||
|
return expectedDiagnostic{}, false // Sourceless diagnostics cannot possibly match
|
||||||
|
}
|
||||||
|
key := expectedDiagnosticKey{rng.Filename, rng.Start.Line}
|
||||||
|
expected, ok := s[key]
|
||||||
|
return expected, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s expectedDiagnostics) ForgetMatchingExpected(diag tfdiags.Diagnostic) {
|
||||||
|
rng := diag.Source().Subject
|
||||||
|
if rng == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := expectedDiagnosticKey{rng.Filename, rng.Start.Line}
|
||||||
|
delete(s, key)
|
||||||
|
}
|
||||||
41
internal/modules/input_variable.go
Normal file
41
internal/modules/input_variable.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Copyright (c) The OpenTofu Authors
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
// Copyright (c) 2023 HashiCorp, Inc.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package modules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/hashicorp/hcl/v2"
|
||||||
|
"github.com/hashicorp/hcl/v2/hclsyntax"
|
||||||
|
|
||||||
|
"github.com/opentofu/opentofu/internal/tfdiags"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InputVariable struct {
|
||||||
|
Name string
|
||||||
|
DeclRange tfdiags.SourceRange
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeInputVariableBlock(block *hcl.Block) (*InputVariable, tfdiags.Diagnostics) {
|
||||||
|
var diags tfdiags.Diagnostics
|
||||||
|
|
||||||
|
ret := &InputVariable{
|
||||||
|
Name: block.Labels[0],
|
||||||
|
DeclRange: tfdiags.SourceRangeFromHCL(block.DefRange),
|
||||||
|
}
|
||||||
|
if !hclsyntax.ValidIdentifier(ret.Name) {
|
||||||
|
diags = diags.Append(&hcl.Diagnostic{
|
||||||
|
Severity: hcl.DiagError,
|
||||||
|
Summary: "Invalid name for input variable",
|
||||||
|
Detail: fmt.Sprintf("Cannot use %q as the name of an input variable. Name must contain only letters, digits, underscores, and dashes.", ret.Name),
|
||||||
|
Subject: block.LabelRanges[0].Ptr(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Shallowly decode the arguments inside the block
|
||||||
|
|
||||||
|
return ret, diags
|
||||||
|
}
|
||||||
@@ -32,9 +32,11 @@ import (
|
|||||||
// live in some other package and use different struct types.)
|
// live in some other package and use different struct types.)
|
||||||
type Module struct {
|
type Module struct {
|
||||||
Dir string
|
Dir string
|
||||||
|
|
||||||
|
InputVariables map[string]*InputVariable
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadModuleDir(dir string) (*Module, tfdiags.Diagnostics) {
|
func LoadModuleFromDir(dir string) (*Module, tfdiags.Diagnostics) {
|
||||||
var diags tfdiags.Diagnostics
|
var diags tfdiags.Diagnostics
|
||||||
ret := &Module{
|
ret := &Module{
|
||||||
Dir: dir,
|
Dir: dir,
|
||||||
@@ -61,7 +63,63 @@ func LoadModuleDir(dir string) (*Module, tfdiags.Diagnostics) {
|
|||||||
return ret, diags
|
return ret, diags
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Decode everything else!
|
// Once we've confirmed that this module hasn't explicitly told us it
|
||||||
|
// is for a different version of OpenTofu, we can collect all of the
|
||||||
|
// diagnostics we encountered while loading the individual files, and
|
||||||
|
// bail out early if things were so invalid that we couldn't even
|
||||||
|
// complete shallow decoding. We delay this intentionally so that
|
||||||
|
// later OpenTofu versions could, for example, use laternewer HCL syntax
|
||||||
|
// features that cause recoverable parse errors without those blocking
|
||||||
|
// us from checking what OpenTofu versions the module was declared as
|
||||||
|
// being compatible with.
|
||||||
|
for _, file := range primaryFiles {
|
||||||
|
diags = diags.Append(file.Diagnostics)
|
||||||
|
}
|
||||||
|
for _, file := range overrideFiles {
|
||||||
|
diags = diags.Append(file.Diagnostics)
|
||||||
|
}
|
||||||
|
if diags.HasErrors() {
|
||||||
|
return ret, diags
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we've got this far without encountering any errors, it's time to
|
||||||
|
// analyze one level deeper with the content of the top-level declarations.
|
||||||
|
ret.InputVariables = make(map[string]*InputVariable)
|
||||||
|
for _, file := range primaryFiles {
|
||||||
|
for _, block := range file.ConfigBlocks {
|
||||||
|
switch block.Type {
|
||||||
|
case "variable":
|
||||||
|
varDecl, moreDiags := decodeInputVariableBlock(block)
|
||||||
|
diags = diags.Append(moreDiags)
|
||||||
|
if varDecl == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if existing, exists := ret.InputVariables[varDecl.Name]; exists {
|
||||||
|
diags = diags.Append(&hcl.Diagnostic{
|
||||||
|
Severity: hcl.DiagError,
|
||||||
|
Summary: "Duplicate input variable declaration",
|
||||||
|
Detail: fmt.Sprintf("An input variable named %q was also declared at %s.", existing.Name, existing.DeclRange.StartString()),
|
||||||
|
Subject: varDecl.DeclRange.ToHCL().Ptr(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ret.InputVariables[varDecl.Name] = varDecl
|
||||||
|
default:
|
||||||
|
// We should not get here because the cases above should
|
||||||
|
// cover all block types from [configFileSchema].
|
||||||
|
diags = diags.Append(fmt.Errorf("unhandled block type %q", block.Type))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, file := range overrideFiles {
|
||||||
|
// TODO: Implement something compatible with the existing merge
|
||||||
|
// behavior from "package configs".
|
||||||
|
diags = diags.Append(tfdiags.Sourceless(
|
||||||
|
tfdiags.Error,
|
||||||
|
"Override files not supported yet",
|
||||||
|
fmt.Sprintf("The prototype of a new approach to module loading does not yet handle override files, so cannot deal with %q.", file.Filename),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
return ret, diags
|
return ret, diags
|
||||||
}
|
}
|
||||||
|
|||||||
32
internal/modules/testdata/by-example/README
vendored
Normal file
32
internal/modules/testdata/by-example/README
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
The subdirectories of this directory each contain an "example" module that
|
||||||
|
will be automatically tested by TestByExample in the "modules" package.
|
||||||
|
|
||||||
|
All modules are expected to be loadable without generating any diagnostics,
|
||||||
|
unless lines in the file are marked with end-of-line comments describing
|
||||||
|
an expected diagnostic. For example:
|
||||||
|
|
||||||
|
vriable "example" { # ERROR: Unsupported block type
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
A line containing "ERROR:" means that the example is expected to generate
|
||||||
|
an error diagnostic referring to that line of source code, whose summary
|
||||||
|
matches the text that follows "ERROR:" after stripping leading/trailing
|
||||||
|
whitespace.
|
||||||
|
|
||||||
|
A line containing "WARNING:" means the same except that the diagnostic is
|
||||||
|
expected to have warning severity.
|
||||||
|
|
||||||
|
Any diagnostic reported on a line that does not include one of these
|
||||||
|
substrings is treated as unexpected and causes a test failure.
|
||||||
|
|
||||||
|
By convention, examples that contain expected errors are named with the
|
||||||
|
prefix "err-" to distinguish them from the examples that are expected to
|
||||||
|
be valid (potentially with warnings). However, that prefix is for human
|
||||||
|
convenience only.
|
||||||
|
|
||||||
|
When an example directory contains only one source file, prefer to give it
|
||||||
|
a basename that matches the directory name (ignoring "err-" prefix) so that
|
||||||
|
it's easier to distinguish them in an editor with multiple examples open at
|
||||||
|
once. When there are multiple files present, just try to select filenames that
|
||||||
|
are all somehow related to the name of the directory containing the example.
|
||||||
2
internal/modules/testdata/by-example/err-unrecognized-block-type/unrecognized-block-type.tf
vendored
Normal file
2
internal/modules/testdata/by-example/err-unrecognized-block-type/unrecognized-block-type.tf
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
not_valid { # ERROR: Unsupported block type
|
||||||
|
}
|
||||||
30
internal/modules/testdata/by-example/variables/variables.tf
vendored
Normal file
30
internal/modules/testdata/by-example/variables/variables.tf
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# This example is a good place to add relatively-simple valid examples of
|
||||||
|
# input variables where it seems like overkill to add an entirely new example
|
||||||
|
# directory, but if you're adding a collection of example blocks related to
|
||||||
|
# a specific feature then probably better to start a new directory to collect
|
||||||
|
# those together thematically.
|
||||||
|
|
||||||
|
variable "unconstrained" {
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "string" {
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "list_of_string" {
|
||||||
|
type = list(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "explicitly_unconstrained" {
|
||||||
|
type = any
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "string_optional" {
|
||||||
|
type = string
|
||||||
|
default = "hello"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "unconstrained_optional" {
|
||||||
|
type = string
|
||||||
|
default = "hello"
|
||||||
|
}
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
package configs2
|
// Copyright (c) The OpenTofu Authors
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
// Copyright (c) 2023 HashiCorp, Inc.
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
package modules
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/hashicorp/hcl/v2"
|
"github.com/hashicorp/hcl/v2"
|
||||||
|
|||||||
Reference in New Issue
Block a user