diff --git a/config/config.go b/config/config.go index 724a78144..80db548a7 100644 --- a/config/config.go +++ b/config/config.go @@ -41,12 +41,6 @@ type Config struct { unknownKeys []string } -// Terraform is the Terraform meta-configuration that can be present -// in configuration files for configuring Terraform itself. -type Terraform struct { - RequiredVersion string `hcl:"required_version"` // Required Terraform version (constraint) -} - // AtlasConfig is the configuration for building in HashiCorp's Atlas. type AtlasConfig struct { Name string diff --git a/config/config_string.go b/config/config_string.go index a5ef7d5cd..0b3abbcd5 100644 --- a/config/config_string.go +++ b/config/config_string.go @@ -50,6 +50,26 @@ func (c *Config) TestString() string { return strings.TrimSpace(buf.String()) } +func terraformStr(t *Terraform) string { + result := "" + + if b := t.Backend; b != nil { + result += fmt.Sprintf("backend (%s)\n", b.Type) + + keys := make([]string, 0, len(b.RawConfig.Raw)) + for k, _ := range b.RawConfig.Raw { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + result += fmt.Sprintf(" %s\n", k) + } + } + + return strings.TrimSpace(result) +} + func modulesStr(ms []*Module) string { result := "" order := make([]int, 0, len(ms)) diff --git a/config/config_terraform.go b/config/config_terraform.go new file mode 100644 index 000000000..272fa62c8 --- /dev/null +++ b/config/config_terraform.go @@ -0,0 +1,48 @@ +package config + +import ( + "github.com/mitchellh/hashstructure" +) + +// Terraform is the Terraform meta-configuration that can be present +// in configuration files for configuring Terraform itself. +type Terraform struct { + RequiredVersion string `hcl:"required_version"` // Required Terraform version (constraint) + Backend *Backend // See Backend struct docs +} + +// Backend is the configuration for the "backend" to use with Terraform. +// A backend is responsible for all major behavior of Terraform's core. +// The abstraction layer above the core (the "backend") allows for behavior +// such as remote operation. +type Backend struct { + Type string + RawConfig *RawConfig + + // Hash is a unique hash code representing the original configuration + // of the backend. This won't be recomputed unless Rehash is called. + Hash uint64 +} + +// Hash returns a unique content hash for this backend's configuration +// as a uint64 value. +func (b *Backend) Rehash() uint64 { + // If we have no backend, the value is zero + if b == nil { + return 0 + } + + // Use hashstructure to hash only our type with the config. + code, err := hashstructure.Hash(map[string]interface{}{ + "type": b.Type, + "config": b.RawConfig.Raw, + }, nil) + + // This should never happen since we have just some basic primitives + // so panic if there is an error. + if err != nil { + panic(err) + } + + return code +} diff --git a/config/config_terraform_test.go b/config/config_terraform_test.go new file mode 100644 index 000000000..e0da63e9d --- /dev/null +++ b/config/config_terraform_test.go @@ -0,0 +1,55 @@ +package config + +import ( + "fmt" + "testing" +) + +func TestBackendHash(t *testing.T) { + // WARNING: The codes below should _never_ change. If they change, it + // means that a future TF version may falsely recognize unchanged backend + // configuration as changed. Ultimately this should have no adverse + // affect but it is annoying for users and should be avoided if possible. + + cases := []struct { + Name string + Fixture string + Code uint64 + }{ + { + "no backend config", + "backend-hash-empty", + 0, + }, + + { + "backend config with only type", + "backend-hash-type-only", + 17852588448730441876, + }, + + { + "backend config with type and config", + "backend-hash-basic", + 10288498853650209002, + }, + } + + for i, tc := range cases { + t.Run(fmt.Sprintf("%d-%s", i, tc.Name), func(t *testing.T) { + c := testConfig(t, tc.Fixture) + err := c.Validate() + if err != nil { + t.Fatalf("err: %s", err) + } + + var actual uint64 + if c.Terraform != nil && c.Terraform.Backend != nil { + actual = c.Terraform.Backend.Hash + } + if actual != tc.Code { + t.Fatalf("bad: %d != %d", actual, tc.Code) + } + }) + } +} diff --git a/config/loader_hcl.go b/config/loader_hcl.go index dfadce9d6..8e0d62c7b 100644 --- a/config/loader_hcl.go +++ b/config/loader_hcl.go @@ -209,6 +209,14 @@ func loadTerraformHcl(list *ast.ObjectList) (*Terraform, error) { // Get our one item item := list.Items[0] + // We need the item value as an ObjectList + var listVal *ast.ObjectList + if ot, ok := item.Val.(*ast.ObjectType); ok { + listVal = ot.List + } else { + return nil, fmt.Errorf("terraform block: should be an object") + } + // NOTE: We purposely don't validate unknown HCL keys here so that // we can potentially read _future_ Terraform version config (to // still be able to validate the required version). @@ -223,9 +231,62 @@ func loadTerraformHcl(list *ast.ObjectList) (*Terraform, error) { err) } + // If we have provisioners, then parse those out + if os := listVal.Filter("backend"); len(os.Items) > 0 { + var err error + config.Backend, err = loadTerraformBackendHcl(os) + if err != nil { + return nil, fmt.Errorf( + "Error reading backend config for terraform block: %s", + err) + } + } + return &config, nil } +// Loads the Backend configuration from an object list. +func loadTerraformBackendHcl(list *ast.ObjectList) (*Backend, error) { + if len(list.Items) > 1 { + return nil, fmt.Errorf("only one 'backend' block allowed") + } + + // Get our one item + item := list.Items[0] + + // Verify the keys + if len(item.Keys) != 1 { + return nil, fmt.Errorf( + "position %s: 'backend' must be followed by exactly one string: a type", + item.Pos()) + } + + typ := item.Keys[0].Token.Value().(string) + + // Decode the raw config + var config map[string]interface{} + if err := hcl.DecodeObject(&config, item.Val); err != nil { + return nil, fmt.Errorf( + "Error reading backend config: %s", + err) + } + + rawConfig, err := NewRawConfig(config) + if err != nil { + return nil, fmt.Errorf( + "Error reading backend config: %s", + err) + } + + b := &Backend{ + Type: typ, + RawConfig: rawConfig, + } + b.Hash = b.Rehash() + + return b, nil +} + // Given a handle to a HCL object, this transforms it into the Atlas // configuration. func loadAtlasHcl(list *ast.ObjectList) (*AtlasConfig, error) { diff --git a/config/loader_test.go b/config/loader_test.go index 80c0d8d1f..f49ce48c1 100644 --- a/config/loader_test.go +++ b/config/loader_test.go @@ -334,6 +334,43 @@ func TestLoadFile_outputDependsOn(t *testing.T) { } } +func TestLoadFile_terraformBackend(t *testing.T) { + c, err := LoadFile(filepath.Join(fixtureDir, "terraform-backend.tf")) + if err != nil { + t.Fatalf("err: %s", err) + } + + if c == nil { + t.Fatal("config should not be nil") + } + + if c.Dir != "" { + t.Fatalf("bad: %#v", c.Dir) + } + + { + actual := terraformStr(c.Terraform) + expected := strings.TrimSpace(` +backend (s3) + foo`) + if actual != expected { + t.Fatalf("bad:\n%s", actual) + } + } +} + +func TestLoadFile_terraformBackendMulti(t *testing.T) { + _, err := LoadFile(filepath.Join(fixtureDir, "terraform-backend-multi.tf")) + if err == nil { + t.Fatal("expected error") + } + + errorStr := err.Error() + if !strings.Contains(errorStr, "only one 'backend'") { + t.Fatalf("bad: expected error has wrong text: %s", errorStr) + } +} + func TestLoadJSONBasic(t *testing.T) { raw, err := ioutil.ReadFile(filepath.Join(fixtureDir, "basic.tf.json")) if err != nil { diff --git a/config/module/testing.go b/config/module/testing.go new file mode 100644 index 000000000..fc9e7331a --- /dev/null +++ b/config/module/testing.go @@ -0,0 +1,38 @@ +package module + +import ( + "io/ioutil" + "os" + "testing" + + "github.com/hashicorp/go-getter" +) + +// TestTree loads a module at the given path and returns the tree as well +// as a function that should be deferred to clean up resources. +func TestTree(t *testing.T, path string) (*Tree, func()) { + // Create a temporary directory for module storage + dir, err := ioutil.TempDir("", "tf") + if err != nil { + t.Fatalf("err: %s", err) + return nil, nil + } + + // Load the module + mod, err := NewTreeModule("", path) + if err != nil { + t.Fatalf("err: %s", err) + return nil, nil + } + + // Get the child modules + s := &getter.FolderStorage{StorageDir: dir} + if err := mod.Load(s, GetModeGet); err != nil { + t.Fatalf("err: %s", err) + return nil, nil + } + + return mod, func() { + os.RemoveAll(dir) + } +} diff --git a/config/test-fixtures/backend-hash-basic/main.tf b/config/test-fixtures/backend-hash-basic/main.tf new file mode 100644 index 000000000..cf0fdfc1f --- /dev/null +++ b/config/test-fixtures/backend-hash-basic/main.tf @@ -0,0 +1,7 @@ +terraform { + backend "foo" { + foo = "bar" + bar = ["baz"] + map = { a = "b" } + } +} diff --git a/config/test-fixtures/backend-hash-empty/main.tf b/config/test-fixtures/backend-hash-empty/main.tf new file mode 100644 index 000000000..75db79290 --- /dev/null +++ b/config/test-fixtures/backend-hash-empty/main.tf @@ -0,0 +1 @@ +terraform {} diff --git a/config/test-fixtures/backend-hash-no-terraform/main.tf b/config/test-fixtures/backend-hash-no-terraform/main.tf new file mode 100644 index 000000000..b7db25411 --- /dev/null +++ b/config/test-fixtures/backend-hash-no-terraform/main.tf @@ -0,0 +1 @@ +# Empty diff --git a/config/test-fixtures/backend-hash-type-only/main.tf b/config/test-fixtures/backend-hash-type-only/main.tf new file mode 100644 index 000000000..ea3d6a3bf --- /dev/null +++ b/config/test-fixtures/backend-hash-type-only/main.tf @@ -0,0 +1,4 @@ +terraform { + backend "foo" { + } +} diff --git a/config/test-fixtures/terraform-backend-multi.tf b/config/test-fixtures/terraform-backend-multi.tf new file mode 100644 index 000000000..777ba5550 --- /dev/null +++ b/config/test-fixtures/terraform-backend-multi.tf @@ -0,0 +1,4 @@ +terraform { + backend "s3" {} + backend "s4" {} +} diff --git a/config/test-fixtures/terraform-backend.tf b/config/test-fixtures/terraform-backend.tf new file mode 100644 index 000000000..44187c8d0 --- /dev/null +++ b/config/test-fixtures/terraform-backend.tf @@ -0,0 +1,5 @@ +terraform { + backend "s3" { + foo = "bar" + } +}