Merge pull request #10080 from hashicorp/f-tf-version

terraform: support version requirement in configuration
This commit is contained in:
Mitchell Hashimoto 2016-11-14 11:53:30 -08:00 committed by GitHub
commit 25d19ef3d0
16 changed files with 354 additions and 7 deletions

View File

@ -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
@ -237,6 +245,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 {

View File

@ -182,6 +182,27 @@ func TestConfigValidate_table(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 {

View File

@ -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) {

View File

@ -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)

View File

@ -1,3 +1,7 @@
terraform {
required_version = "foo"
}
variable "foo" {
default = "bar"
description = "bar"

View File

@ -0,0 +1,3 @@
terraform {
required_version = "nope"
}

View File

@ -0,0 +1,3 @@
terraform {
required_version = "${var.foo}"
}

View File

@ -0,0 +1,3 @@
terraform {
required_version = "> 0.7.0"
}

View File

@ -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)

View File

@ -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

View File

@ -0,0 +1 @@
terraform { required_version = ">= 0.5.0" }

View File

@ -0,0 +1,3 @@
module "child" {
source = "./child"
}

View File

@ -0,0 +1 @@
terraform {}

View File

@ -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
}

View File

@ -0,0 +1,77 @@
---
layout: "docs"
page_title: "Configuring Terraform"
sidebar_current: "docs-config-terraform"
description: |-
The `terraform` configuration section is used to configure Terraform itself, such as requiring a minimum Terraform version to execute a configuration.
---
# 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
}
```

View File

@ -49,6 +49,10 @@
<a href="/docs/configuration/modules.html">Modules</a>
</li>
<li<%= sidebar_current("docs-config-terraform") %>>
<a href="/docs/configuration/terraform.html">Terraform</a>
</li>
<li<%= sidebar_current("docs-config-atlas") %>>
<a href="/docs/configuration/atlas.html">Atlas</a>
</li>