diff --git a/configs/module.go b/configs/module.go index 0a4d5b916..dec21d045 100644 --- a/configs/module.go +++ b/configs/module.go @@ -1,6 +1,8 @@ package configs import ( + "fmt" + "github.com/hashicorp/hcl2/hcl" ) @@ -23,20 +25,6 @@ type Module struct { DataResources map[string]*DataResource } -// NewModule takes a list of primary files and a list of override files and -// produces a *Module by combining the files together. -// -// If there are any conflicting declarations in the given files -- for example, -// if the same variable name is defined twice -- then the resulting module -// will be incomplete and error diagnostics will be returned. Careful static -// analysis of the returned Module is still possible in this case, but the -// module will probably not be semantically valid. -func NewModule(primaryFiles, overrideFiles []*File) (*Module, hcl.Diagnostics) { - // TODO: process each file in turn, combining and merging as necessary - // to produce a single flat *Module. - panic("NewModule not yet implemented") -} - // File describes the contents of a single configuration file. // // Individual files are not usually used alone, but rather combined together @@ -64,3 +52,325 @@ type File struct { ManagedResources []*ManagedResource DataResources []*DataResource } + +// NewModule takes a list of primary files and a list of override files and +// produces a *Module by combining the files together. +// +// If there are any conflicting declarations in the given files -- for example, +// if the same variable name is defined twice -- then the resulting module +// will be incomplete and error diagnostics will be returned. Careful static +// analysis of the returned Module is still possible in this case, but the +// module will probably not be semantically valid. +func NewModule(primaryFiles, overrideFiles []*File) (*Module, hcl.Diagnostics) { + var diags hcl.Diagnostics + mod := &Module{ + ProviderConfigs: map[string]*Provider{}, + ProviderRequirements: map[string][]VersionConstraint{}, + Variables: map[string]*Variable{}, + Locals: map[string]*Local{}, + Outputs: map[string]*Output{}, + ModuleCalls: map[string]*ModuleCall{}, + ManagedResources: map[string]*ManagedResource{}, + DataResources: map[string]*DataResource{}, + } + + for _, file := range primaryFiles { + fileDiags := mod.appendFile(file) + diags = append(diags, fileDiags...) + } + + for _, file := range overrideFiles { + fileDiags := mod.mergeFile(file) + diags = append(diags, fileDiags...) + } + + return mod, diags +} + +func (m *Module) appendFile(file *File) hcl.Diagnostics { + var diags hcl.Diagnostics + + for _, constraint := range file.CoreVersionConstraints { + // If there are any conflicting requirements then we'll catch them + // when we actually check these constraints. + m.CoreVersionConstraints = append(m.CoreVersionConstraints, constraint) + } + + for _, b := range file.Backends { + if m.Backend != nil { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Duplicate backend configuration", + Detail: fmt.Sprintf("A module may have only one backend configuration. The backend was previously configured at %s.", m.Backend.DeclRange), + Subject: &b.DeclRange, + }) + continue + } + m.Backend = b + } + + for _, pc := range file.ProviderConfigs { + key := pc.moduleUniqueKey() + if existing, exists := m.ProviderConfigs[key]; exists { + if existing.Alias == "" { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Duplicate provider configuration", + Detail: fmt.Sprintf("A default (non-aliased) provider configuration for %q was already given at %s. If multiple configurations are required, set the \"alias\" argument for alternative configurations.", existing.Name, existing.DeclRange), + Subject: &pc.DeclRange, + }) + } else { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Duplicate provider configuration", + Detail: fmt.Sprintf("A provider configuration for %q with alias %q was already given at %s. Each configuration for the same provider must have a distinct alias.", existing.Name, existing.Alias, existing.DeclRange), + Subject: &pc.DeclRange, + }) + } + continue + } + m.ProviderConfigs[key] = pc + } + + for _, reqd := range file.ProviderRequirements { + m.ProviderRequirements[reqd.Name] = append(m.ProviderRequirements[reqd.Name], reqd.Requirement) + } + + for _, v := range file.Variables { + if existing, exists := m.Variables[v.Name]; exists { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Duplicate variable declaration", + Detail: fmt.Sprintf("A variable named %q was already declared at %s. Variable names must be unique within a module.", existing.Name, existing.DeclRange), + Subject: &v.DeclRange, + }) + } + m.Variables[v.Name] = v + } + + for _, l := range file.Locals { + if existing, exists := m.Locals[l.Name]; exists { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Duplicate local value definition", + Detail: fmt.Sprintf("A local value named %q was already defined at %s. Local value names must be unique within a module.", existing.Name, existing.DeclRange), + Subject: &l.DeclRange, + }) + } + m.Locals[l.Name] = l + } + + for _, o := range file.Outputs { + if existing, exists := m.Outputs[o.Name]; exists { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Duplicate output definition", + Detail: fmt.Sprintf("An output named %q was already defined at %s. Output names must be unique within a module.", existing.Name, existing.DeclRange), + Subject: &o.DeclRange, + }) + } + m.Outputs[o.Name] = o + } + + for _, mc := range file.ModuleCalls { + if existing, exists := m.ModuleCalls[mc.Name]; exists { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Duplicate module call", + Detail: fmt.Sprintf("An module call named %q was already defined at %s. Module calls must have unique names within a module.", existing.Name, existing.DeclRange), + Subject: &mc.DeclRange, + }) + } + m.ModuleCalls[mc.Name] = mc + } + + for _, r := range file.ManagedResources { + key := r.moduleUniqueKey() + if existing, exists := m.ManagedResources[key]; exists { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Duplicate resource %q configuration", existing.Type), + Detail: fmt.Sprintf("A %s resource named %q was already declared at %s. Resource names must be unique per type in each module.", existing.Type, existing.Name, existing.DeclRange), + Subject: &r.DeclRange, + }) + continue + } + m.ManagedResources[key] = r + } + + for _, r := range file.DataResources { + key := r.moduleUniqueKey() + if existing, exists := m.DataResources[key]; exists { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Duplicate data %q configuration", existing.Type), + Detail: fmt.Sprintf("A %s data resource named %q was already declared at %s. Resource names must be unique per type in each module.", existing.Type, existing.Name, existing.DeclRange), + Subject: &r.DeclRange, + }) + continue + } + m.DataResources[key] = r + } + + return diags +} + +func (m *Module) mergeFile(file *File) hcl.Diagnostics { + var diags hcl.Diagnostics + + if len(file.CoreVersionConstraints) != 0 { + // This is a bit of a strange case for overriding since we normally + // would union together across multiple files anyway, but we'll + // allow it and have each override file clobber any existing list. + m.CoreVersionConstraints = nil + for _, constraint := range file.CoreVersionConstraints { + m.CoreVersionConstraints = append(m.CoreVersionConstraints, constraint) + } + } + + if len(file.Backends) != 0 { + switch len(file.Backends) { + case 1: + m.Backend = file.Backends[0] + default: + // An override file with multiple backends is still invalid, even + // though it can override backends from _other_ files. + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Duplicate backend configuration", + Detail: fmt.Sprintf("Each override file may have only one backend configuration. A backend was previously configured at %s.", file.Backends[0].DeclRange), + Subject: &file.Backends[1].DeclRange, + }) + } + } + + for _, pc := range file.ProviderConfigs { + key := pc.moduleUniqueKey() + existing, exists := m.ProviderConfigs[key] + if pc.Alias == "" { + // We allow overriding a non-existing _default_ provider configuration + // because the user model is that an absent provider configuration + // implies an empty provider configuration, which is what the user + // is therefore overriding here. + if exists { + mergeDiags := existing.merge(pc) + diags = append(diags, mergeDiags...) + } else { + m.ProviderConfigs[key] = pc + } + } else { + // For aliased providers, there must be a base configuration to + // override. This allows us to detect and report alias typos + // that might otherwise cause the override to not apply. + if !exists { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Missing base provider configuration for override", + Detail: fmt.Sprintf("There is no %s provider configuration with the alias %q. An override file can only override an aliased provider configuration that was already defined in a primary configuration file.", pc.Name, pc.Alias), + Subject: &pc.DeclRange, + }) + continue + } + mergeDiags := existing.merge(pc) + diags = append(diags, mergeDiags...) + } + } + + if len(file.ProviderRequirements) != 0 { + mergeProviderVersionConstraints(m.ProviderRequirements, file.ProviderRequirements) + } + + for _, v := range file.Variables { + existing, exists := m.Variables[v.Name] + if !exists { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Missing base variable declaration to override", + Detail: fmt.Sprintf("There is no variable named %q. An override file can only override a variable that was already declared in a primary configuration file.", v.Name), + Subject: &v.DeclRange, + }) + continue + } + mergeDiags := existing.merge(v) + diags = append(diags, mergeDiags...) + } + + for _, l := range file.Locals { + existing, exists := m.Locals[l.Name] + if !exists { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Missing base local value definition to override", + Detail: fmt.Sprintf("There is no local value named %q. An override file can only override a local value that was already defined in a primary configuration file.", l.Name), + Subject: &l.DeclRange, + }) + continue + } + mergeDiags := existing.merge(l) + diags = append(diags, mergeDiags...) + } + + for _, o := range file.Outputs { + existing, exists := m.Outputs[o.Name] + if !exists { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Missing base output definition to override", + Detail: fmt.Sprintf("There is no output named %q. An override file can only override an output that was already defined in a primary configuration file.", o.Name), + Subject: &o.DeclRange, + }) + continue + } + mergeDiags := existing.merge(o) + diags = append(diags, mergeDiags...) + } + + for _, mc := range file.ModuleCalls { + existing, exists := m.ModuleCalls[mc.Name] + if !exists { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Missing module call to override", + Detail: fmt.Sprintf("There is no module call named %q. An override file can only override a module call that was defined in a primary configuration file.", mc.Name), + Subject: &mc.DeclRange, + }) + continue + } + mergeDiags := existing.merge(mc) + diags = append(diags, mergeDiags...) + } + + for _, r := range file.ManagedResources { + key := r.moduleUniqueKey() + existing, exists := m.ManagedResources[key] + if !exists { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Missing resource to override", + Detail: fmt.Sprintf("There is no %s resource named %q. An override file can only override a resource block defined in a primary configuration file.", r.Type, r.Name), + Subject: &r.DeclRange, + }) + continue + } + mergeDiags := existing.merge(r) + diags = append(diags, mergeDiags...) + } + + for _, r := range file.DataResources { + key := r.moduleUniqueKey() + existing, exists := m.DataResources[key] + if !exists { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Missing data resource to override", + Detail: fmt.Sprintf("There is no %s data resource named %q. An override file can only override a data block defined in a primary configuration file.", r.Type, r.Name), + Subject: &r.DeclRange, + }) + continue + } + mergeDiags := existing.merge(r) + diags = append(diags, mergeDiags...) + } + + return diags +} diff --git a/configs/module_merge.go b/configs/module_merge.go new file mode 100644 index 000000000..d6adc5442 --- /dev/null +++ b/configs/module_merge.go @@ -0,0 +1,212 @@ +package configs + +import ( + "github.com/hashicorp/hcl2/hcl" + "github.com/zclconf/go-cty/cty" +) + +// The methods in this file are used by Module.mergeFile to apply overrides +// to our different configuration elements. These methods all follow the +// pattern of mutating the receiver to incorporate settings from the parameter, +// returning error diagnostics if any aspect of the parameter cannot be merged +// into the receiver for some reason. +// +// User expectation is that anything _explicitly_ set in the given object +// should take precedence over the corresponding settings in the receiver, +// but that anything omitted in the given object should be left unchanged. +// In some cases it may be reasonable to do a "deep merge" of certain nested +// features, if it is possible to unambiguously correlate the nested elements +// and their behaviors are orthogonal to each other. + +func (p *Provider) merge(op *Provider) hcl.Diagnostics { + var diags hcl.Diagnostics + + if op.Version.Required != nil { + p.Version = op.Version + } + + p.Config = mergeBodies(p.Config, op.Config) + + return diags +} + +func mergeProviderVersionConstraints(recv map[string][]VersionConstraint, ovrd []*ProviderRequirement) { + // Any provider name that's mentioned in the override gets nilled out in + // our map so that we'll rebuild it below. Any provider not mentioned is + // left unchanged. + for _, reqd := range ovrd { + delete(recv, reqd.Name) + } + for _, reqd := range ovrd { + recv[reqd.Name] = append(recv[reqd.Name], reqd.Requirement) + } +} + +func (v *Variable) merge(ov *Variable) hcl.Diagnostics { + var diags hcl.Diagnostics + + if ov.Description != "" { + v.Description = ov.Description + } + if ov.Default != cty.NilVal { + v.Default = ov.Default + } + if ov.TypeHint != TypeHintNone { + v.TypeHint = ov.TypeHint + } + + return diags +} + +func (l *Local) merge(ol *Local) hcl.Diagnostics { + var diags hcl.Diagnostics + + // Since a local is just a single expression in configuration, the + // override definition entirely replaces the base definition, including + // the source range so that we'll send the user to the right place if + // there is an error. + l.Expr = ol.Expr + l.DeclRange = ol.DeclRange + + return diags +} + +func (o *Output) merge(oo *Output) hcl.Diagnostics { + var diags hcl.Diagnostics + + if oo.Description != "" { + o.Description = oo.Description + } + if oo.Expr != nil { + o.Expr = oo.Expr + } + if oo.Sensitive { + // Since this is just a bool, we can't distinguish false from unset + // and so the override can only make the output _more_ sensitive. + o.Sensitive = oo.Sensitive + } + + // We don't allow depends_on to be overridden because that is likely to + // cause confusing misbehavior. + if len(oo.DependsOn) != 0 { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Unsupported override", + Detail: "The depends_on argument may not be overridden.", + Subject: oo.DependsOn[0].SourceRange().Ptr(), // the first item is the closest range we have + }) + } + + return diags +} + +func (mc *ModuleCall) merge(omc *ModuleCall) hcl.Diagnostics { + var diags hcl.Diagnostics + + if omc.SourceAddr != "" { + mc.SourceAddr = omc.SourceAddr + mc.SourceAddrRange = omc.SourceAddrRange + } + + if omc.Count != nil { + mc.Count = omc.Count + } + + if omc.ForEach != nil { + mc.ForEach = omc.ForEach + } + + if len(omc.Version.Required) != 0 { + mc.Version = omc.Version + } + + mc.Config = mergeBodies(mc.Config, omc.Config) + + // We don't allow depends_on to be overridden because that is likely to + // cause confusing misbehavior. + if len(mc.DependsOn) != 0 { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Unsupported override", + Detail: "The depends_on argument may not be overridden.", + Subject: mc.DependsOn[0].SourceRange().Ptr(), // the first item is the closest range we have + }) + } + + return diags +} + +func (r *ManagedResource) merge(or *ManagedResource) hcl.Diagnostics { + var diags hcl.Diagnostics + + if or.Connection != nil { + r.Connection = or.Connection + } + if or.Count != nil { + r.Count = or.Count + } + if or.CreateBeforeDestroy { + // We can't distinguish false from unset here + r.CreateBeforeDestroy = or.CreateBeforeDestroy + } + if or.ForEach != nil { + r.ForEach = or.ForEach + } + if len(or.IgnoreChanges) != 0 { + r.IgnoreChanges = or.IgnoreChanges + } + if or.PreventDestroy { + // We can't distinguish false from unset here + r.PreventDestroy = or.PreventDestroy + } + if or.ProviderConfigRef != nil { + r.ProviderConfigRef = or.ProviderConfigRef + } + if len(or.Provisioners) != 0 { + r.Provisioners = or.Provisioners + } + + r.Config = mergeBodies(r.Config, or.Config) + + // We don't allow depends_on to be overridden because that is likely to + // cause confusing misbehavior. + if len(r.DependsOn) != 0 { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Unsupported override", + Detail: "The depends_on argument may not be overridden.", + Subject: r.DependsOn[0].SourceRange().Ptr(), // the first item is the closest range we have + }) + } + + return diags +} + +func (r *DataResource) merge(or *DataResource) hcl.Diagnostics { + var diags hcl.Diagnostics + + if or.Count != nil { + r.Count = or.Count + } + if or.ForEach != nil { + r.ForEach = or.ForEach + } + if or.ProviderConfigRef != nil { + r.ProviderConfigRef = or.ProviderConfigRef + } + + r.Config = mergeBodies(r.Config, or.Config) + + // We don't allow depends_on to be overridden because that is likely to + // cause confusing misbehavior. + if len(r.DependsOn) != 0 { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Unsupported override", + Detail: "The depends_on argument may not be overridden.", + Subject: r.DependsOn[0].SourceRange().Ptr(), // the first item is the closest range we have + }) + } + + return diags +} diff --git a/configs/module_merge_body.go b/configs/module_merge_body.go new file mode 100644 index 000000000..166d20430 --- /dev/null +++ b/configs/module_merge_body.go @@ -0,0 +1,47 @@ +package configs + +import ( + "github.com/hashicorp/hcl2/hcl" +) + +func mergeBodies(base, override hcl.Body) hcl.Body { + return mergeBody{ + Base: base, + Override: override, + } +} + +// mergeBody is a hcl.Body implementation that wraps a pair of other bodies +// and allows attributes and blocks within the override to take precedence +// over those defined in the base body. +// +// This is used to deal with dynamically-processed bodies in Module.mergeFile. +// It uses a shallow-only merging strategy where direct attributes defined +// in Override will override attributes of the same name in Base, while any +// blocks defined in Override will hide all blocks of the same type in Base. +// +// This cannot possibly "do the right thing" in all cases, because we don't +// have enough information about user intent. However, this behavior is intended +// to be reasonable for simple overriding use-cases. +type mergeBody struct { + Base hcl.Body + Override hcl.Body +} + +var _ hcl.Body = mergeBody{} + +func (b mergeBody) Content(schema *hcl.BodySchema) (*hcl.BodyContent, hcl.Diagnostics) { + panic("mergeBody.Content not yet implemented") +} + +func (b mergeBody) PartialContent(schema *hcl.BodySchema) (*hcl.BodyContent, hcl.Body, hcl.Diagnostics) { + panic("mergeBody.Content not yet implemented") +} + +func (b mergeBody) JustAttributes() (hcl.Attributes, hcl.Diagnostics) { + panic("mergeBody.JustAttributes not yet implemented") +} + +func (b mergeBody) MissingItemRange() hcl.Range { + return b.Base.MissingItemRange() +} diff --git a/configs/parser_config_dir.go b/configs/parser_config_dir.go new file mode 100644 index 000000000..57c72b604 --- /dev/null +++ b/configs/parser_config_dir.go @@ -0,0 +1,125 @@ +package configs + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/hashicorp/hcl2/hcl" +) + +// LoadConfigDir reads the .tf and .tf.json files in the given directory +// as config files (using LoadConfigFile) and then combines these files into +// a single Module. +// +// If this method returns nil, that indicates that the given directory does not +// exist at all or could not be opened for some reason. Callers may wish to +// detect this case and ignore the returned diagnostics so that they can +// produce a more context-aware error message in that case. +// +// If this method returns a non-nil module while error diagnostics are returned +// then the module may be incomplete but can be used carefully for static +// analysis. +// +// This file does not consider a directory with no files to be an error, and +// will simply return an empty module in that case. Callers should first call +// Parser.IsConfigDir if they wish to recognize that situation. +// +// .tf files are parsed using the HCL native syntax while .tf.json files are +// parsed using the HCL JSON syntax. +func (p *Parser) LoadConfigDir(path string) (*Module, hcl.Diagnostics) { + primaryPaths, overridePaths, diags := p.dirFiles(path) + if diags.HasErrors() { + return nil, diags + } + + primary, fDiags := p.loadFiles(primaryPaths) + diags = append(diags, fDiags...) + override, fDiags := p.loadFiles(overridePaths) + diags = append(diags, fDiags...) + + mod, modDiags := NewModule(primary, override) + diags = append(diags, modDiags...) + + return mod, diags +} + +// IsConfigDir determines whether the given path refers to a directory that +// exists and contains at least one Terraform config file (with a .tf or +// .tf.json extension.) +func (p *Parser) IsConfigDir(path string) bool { + primaryPaths, overridePaths, _ := p.dirFiles(path) + return (len(primaryPaths) + len(overridePaths)) > 0 +} + +func (p *Parser) loadFiles(paths []string) ([]*File, hcl.Diagnostics) { + var files []*File + var diags hcl.Diagnostics + + for _, path := range paths { + f, fDiags := p.LoadConfigFile(path) + diags = append(diags, fDiags...) + if f != nil { + files = append(files, f) + } + } + + return files, diags +} + +func (p *Parser) dirFiles(dir string) (primary, override []string, diags hcl.Diagnostics) { + infos, err := p.fs.ReadDir(dir) + if err != nil { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Failed to read module directory", + Detail: fmt.Sprintf("Module directory %s does not exist or cannot be read.", dir), + }) + return + } + + for _, info := range infos { + if info.IsDir() { + // We only care about files + continue + } + + name := info.Name() + ext := fileExt(name) + if ext == "" || IsIgnoredFile(name) { + continue + } + + baseName := name[:len(name)-len(ext)] // strip extension + isOverride := baseName == "override" || strings.HasSuffix(baseName, "_override") + + fullPath := filepath.Join(dir, name) + if isOverride { + override = append(override, fullPath) + } else { + primary = append(primary, fullPath) + } + } + + return +} + +// fileExt returns the Terraform configuration extension of the given +// path, or a blank string if it is not a recognized extension. +func fileExt(path string) string { + if strings.HasSuffix(path, ".tf") { + return ".tf" + } else if strings.HasSuffix(path, ".tf.json") { + return ".tf.json" + } else { + return "" + } +} + +// IsIgnoredFile returns true if the given filename (which must not have a +// directory path ahead of it) should be ignored as e.g. an editor swap file. +func IsIgnoredFile(name string) bool { + return strings.HasPrefix(name, ".") || // Unix-like hidden files + strings.HasSuffix(name, "~") || // vim + strings.HasPrefix(name, "#") && strings.HasSuffix(name, "#") // emacs +} diff --git a/configs/parser_config_dir_test.go b/configs/parser_config_dir_test.go new file mode 100644 index 000000000..703fc122d --- /dev/null +++ b/configs/parser_config_dir_test.go @@ -0,0 +1,136 @@ +package configs + +import ( + "fmt" + "io/ioutil" + "path/filepath" + "testing" +) + +// TestParseLoadConfigDirSuccess is a simple test that just verifies that +// a number of test configuration directories (in test-fixtures/valid-modules) +// can be parsed without raising any diagnostics. +// +// It also re-tests the individual files in test-fixtures/valid-files as if +// they were single-file modules, to ensure that they can be bundled into +// modules correctly. +// +// This test does not verify that reading these modules produces the correct +// module element contents. More detailed assertions may be made on some subset +// of these configuration files in other tests. +func TestParserLoadConfigDirSuccess(t *testing.T) { + dirs, err := ioutil.ReadDir("test-fixtures/valid-modules") + if err != nil { + t.Fatal(err) + } + + for _, info := range dirs { + name := info.Name() + t.Run(name, func(t *testing.T) { + parser := NewParser(nil) + path := filepath.Join("test-fixtures/valid-modules", name) + + _, diags := parser.LoadConfigDir(path) + if len(diags) != 0 { + t.Errorf("unexpected diagnostics") + for _, diag := range diags { + t.Logf("- %s", diag) + } + } + }) + } + + // The individual files in test-fixtures/valid-files should also work + // when loaded as modules. + files, err := ioutil.ReadDir("test-fixtures/valid-files") + if err != nil { + t.Fatal(err) + } + + for _, info := range files { + name := info.Name() + t.Run(fmt.Sprintf("%s as module", name), func(t *testing.T) { + src, err := ioutil.ReadFile(filepath.Join("test-fixtures/valid-files", name)) + if err != nil { + t.Fatal(err) + } + + parser := testParser(map[string]string{ + "mod/" + name: string(src), + }) + + _, diags := parser.LoadConfigDir("mod") + if len(diags) != 0 { + t.Errorf("unexpected diagnostics") + for _, diag := range diags { + t.Logf("- %s", diag) + } + } + }) + } + +} + +// TestParseLoadConfigDirFailure is a simple test that just verifies that +// a number of test configuration directories (in test-fixtures/invalid-modules) +// produce diagnostics when parsed. +// +// It also re-tests the individual files in test-fixtures/invalid-files as if +// they were single-file modules, to ensure that their errors are still +// detected when loading as part of a module. +// +// This test does not verify that reading these modules produces any +// diagnostics in particular. More detailed assertions may be made on some subset +// of these configuration files in other tests. +func TestParserLoadConfigDirFailure(t *testing.T) { + dirs, err := ioutil.ReadDir("test-fixtures/invalid-modules") + if err != nil { + t.Fatal(err) + } + + for _, info := range dirs { + name := info.Name() + t.Run(name, func(t *testing.T) { + parser := NewParser(nil) + path := filepath.Join("test-fixtures/invalid-modules", name) + + _, diags := parser.LoadConfigDir(path) + if !diags.HasErrors() { + t.Errorf("no errors; want at least one") + for _, diag := range diags { + t.Logf("- %s", diag) + } + } + }) + } + + // The individual files in test-fixtures/valid-files should also work + // when loaded as modules. + files, err := ioutil.ReadDir("test-fixtures/invalid-files") + if err != nil { + t.Fatal(err) + } + + for _, info := range files { + name := info.Name() + t.Run(fmt.Sprintf("%s as module", name), func(t *testing.T) { + src, err := ioutil.ReadFile(filepath.Join("test-fixtures/invalid-files", name)) + if err != nil { + t.Fatal(err) + } + + parser := testParser(map[string]string{ + "mod/" + name: string(src), + }) + + _, diags := parser.LoadConfigDir("mod") + if !diags.HasErrors() { + t.Errorf("no errors; want at least one") + for _, diag := range diags { + t.Logf("- %s", diag) + } + } + }) + } + +} diff --git a/configs/provider.go b/configs/provider.go index d55874ad3..927490963 100644 --- a/configs/provider.go +++ b/configs/provider.go @@ -57,6 +57,13 @@ func decodeProviderBlock(block *hcl.Block) (*Provider, hcl.Diagnostics) { return provider, diags } +func (p *Provider) moduleUniqueKey() string { + if p.Alias != "" { + return fmt.Sprintf("%s.%s", p.Name, p.Alias) + } + return p.Name +} + // ProviderRequirement represents a declaration of a dependency on a particular // provider version without actually configuring that provider. This is used in // child modules that expect a provider to be passed in from their parent. diff --git a/configs/resource.go b/configs/resource.go index 027d04b2b..b66d12e38 100644 --- a/configs/resource.go +++ b/configs/resource.go @@ -31,6 +31,10 @@ type ManagedResource struct { TypeRange hcl.Range } +func (r *ManagedResource) moduleUniqueKey() string { + return fmt.Sprintf("%s.%s", r.Name, r.Type) +} + func decodeResourceBlock(block *hcl.Block) (*ManagedResource, hcl.Diagnostics) { r := &ManagedResource{ Type: block.Labels[0], @@ -170,6 +174,10 @@ type DataResource struct { TypeRange hcl.Range } +func (r *DataResource) moduleUniqueKey() string { + return fmt.Sprintf("data.%s.%s", r.Name, r.Type) +} + func decodeDataBlock(block *hcl.Block) (*DataResource, hcl.Diagnostics) { r := &DataResource{ Type: block.Labels[0], diff --git a/configs/test-fixtures/invalid-files/json-as-native-syntax.tf b/configs/test-fixtures/invalid-files/json-as-native-syntax.tf new file mode 100644 index 000000000..2e2809093 --- /dev/null +++ b/configs/test-fixtures/invalid-files/json-as-native-syntax.tf @@ -0,0 +1,3 @@ +{ + "terraform": {} +} diff --git a/configs/test-fixtures/invalid-files/native-syntax-as-json.tf.json b/configs/test-fixtures/invalid-files/native-syntax-as-json.tf.json new file mode 100644 index 000000000..ca88e62b4 --- /dev/null +++ b/configs/test-fixtures/invalid-files/native-syntax-as-json.tf.json @@ -0,0 +1,2 @@ +terraform { +} diff --git a/configs/test-fixtures/invalid-modules/override-nonexist-variable/override.tf b/configs/test-fixtures/invalid-modules/override-nonexist-variable/override.tf new file mode 100644 index 000000000..720db27b8 --- /dev/null +++ b/configs/test-fixtures/invalid-modules/override-nonexist-variable/override.tf @@ -0,0 +1,3 @@ +variable "foo" { + description = "overridden" +} diff --git a/configs/test-fixtures/valid-modules/empty/README b/configs/test-fixtures/valid-modules/empty/README new file mode 100644 index 000000000..6d937077a --- /dev/null +++ b/configs/test-fixtures/valid-modules/empty/README @@ -0,0 +1,2 @@ +This directory is intentionally empty, to test what happens when we load +a module that contains no configuration files. diff --git a/configs/test-fixtures/valid-modules/override-variable/a_override.tf b/configs/test-fixtures/valid-modules/override-variable/a_override.tf new file mode 100644 index 000000000..6ec4d1ef3 --- /dev/null +++ b/configs/test-fixtures/valid-modules/override-variable/a_override.tf @@ -0,0 +1,9 @@ +variable "fully_overridden" { + default = "a_override" + description = "a_override description" + type = string +} + +variable "partially_overridden" { + default = "a_override partial" +} diff --git a/configs/test-fixtures/valid-modules/override-variable/b_override.tf b/configs/test-fixtures/valid-modules/override-variable/b_override.tf new file mode 100644 index 000000000..21dbe82e9 --- /dev/null +++ b/configs/test-fixtures/valid-modules/override-variable/b_override.tf @@ -0,0 +1,9 @@ +variable "fully_overridden" { + default = "b_override" + description = "b_override description" + type = string +} + +variable "partially_overridden" { + default = "b_override partial" +} diff --git a/configs/test-fixtures/valid-modules/override-variable/primary.tf b/configs/test-fixtures/valid-modules/override-variable/primary.tf new file mode 100644 index 000000000..981b86b8e --- /dev/null +++ b/configs/test-fixtures/valid-modules/override-variable/primary.tf @@ -0,0 +1,11 @@ +variable "fully_overridden" { + default = "base" + description = "base description" + type = string +} + +variable "partially_overridden" { + default = "base" + description = "base description" + type = string +}