From 85d3439fa061792d04fa70dd9f69d6889f4fb42c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 12 Nov 2016 16:20:33 -0800 Subject: [PATCH 1/4] config: parse and validate terraform.required_version --- config/config.go | 32 ++++++++++++ config/config_test.go | 21 ++++++++ config/loader_hcl.go | 50 ++++++++++++++++--- config/loader_test.go | 5 ++ config/test-fixtures/basic.tf | 4 ++ .../validate-bad-tf-version/main.tf | 3 ++ .../validate-tf-version-interp/main.tf | 3 ++ .../test-fixtures/validate-tf-version/main.tf | 3 ++ 8 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 config/test-fixtures/validate-bad-tf-version/main.tf create mode 100644 config/test-fixtures/validate-tf-version-interp/main.tf create mode 100644 config/test-fixtures/validate-tf-version/main.tf diff --git a/config/config.go b/config/config.go index 3c8f8826d..4000eb077 100644 --- a/config/config.go +++ b/config/config.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/hashicorp/go-multierror" + "github.com/hashicorp/go-version" "github.com/hashicorp/hil" "github.com/hashicorp/hil/ast" "github.com/hashicorp/terraform/helper/hilmapstructure" @@ -27,6 +28,7 @@ type Config struct { // any meaningful directory. Dir string + Terraform *Terraform Atlas *AtlasConfig Modules []*Module ProviderConfigs []*ProviderConfig @@ -39,6 +41,12 @@ 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 @@ -236,6 +244,30 @@ func (c *Config) Validate() error { "Unknown root level key: %s", k)) } + // Validate the Terraform config + if tf := c.Terraform; tf != nil { + if raw := tf.RequiredVersion; raw != "" { + // Check that the value has no interpolations + rc, err := NewRawConfig(map[string]interface{}{ + "root": raw, + }) + if err != nil { + errs = append(errs, fmt.Errorf( + "terraform.required_version: %s", err)) + } else if len(rc.Interpolations) > 0 { + errs = append(errs, fmt.Errorf( + "terraform.required_version: cannot contain interpolations")) + } else { + // Check it is valid + _, err := version.NewConstraint(raw) + if err != nil { + errs = append(errs, fmt.Errorf( + "terraform.required_version: invalid syntax: %s", err)) + } + } + } + } + vars := c.InterpolatedVariables() varMap := make(map[string]*Variable) for _, v := range c.Variables { diff --git a/config/config_test.go b/config/config_test.go index 7c7776b94..a0078671b 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -137,6 +137,27 @@ func TestConfigValidate(t *testing.T) { } } +func TestConfigValidate_tfVersion(t *testing.T) { + c := testConfig(t, "validate-tf-version") + if err := c.Validate(); err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestConfigValidate_tfVersionBad(t *testing.T) { + c := testConfig(t, "validate-bad-tf-version") + if err := c.Validate(); err == nil { + t.Fatal("should not be valid") + } +} + +func TestConfigValidate_tfVersionInterpolations(t *testing.T) { + c := testConfig(t, "validate-tf-version-interp") + if err := c.Validate(); err == nil { + t.Fatal("should not be valid") + } +} + func TestConfigValidate_badDependsOn(t *testing.T) { c := testConfig(t, "validate-bad-depends-on") if err := c.Validate(); err == nil { diff --git a/config/loader_hcl.go b/config/loader_hcl.go index 2122d622e..f6ef24a7b 100644 --- a/config/loader_hcl.go +++ b/config/loader_hcl.go @@ -19,13 +19,14 @@ type hclConfigurable struct { func (t *hclConfigurable) Config() (*Config, error) { validKeys := map[string]struct{}{ - "atlas": struct{}{}, - "data": struct{}{}, - "module": struct{}{}, - "output": struct{}{}, - "provider": struct{}{}, - "resource": struct{}{}, - "variable": struct{}{}, + "atlas": struct{}{}, + "data": struct{}{}, + "module": struct{}{}, + "output": struct{}{}, + "provider": struct{}{}, + "resource": struct{}{}, + "terraform": struct{}{}, + "variable": struct{}{}, } // Top-level item should be the object list @@ -37,6 +38,15 @@ func (t *hclConfigurable) Config() (*Config, error) { // Start building up the actual configuration. config := new(Config) + // Terraform config + if o := list.Filter("terraform"); len(o.Items) > 0 { + var err error + config.Terraform, err = loadTerraformHcl(o) + if err != nil { + return nil, err + } + } + // Build the variables if vars := list.Filter("variable"); len(vars.Items) > 0 { var err error @@ -190,6 +200,32 @@ func loadFileHcl(root string) (configurable, []string, error) { return result, nil, nil } +// Given a handle to a HCL object, this transforms it into the Terraform config +func loadTerraformHcl(list *ast.ObjectList) (*Terraform, error) { + if len(list.Items) > 1 { + return nil, fmt.Errorf("only one 'terraform' block allowed per module") + } + + // Get our one item + item := list.Items[0] + + // 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). + // + // We should still keep track of unknown keys to validate later, but + // HCL doesn't currently support that. + + var config Terraform + if err := hcl.DecodeObject(&config, item.Val); err != nil { + return nil, fmt.Errorf( + "Error reading terraform config: %s", + err) + } + + return &config, 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 73b09a6fe..8c0dfaa6d 100644 --- a/config/loader_test.go +++ b/config/loader_test.go @@ -160,6 +160,11 @@ func TestLoadFileBasic(t *testing.T) { t.Fatalf("bad: %#v", c.Dir) } + expectedTF := &Terraform{RequiredVersion: "foo"} + if !reflect.DeepEqual(c.Terraform, expectedTF) { + t.Fatalf("bad: %#v", c.Terraform) + } + expectedAtlas := &AtlasConfig{Name: "mitchellh/foo"} if !reflect.DeepEqual(c.Atlas, expectedAtlas) { t.Fatalf("bad: %#v", c.Atlas) diff --git a/config/test-fixtures/basic.tf b/config/test-fixtures/basic.tf index 45314d54b..aa5a5c6ed 100644 --- a/config/test-fixtures/basic.tf +++ b/config/test-fixtures/basic.tf @@ -1,3 +1,7 @@ +terraform { + required_version = "foo" +} + variable "foo" { default = "bar" description = "bar" diff --git a/config/test-fixtures/validate-bad-tf-version/main.tf b/config/test-fixtures/validate-bad-tf-version/main.tf new file mode 100644 index 000000000..47348a350 --- /dev/null +++ b/config/test-fixtures/validate-bad-tf-version/main.tf @@ -0,0 +1,3 @@ +terraform { + required_version = "nope" +} diff --git a/config/test-fixtures/validate-tf-version-interp/main.tf b/config/test-fixtures/validate-tf-version-interp/main.tf new file mode 100644 index 000000000..51e68adb3 --- /dev/null +++ b/config/test-fixtures/validate-tf-version-interp/main.tf @@ -0,0 +1,3 @@ +terraform { + required_version = "${var.foo}" +} diff --git a/config/test-fixtures/validate-tf-version/main.tf b/config/test-fixtures/validate-tf-version/main.tf new file mode 100644 index 000000000..9bbc92303 --- /dev/null +++ b/config/test-fixtures/validate-tf-version/main.tf @@ -0,0 +1,3 @@ +terraform { + required_version = "> 0.7.0" +} From 2c467e0f7481eca92c76bbaaba67e7895d2040d1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 12 Nov 2016 16:50:26 -0800 Subject: [PATCH 2/4] terraform: verify version requirements from configuration --- terraform/context.go | 7 ++ terraform/context_test.go | 78 +++++++++++++++++++ .../child/main.tf | 1 + .../context-required-version-module/main.tf | 3 + .../context-required-version/main.tf | 1 + terraform/version_required.go | 69 ++++++++++++++++ 6 files changed, 159 insertions(+) create mode 100644 terraform/test-fixtures/context-required-version-module/child/main.tf create mode 100644 terraform/test-fixtures/context-required-version-module/main.tf create mode 100644 terraform/test-fixtures/context-required-version/main.tf create mode 100644 terraform/version_required.go diff --git a/terraform/context.go b/terraform/context.go index 7720bd8ac..ffd85535b 100644 --- a/terraform/context.go +++ b/terraform/context.go @@ -102,6 +102,13 @@ type Context struct { // should not be mutated in any way, since the pointers are copied, not // the values themselves. func NewContext(opts *ContextOpts) (*Context, error) { + // Validate the version requirement if it is given + if opts.Module != nil { + if err := checkRequiredVersion(opts.Module); err != nil { + return nil, err + } + } + // Copy all the hooks and add our stop hook. We don't append directly // to the Config so that we're not modifying that in-place. sh := new(stopHook) diff --git a/terraform/context_test.go b/terraform/context_test.go index f849d21dc..977b9e578 100644 --- a/terraform/context_test.go +++ b/terraform/context_test.go @@ -6,9 +6,87 @@ import ( "testing" "time" + "github.com/hashicorp/go-version" "github.com/hashicorp/terraform/flatmap" ) +func TestNewContextRequiredVersion(t *testing.T) { + cases := []struct { + Name string + Module string + Version string + Value string + Err bool + }{ + { + "no requirement", + "", + "0.1.0", + "", + false, + }, + + { + "doesn't match", + "", + "0.1.0", + "> 0.6.0", + true, + }, + + { + "matches", + "", + "0.7.0", + "> 0.6.0", + false, + }, + + { + "module matches", + "context-required-version-module", + "0.5.0", + "", + false, + }, + + { + "module doesn't match", + "context-required-version-module", + "0.4.0", + "", + true, + }, + } + + for i, tc := range cases { + t.Run(fmt.Sprintf("%d-%s", i, tc.Name), func(t *testing.T) { + // Reset the version for the tests + old := SemVersion + SemVersion = version.Must(version.NewVersion(tc.Version)) + defer func() { SemVersion = old }() + + name := "context-required-version" + if tc.Module != "" { + name = tc.Module + } + mod := testModule(t, name) + if tc.Value != "" { + mod.Config().Terraform.RequiredVersion = tc.Value + } + _, err := NewContext(&ContextOpts{ + Module: mod, + }) + if (err != nil) != tc.Err { + t.Fatalf("err: %s", err) + } + if err != nil { + return + } + }) + } +} + func TestNewContextState(t *testing.T) { cases := map[string]struct { Input *ContextOpts diff --git a/terraform/test-fixtures/context-required-version-module/child/main.tf b/terraform/test-fixtures/context-required-version-module/child/main.tf new file mode 100644 index 000000000..8d915e4b7 --- /dev/null +++ b/terraform/test-fixtures/context-required-version-module/child/main.tf @@ -0,0 +1 @@ +terraform { required_version = ">= 0.5.0" } diff --git a/terraform/test-fixtures/context-required-version-module/main.tf b/terraform/test-fixtures/context-required-version-module/main.tf new file mode 100644 index 000000000..0f6991c53 --- /dev/null +++ b/terraform/test-fixtures/context-required-version-module/main.tf @@ -0,0 +1,3 @@ +module "child" { + source = "./child" +} diff --git a/terraform/test-fixtures/context-required-version/main.tf b/terraform/test-fixtures/context-required-version/main.tf new file mode 100644 index 000000000..75db79290 --- /dev/null +++ b/terraform/test-fixtures/context-required-version/main.tf @@ -0,0 +1 @@ +terraform {} diff --git a/terraform/version_required.go b/terraform/version_required.go new file mode 100644 index 000000000..3cbbf5608 --- /dev/null +++ b/terraform/version_required.go @@ -0,0 +1,69 @@ +package terraform + +import ( + "fmt" + + "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform/config" + "github.com/hashicorp/terraform/config/module" +) + +// checkRequiredVersion verifies that any version requirements specified by +// the configuration are met. +// +// This checks the root module as well as any additional version requirements +// from child modules. +// +// This is tested in context_test.go. +func checkRequiredVersion(m *module.Tree) error { + // Check any children + for _, c := range m.Children() { + if err := checkRequiredVersion(c); err != nil { + return err + } + } + + var tf *config.Terraform + if c := m.Config(); c != nil { + tf = c.Terraform + } + + // If there is no Terraform config or the required version isn't set, + // we move on. + if tf == nil || tf.RequiredVersion == "" { + return nil + } + + // Path for errors + module := "root" + if path := normalizeModulePath(m.Path()); len(path) > 1 { + module = modulePrefixStr(path) + } + + // Check this version requirement of this module + cs, err := version.NewConstraint(tf.RequiredVersion) + if err != nil { + return fmt.Errorf( + "%s: terraform.required_version %q syntax error: %s", + module, + tf.RequiredVersion, err) + } + + if !cs.Check(SemVersion) { + return fmt.Errorf( + "The currently running version of Terraform doesn't meet the\n"+ + "version requirements explicitly specified by the configuration.\n"+ + "Please use the required version or update the configuration.\n"+ + "Note that version requirements are usually set for a reason, so\n"+ + "we recommend verifying with whoever set the version requirements\n"+ + "prior to making any manual changes.\n\n"+ + " Module: %s\n"+ + " Required version: %s\n"+ + " Current version: %s", + module, + tf.RequiredVersion, + SemVersion) + } + + return nil +} From 0e2e19c7846f887a69aaf4f461e82c3610902a92 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 12 Nov 2016 22:50:28 -0800 Subject: [PATCH 3/4] website: document required_version --- .../docs/configuration/terraform.html.md | 77 +++++++++++++++++++ website/source/layouts/docs.erb | 4 + 2 files changed, 81 insertions(+) create mode 100644 website/source/docs/configuration/terraform.html.md diff --git a/website/source/docs/configuration/terraform.html.md b/website/source/docs/configuration/terraform.html.md new file mode 100644 index 000000000..5effde086 --- /dev/null +++ b/website/source/docs/configuration/terraform.html.md @@ -0,0 +1,77 @@ +--- +layout: "docs" +page_title: "Configuring Terraform" +sidebar_current: "docs-config-terraform" +description: |- + Atlas is the ideal way to use Terraform in a team environment. Atlas will run Terraform for you, safely handle parallelization across different team members, save run history along with plans, and more. +--- + +# Terraform Configuration + +The `terraform` configuration section is used to configure Terraform itself, +such as requiring a minimum Terraform version to execute a configuration. + +This page assumes you're familiar with the +[configuration syntax](/docs/configuration/syntax.html) +already. + +## Example + +Terraform configuration looks like the following: + +``` +terraform { + required_version = "> 0.7.0" +} +``` + +## Description + +The `terraform` block configures the behavior of Terraform itself. + +The currently only allowed configuration within this block is +`required_version`. This setting specifies a set of version constraints +that must me bet to perform operations on this configuration. If the +running Terraform version doesn't meet these constraints, an error +is shown. See the section below dedicated to this option. + +**No value within the `terraform` block can use interpolations.** The +`terraform` block is loaded very early in the execution of Terraform +and interpolations are not yet available. + +## Specifying a Required Terraform Version + +The `required_version` setting can be used to require a specific version +of Terraform. If the running version of Terraform doesn't match the +constraints specified, Terraform will show an error and exit. + +When [modules](/docs/configuration/modules.html) are used, all Terraform +version requirements specified by the complete module tree must be +satisified. This means that the `required_version` setting can be used +by a module to require that all consumers of a module also use a specific +version. + +The value of this configuration is a comma-separated list of constraints. +A constraint is an operator followed by a version, such as `> 0.7.0`. +Constraints support the following operations: + + * `=` (or no operator): exact version equality + * `!=`: version not equal + * `>`, `>=`, `<`, `<=`: version comparison, where "greater than" is + a larger version number. + * `~>`: pessimistic constraint operator. Example: for `~> 0.9`, this means + `>= 0.9, < 1.0`. Example: for `~> 0.8.4`, this means `>= 0.8.4, < 0.9` + +For modules, a minimum version is recommended, such as `> 0.8.0`. This +minimum version ensures that a module operates as expected, but gives +the consumer flexibility to use newer versions. + +## Syntax + +The full syntax is: + +``` +terraform { + required_version = VALUE +} +``` diff --git a/website/source/layouts/docs.erb b/website/source/layouts/docs.erb index e82527976..664670302 100644 --- a/website/source/layouts/docs.erb +++ b/website/source/layouts/docs.erb @@ -49,6 +49,10 @@ Modules + > + Terraform + + > Atlas From 15f07e0e50ffbf65ae7869cdff5a90c79e3d745e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 12 Nov 2016 22:52:27 -0800 Subject: [PATCH 4/4] website: correct description for Terraform section --- website/source/docs/configuration/terraform.html.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/source/docs/configuration/terraform.html.md b/website/source/docs/configuration/terraform.html.md index 5effde086..6a4078e32 100644 --- a/website/source/docs/configuration/terraform.html.md +++ b/website/source/docs/configuration/terraform.html.md @@ -3,7 +3,7 @@ layout: "docs" page_title: "Configuring Terraform" sidebar_current: "docs-config-terraform" description: |- - Atlas is the ideal way to use Terraform in a team environment. Atlas will run Terraform for you, safely handle parallelization across different team members, save run history along with plans, and more. + The `terraform` configuration section is used to configure Terraform itself, such as requiring a minimum Terraform version to execute a configuration. --- # Terraform Configuration