Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow parsing module from any filesystem #49

Merged
merged 2 commits into from
Aug 6, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions tfconfig/filesystem.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package tfconfig

import (
"io/ioutil"
"os"
)

// FS represents a minimal filesystem implementation
// See io/fs.FS in http://golang.org/s/draft-iofs-design
type FS interface {
Open(name string) (File, error)
ReadFile(name string) ([]byte, error)
ReadDir(dirname string) ([]os.FileInfo, error)
}

// File represents an open file in FS
// See io/fs.File in http://golang.org/s/draft-iofs-design
type File interface {
Stat() (os.FileInfo, error)
Read([]byte) (int, error)
Close() error
}

type osFs struct{}

func (fs *osFs) Open(name string) (File, error) {
return os.Open(name)
}

func (fs *osFs) ReadFile(name string) ([]byte, error) {
return ioutil.ReadFile(name)
}

func (fs *osFs) ReadDir(dirname string) ([]os.FileInfo, error) {
return ioutil.ReadDir(dirname)
}

// NewOsFs provides a basic implementation of FS for an OS filesystem
func NewOsFs() FS {
return &osFs{}
}
23 changes: 17 additions & 6 deletions tfconfig/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package tfconfig

import (
"fmt"
"io/ioutil"
"path/filepath"
"strings"

Expand All @@ -12,18 +11,23 @@ import (
// LoadModule reads the directory at the given path and attempts to interpret
// it as a Terraform module.
func LoadModule(dir string) (*Module, Diagnostics) {
return LoadModuleFromFilesystem(NewOsFs(), dir)
}

// LoadModuleFromFilesystem reads the directory at the given path
// in the given FS and attempts to interpret it as a Terraform module
func LoadModuleFromFilesystem(fs FS, dir string) (*Module, Diagnostics) {
// For broad compatibility here we actually have two separate loader
// codepaths. The main one uses the new HCL parser and API and is intended
// for configurations from Terraform 0.12 onwards (though will work for
// many older configurations too), but we'll also fall back on one that
// uses the _old_ HCL implementation so we can deal with some edge-cases
// that are not valid in new HCL.

module, diags := loadModule(dir)
module, diags := loadModule(fs, dir)
if diags.HasErrors() {
// Try using the legacy HCL parser and see if we fare better.
legacyModule, legacyDiags := loadModuleLegacyHCL(dir)
legacyModule, legacyDiags := loadModuleLegacyHCL(fs, dir)
if !legacyDiags.HasErrors() {
legacyModule.init(legacyDiags)
return legacyModule, legacyDiags
Expand All @@ -37,7 +41,14 @@ func LoadModule(dir string) (*Module, Diagnostics) {
// IsModuleDir checks if the given path contains terraform configuration files.
// This allows the caller to decide how to handle directories that do not have tf files.
func IsModuleDir(dir string) bool {
primaryPaths, _ := dirFiles(dir)
return IsModuleDirOnFilesystem(NewOsFs(), dir)
}

// IsModuleDirOnFilesystem checks if the given path in the given FS contains
// Terraform configuration files. This allows the caller to decide
// how to handle directories that do not have tf files.
func IsModuleDirOnFilesystem(fs FS, dir string) bool {
primaryPaths, _ := dirFiles(fs, dir)
if len(primaryPaths) == 0 {
return false
}
Expand Down Expand Up @@ -67,8 +78,8 @@ func (m *Module) init(diags Diagnostics) {
m.Diagnostics = diags
}

func dirFiles(dir string) (primary []string, diags hcl.Diagnostics) {
infos, err := ioutil.ReadDir(dir)
func dirFiles(fs FS, dir string) (primary []string, diags hcl.Diagnostics) {
infos, err := fs.ReadDir(dir)
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Expand Down
18 changes: 14 additions & 4 deletions tfconfig/load_hcl.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,29 @@ import (
ctyjson "github.com/zclconf/go-cty/cty/json"
)

func loadModule(dir string) (*Module, Diagnostics) {
func loadModule(fs FS, dir string) (*Module, Diagnostics) {
mod := newModule(dir)
primaryPaths, diags := dirFiles(dir)
primaryPaths, diags := dirFiles(fs, dir)

parser := hclparse.NewParser()

for _, filename := range primaryPaths {
var file *hcl.File
var fileDiags hcl.Diagnostics

b, err := fs.ReadFile(filename)
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Failed to read file",
Detail: fmt.Sprintf("The configuration file %q could not be read.", filename),
})
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error matches the one from ParseHCLFile: https://github.com/hashicorp/hcl/blob/350d663f3c09e5a6d72ca11cb3250c1ce395bc8f/hclparse/parser.go#L70-L79

There is a small drift from ParseJSONFile in that json.ParseFile first opens the file and reports early diagnostic from there. This difference seems insignificant to me, but I'm open to retrofitting the extra Open call with that extra dedicated diagnostic here if deemed necessary.

continue
}
if strings.HasSuffix(filename, ".json") {
file, fileDiags = parser.ParseJSONFile(filename)
file, fileDiags = parser.ParseJSON(b, filename)
} else {
file, fileDiags = parser.ParseHCLFile(filename)
file, fileDiags = parser.ParseHCL(b, filename)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These original Parse*File methods also provided "cache" of AST for each file which this change effectively removes.

It's just filename-based cache and so in order to get a hit we'd need two duplicate files in a directory, which is unlikely to ever happen anyway, so this change should in that sense be harmless and shouldn't affect performance in any way.

}
diags = append(diags, fileDiags...)
if file == nil {
Expand Down
9 changes: 4 additions & 5 deletions tfconfig/load_legacy.go
Original file line number Diff line number Diff line change
@@ -1,28 +1,27 @@
package tfconfig

import (
"io/ioutil"
"strings"

legacyhcl "github.com/hashicorp/hcl"
legacyast "github.com/hashicorp/hcl/hcl/ast"
)

func loadModuleLegacyHCL(dir string) (*Module, Diagnostics) {
func loadModuleLegacyHCL(fs FS, dir string) (*Module, Diagnostics) {
// This implementation is intentionally more quick-and-dirty than the
// main loader. In particular, it doesn't bother to keep careful track
// of multiple error messages because we always fall back on returning
// the main parser's error message if our fallback parsing produces
// an error, and thus the errors here are not seen by the end-caller.
mod := newModule(dir)

primaryPaths, diags := dirFiles(dir)
primaryPaths, diags := dirFiles(fs, dir)
if diags.HasErrors() {
return mod, diagnosticsHCL(diags)
}

for _, filename := range primaryPaths {
src, err := ioutil.ReadFile(filename)
src, err := fs.ReadFile(filename)
if err != nil {
return mod, diagnosticsErrorf("Error reading %s: %s", filename, err)
}
Expand Down Expand Up @@ -320,7 +319,7 @@ func unwrapLegacyHCLObjectKeysFromJSON(item *legacyast.ObjectItem, depth int) {
item.Val = &legacyast.ObjectType{
List: &legacyast.ObjectList{
Items: []*legacyast.ObjectItem{
&legacyast.ObjectItem{
{
Keys: []*legacyast.ObjectKey{key},
Val: item.Val,
},
Expand Down