Skip to content

Commit

Permalink
Added terraform-config-inspect.
Browse files Browse the repository at this point in the history
  • Loading branch information
YesYouKenSpace committed Oct 15, 2019
1 parent 86a56a3 commit ceffad7
Show file tree
Hide file tree
Showing 3 changed files with 235 additions and 6 deletions.
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},
},
}

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 {
// 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)
}
}
}

0 comments on commit ceffad7

Please sign in to comment.