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

Terraform version config detection #789

Merged
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
14 changes: 4 additions & 10 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,16 @@ require (
github.com/go-ozzo/ozzo-validation v0.0.0-20170913164239-85dcd8368eba
github.com/go-playground/locales v0.12.1 // indirect
github.com/go-playground/universal-translator v0.16.0 // indirect
github.com/go-test/deep v1.0.1
github.com/go-test/deep v1.0.3
github.com/google/go-github/v28 v28.0.0
github.com/google/uuid v0.0.0-20161128191214-064e2069ce9c // indirect
github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f // indirect
github.com/gorilla/mux v1.6.2
github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce // indirect
github.com/hashicorp/go-getter v1.2.0
github.com/hashicorp/go-multierror v0.0.0-20170622060955-83588e72410a
github.com/hashicorp/go-version v1.1.0
github.com/hashicorp/golang-lru v0.5.1 // indirect
github.com/hashicorp/hcl v0.0.0-20170914154624-68e816d1c783 // indirect
github.com/hpcloud/tail v1.0.0 // indirect
github.com/hashicorp/terraform-config-inspect v0.0.0-20190821133035-82a99dc22ef4
github.com/huandu/xstrings v1.0.0 // indirect
github.com/imdario/mergo v0.3.5 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
Expand All @@ -39,7 +37,6 @@ require (
github.com/mohae/deepcopy v0.0.0-20170603005431-491d3605edfb
github.com/nlopes/slack v0.1.0
github.com/onsi/ginkgo v1.9.0 // indirect
github.com/onsi/gomega v1.2.0 // indirect
github.com/pelletier/go-buffruneio v0.2.0 // indirect
github.com/pelletier/go-toml v1.0.0 // indirect
github.com/petergtz/pegomock v2.5.0+incompatible
Expand All @@ -48,20 +45,17 @@ require (
github.com/spf13/cast v1.1.0 // indirect
github.com/spf13/cobra v0.0.0-20170905172051-b78744579491
github.com/spf13/jwalterweatherman v0.0.0-20170901151539-12bd96e66386 // indirect
github.com/spf13/pflag v1.0.0
github.com/spf13/pflag v1.0.3
github.com/spf13/viper v1.0.0
github.com/ulikunitz/xz v0.5.6 // indirect
github.com/urfave/cli v1.20.0
github.com/urfave/negroni v0.2.0
github.com/xanzy/go-gitlab v0.20.2-0.20190819195750-b1d195859ad0
go.opencensus.io v0.19.1 // indirect
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2
golang.org/x/sys v0.0.0-20190312061237-fead79001313 // indirect
golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19 // indirect
gopkg.in/fsnotify.v1 v1.4.7 // indirect
gopkg.in/go-playground/assert.v1 v1.2.1 // indirect
gopkg.in/go-playground/validator.v9 v9.20.2
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/yaml.v2 v2.2.2
gotest.tools v2.2.0+incompatible // indirect
)
49 changes: 49 additions & 0 deletions go.sum

Large diffs are not rendered by default.

56 changes: 52 additions & 4 deletions server/events/project_command_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ package events

import (
"fmt"
"path/filepath"
"regexp"
"strings"

"github.com/runatlantis/atlantis/server/events/yaml/valid"

"github.com/hashicorp/go-version"
"github.com/hashicorp/terraform-config-inspect/tfconfig"
"github.com/pkg/errors"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/vcs"
Expand Down Expand Up @@ -137,7 +141,7 @@ func (p *DefaultProjectCommandBuilder) buildPlanAllCommands(ctx *CommandContext,
for _, mp := range matchingProjects {
ctx.Log.Debug("determining config for project at dir: %q workspace: %q", mp.Dir, mp.Workspace)
mergedCfg := p.GlobalCfg.MergeProjectCfg(ctx.Log, ctx.BaseRepo.ID(), mp, repoCfg)
projCtxs = append(projCtxs, p.buildCtx(ctx, models.PlanCommand, mergedCfg, commentFlags, repoCfg.Automerge, verbose))
projCtxs = append(projCtxs, p.buildCtx(ctx, models.PlanCommand, mergedCfg, commentFlags, repoCfg.Automerge, verbose, repoDir))
}
} else {
// If there is no config file, then we'll plan each project that
Expand All @@ -148,7 +152,7 @@ func (p *DefaultProjectCommandBuilder) buildPlanAllCommands(ctx *CommandContext,
for _, mp := range modifiedProjects {
ctx.Log.Debug("determining config for project at dir: %q", mp.Path)
pCfg := p.GlobalCfg.DefaultProjCfg(ctx.Log, ctx.BaseRepo.ID(), mp.Path, DefaultWorkspace)
projCtxs = append(projCtxs, p.buildCtx(ctx, models.PlanCommand, pCfg, commentFlags, DefaultAutomergeEnabled, verbose))
projCtxs = append(projCtxs, p.buildCtx(ctx, models.PlanCommand, pCfg, commentFlags, DefaultAutomergeEnabled, verbose, repoDir))
}
}

Expand Down Expand Up @@ -282,7 +286,7 @@ func (p *DefaultProjectCommandBuilder) buildProjectCommandCtx(
if repoCfgPtr != nil {
automerge = repoCfgPtr.Automerge
}
return p.buildCtx(ctx, cmd, projCfg, commentFlags, automerge, verbose), nil
return p.buildCtx(ctx, cmd, projCfg, commentFlags, automerge, verbose, repoDir), nil
}

// getCfg returns the atlantis.yaml config (if it exists) for this project. If
Expand Down Expand Up @@ -371,7 +375,8 @@ func (p *DefaultProjectCommandBuilder) buildCtx(ctx *CommandContext,
projCfg valid.MergedProjectCfg,
commentArgs []string,
automergeEnabled bool,
verbose bool) models.ProjectCommandContext {
verbose bool,
absRepoDir string) models.ProjectCommandContext {

var steps []valid.Step
switch cmd {
Expand All @@ -381,6 +386,14 @@ func (p *DefaultProjectCommandBuilder) buildCtx(ctx *CommandContext,
steps = projCfg.Workflow.Apply.Steps
}

// if TerraformVersion not defined in config file fallback to terraform configuration
if projCfg.TerraformVersion == nil {
version := p.getTfVersion(ctx, filepath.Join(absRepoDir, projCfg.RepoRelDir))
if version != nil {
projCfg.TerraformVersion = version
}
}

return models.ProjectCommandContext{
ApplyCmd: p.CommentBuilder.BuildApplyComment(projCfg.RepoRelDir, projCfg.Workspace, projCfg.Name),
BaseRepo: ctx.BaseRepo,
Expand Down Expand Up @@ -415,3 +428,38 @@ func (p *DefaultProjectCommandBuilder) escapeArgs(args []string) []string {
}
return escaped
}

// Extracts required_version from Terraform configuration.
// Returns nil if unable to determine version from configuation, check warning log for clarification.
func (p *DefaultProjectCommandBuilder) getTfVersion(ctx *CommandContext, absProjDir string) *version.Version {
module, diags := tfconfig.LoadModule(absProjDir)
if diags.HasErrors() {
ctx.Log.Debug(diags.Error())
return nil
}

if len(module.RequiredCore) != 1 {
ctx.Log.Info("cannot determine which version to use from terraform configuration, detected %d possibilities.", len(module.RequiredCore))
return nil
}

ctx.Log.Info("verifying if \"%q\" is valid exact version.", module.RequiredCore[0])

// We allow `= x.y.z`, `=x.y.z` or `x.y.z` where `x`, `y` and `z` are integers
re := regexp.MustCompile(`^=?\s*([^\s]+)\s*$`)
matched := re.FindStringSubmatch(module.RequiredCore[0])
if len(matched) == 0 {
ctx.Log.Info("did not specify exact version in terraform configuration.")
return nil
}

version, err := version.NewVersion(matched[1])

if err != nil {
ctx.Log.Debug(err.Error())
return nil
}

ctx.Log.Debug("detected version: \"%q\".", version)
return version
}
171 changes: 171 additions & 0 deletions server/events/project_command_builder_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package events_test

import (
"fmt"
"io/ioutil"
"path/filepath"
"strings"
Expand Down Expand Up @@ -716,3 +717,173 @@ func TestDefaultProjectCommandBuilder_EscapeArgs(t *testing.T) {
})
}
}

// Test that terraform version is used when specified in terraform configuration
func TestDefaultProjectCommandBuilder_TerraformVersion(t *testing.T) {
// For the following tests:
// If terraform configuration is used, result should be `0.12.8`.
// If project configuration is used, result should be `0.12.6`.
// If default is to be used, result should be `nil`.
baseVersionConfig := `
terraform {
required_version = "%s0.12.8"
}
`

atlantisYamlContent := `
version: 3
projects:
- dir: project1 # project1 uses the defaults
terraform_version: v0.12.6
`

exactSymbols := []string{"", "="}
nonExactSymbols := []string{">", ">=", "<", "<=", "~="}

type testCase struct {
DirStructure map[string]interface{}
AtlantisYAML string
ModifiedFiles []string
Exp map[string][]int
}

testCases := make(map[string]testCase)

for _, exactSymbol := range exactSymbols {
testCases[fmt.Sprintf("exact version in terraform config using \"%s\"", exactSymbol)] = testCase{
DirStructure: map[string]interface{}{
"project1": map[string]interface{}{
"main.tf": fmt.Sprintf(baseVersionConfig, exactSymbol),
},
},
ModifiedFiles: []string{"project1/main.tf"},
Exp: map[string][]int{
"project1": {0, 12, 8},
},
}
}

for _, nonExactSymbol := range nonExactSymbols {
testCases[fmt.Sprintf("non-exact version in terraform config using \"%s\"", nonExactSymbol)] = testCase{
DirStructure: map[string]interface{}{
"project1": map[string]interface{}{
"main.tf": fmt.Sprintf(baseVersionConfig, nonExactSymbol),
},
},
ModifiedFiles: []string{"project1/main.tf"},
Exp: map[string][]int{
"project1": nil,
},
}
}

// atlantis.yaml should take precedence over terraform config
testCases["with project config and terraform config"] = testCase{
DirStructure: map[string]interface{}{
"project1": map[string]interface{}{
"main.tf": fmt.Sprintf(baseVersionConfig, exactSymbols[0]),
},
yaml.AtlantisYAMLFilename: atlantisYamlContent,
},
ModifiedFiles: []string{"project1/main.tf", "project2/main.tf"},
Exp: map[string][]int{
"project1": {0, 12, 6},
},
}

testCases["with project config only"] = testCase{
DirStructure: map[string]interface{}{
"project1": map[string]interface{}{
"main.tf": nil,
},
yaml.AtlantisYAMLFilename: atlantisYamlContent,
},
ModifiedFiles: []string{"project1/main.tf"},
Exp: map[string][]int{
"project1": {0, 12, 6},
},
}

testCases["neither project config or terraform config"] = testCase{
DirStructure: map[string]interface{}{
"project1": map[string]interface{}{
"main.tf": nil,
},
},
ModifiedFiles: []string{"project1/main.tf", "project2/main.tf"},
Exp: map[string][]int{
"project1": nil,
},
}

testCases["project with different terraform config"] = testCase{
DirStructure: map[string]interface{}{
"project1": map[string]interface{}{
"main.tf": fmt.Sprintf(baseVersionConfig, exactSymbols[0]),
},
"project2": map[string]interface{}{
"main.tf": strings.Replace(fmt.Sprintf(baseVersionConfig, exactSymbols[0]), "0.12.8", "0.12.9", -1),
},
},
ModifiedFiles: []string{"project1/main.tf", "project2/main.tf"},
Exp: map[string][]int{
"project1": {0, 12, 8},
"project2": {0, 12, 9},
},
YesYouKenSpace marked this conversation as resolved.
Show resolved Hide resolved
}

for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
RegisterMockTestingT(t)

tmpDir, cleanup := DirStructure(t, testCase.DirStructure)

defer cleanup()
vcsClient := vcsmocks.NewMockClient()
When(vcsClient.GetModifiedFiles(matchers.AnyModelsRepo(), matchers.AnyModelsPullRequest())).ThenReturn(testCase.ModifiedFiles, nil)

workingDir := mocks.NewMockWorkingDir()
When(workingDir.Clone(
matchers.AnyPtrToLoggingSimpleLogger(),
matchers.AnyModelsRepo(),
matchers.AnyModelsRepo(),
matchers.AnyModelsPullRequest(),
AnyString())).ThenReturn(tmpDir, nil)

When(workingDir.GetWorkingDir(
matchers.AnyModelsRepo(),
matchers.AnyModelsPullRequest(),
AnyString())).ThenReturn(tmpDir, nil)

builder := &events.DefaultProjectCommandBuilder{
WorkingDirLocker: events.NewDefaultWorkingDirLocker(),
WorkingDir: workingDir,
VCSClient: vcsClient,
ParserValidator: &yaml.ParserValidator{},
ProjectFinder: &events.DefaultProjectFinder{},
CommentBuilder: &events.CommentParser{},
GlobalCfg: valid.NewGlobalCfg(true, false, false),
}

actCtxs, err := builder.BuildPlanCommands(
&events.CommandContext{},
&events.CommentCommand{
RepoRelDir: "",
Flags: nil,
Name: models.PlanCommand,
Verbose: false,
})

Ok(t, err)
Equals(t, len(testCase.Exp), len(actCtxs))
for _, actCtx := range actCtxs {
if testCase.Exp[actCtx.RepoRelDir] != nil {
Assert(t, actCtx.TerraformVersion != nil, "TerraformVersion is nil.")
Equals(t, testCase.Exp[actCtx.RepoRelDir], actCtx.TerraformVersion.Segments())
} else {
Assert(t, actCtx.TerraformVersion == nil, "TerraformVersion is supposed to be nil.")
}
}
})
}
}
14 changes: 12 additions & 2 deletions testing/temp_files.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,22 @@ func TempDir(t *testing.T) (string, func()) {
// DirStructure creates a directory structure in a temporary directory.
// structure describes the dir structure. If the value is another map, then the
// key is the name of a directory. If the value is nil, then the key is the name
// of a file. It returns the path to the temp directory containing the defined
// of a file. If val is a string then key is a file name and val is the file's content.
// It returns the path to the temp directory containing the defined
// structure and a cleanup function to delete the directory.
// Example usage:
// versionConfig := `
// terraform {
// required_version = "= 0.12.8"
// }
// `
// tmpDir, cleanup := DirStructure(t, map[string]interface{}{
// "pulldir": map[string]interface{}{
// "project1": map[string]interface{}{
// "main.tf": nil,
// },
// "project2": map[string]interface{}{,
// "main.tf": nil,
// "main.tf": versionConfig,
// },
// },
// })
Expand All @@ -57,6 +63,10 @@ func dirStructureGo(t *testing.T, parentDir string, structure map[string]interfa
Ok(t, os.Mkdir(subDir, 0700))
// Recurse and create contents.
dirStructureGo(t, subDir, dirContents)
} else if fileContent, ok := val.(string); ok {
YesYouKenSpace marked this conversation as resolved.
Show resolved Hide resolved
// If val is a string then key is a file name and val is the file's content
err := ioutil.WriteFile(filepath.Join(parentDir, key), []byte(fileContent), 0600)
Ok(t, err)
}
}
}
Loading