From ceffad7aec71764cdd6a374d13f208d86034528d Mon Sep 17 00:00:00 2001 From: Kenneth Tan Xin You Date: Sun, 6 Oct 2019 14:14:29 +0800 Subject: [PATCH] Added terraform-config-inspect. --- server/events/project_command_builder.go | 56 +++++- server/events/project_command_builder_test.go | 171 ++++++++++++++++++ testing/temp_files.go | 14 +- 3 files changed, 235 insertions(+), 6 deletions(-) diff --git a/server/events/project_command_builder.go b/server/events/project_command_builder.go index bedd17098f..63bcbcc309 100644 --- a/server/events/project_command_builder.go +++ b/server/events/project_command_builder.go @@ -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" @@ -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 @@ -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)) } } @@ -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 @@ -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 { @@ -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, @@ -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 +} diff --git a/server/events/project_command_builder_test.go b/server/events/project_command_builder_test.go index cb2bf521a7..e87ce20ad7 100644 --- a/server/events/project_command_builder_test.go +++ b/server/events/project_command_builder_test.go @@ -1,6 +1,7 @@ package events_test import ( + "fmt" "io/ioutil" "path/filepath" "strings" @@ -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.") + } + } + }) + } +} diff --git a/testing/temp_files.go b/testing/temp_files.go index 8e4ac781d8..6bab8f03ab 100644 --- a/testing/temp_files.go +++ b/testing/temp_files.go @@ -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, // }, // }, // }) @@ -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) } } }