diff --git a/cmd/server.go b/cmd/server.go index 2c9e0332e..0f4a82feb 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -61,6 +61,7 @@ const ( SilenceWhitelistErrorsFlag = "silence-whitelist-errors" SSLCertFileFlag = "ssl-cert-file" SSLKeyFileFlag = "ssl-key-file" + TFETokenFlag = "tfe-token" // Flag defaults. DefaultBitbucketBaseURL = bitbucketcloud.BaseURL @@ -167,6 +168,12 @@ var stringFlags = []stringFlag{ name: SSLKeyFileFlag, description: fmt.Sprintf("File containing x509 private key matching --%s.", SSLCertFileFlag), }, + { + name: TFETokenFlag, + description: "API token for Terraform Enterprise. This will be used to generate a ~/.terraformrc file." + + " Only set if using TFE as a backend." + + " Should be specified via the ATLANTIS_TFE_TOKEN environment variable for security.", + }, } var boolFlags = []boolFlag{ { diff --git a/cmd/server_test.go b/cmd/server_test.go index 84c8528b4..1f827929a 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -349,6 +349,7 @@ func TestExecute_Defaults(t *testing.T) { Equals(t, false, passedConfig.RequireMergeable) Equals(t, "", passedConfig.SSLCertFile) Equals(t, "", passedConfig.SSLKeyFile) + Equals(t, "", passedConfig.TFEToken) } func TestExecute_ExpandHomeInDataDir(t *testing.T) { @@ -447,6 +448,7 @@ func TestExecute_Flags(t *testing.T) { cmd.RequireMergeableFlag: true, cmd.SSLCertFileFlag: "cert-file", cmd.SSLKeyFileFlag: "key-file", + cmd.TFETokenFlag: "my-token", }) err := c.Execute() Ok(t, err) @@ -474,6 +476,7 @@ func TestExecute_Flags(t *testing.T) { Equals(t, true, passedConfig.RequireMergeable) Equals(t, "cert-file", passedConfig.SSLCertFile) Equals(t, "key-file", passedConfig.SSLKeyFile) + Equals(t, "my-token", passedConfig.TFEToken) } func TestExecute_ConfigFile(t *testing.T) { @@ -502,6 +505,7 @@ require-approval: true require-mergeable: true ssl-cert-file: cert-file ssl-key-file: key-file +tfe-token: my-token `) defer os.Remove(tmpFile) // nolint: errcheck c := setup(map[string]interface{}{ @@ -533,6 +537,7 @@ ssl-key-file: key-file Equals(t, true, passedConfig.RequireMergeable) Equals(t, "cert-file", passedConfig.SSLCertFile) Equals(t, "key-file", passedConfig.SSLKeyFile) + Equals(t, "my-token", passedConfig.TFEToken) } func TestExecute_EnvironmentOverride(t *testing.T) { @@ -560,6 +565,7 @@ repo-whitelist: "github.com/runatlantis/atlantis" require-approval: true ssl-cert-file: cert-file ssl-key-file: key-file +ssl-key-file: my-token `) defer os.Remove(tmpFile) // nolint: errcheck @@ -588,6 +594,7 @@ ssl-key-file: key-file "REQUIRE_MERGEABLE": "false", "SSL_CERT_FILE": "override-cert-file", "SSL_KEY_FILE": "override-key-file", + "TFE_TOKEN": "override-my-token", } { os.Setenv("ATLANTIS_"+name, value) // nolint: errcheck } @@ -619,6 +626,7 @@ ssl-key-file: key-file Equals(t, false, passedConfig.RequireMergeable) Equals(t, "override-cert-file", passedConfig.SSLCertFile) Equals(t, "override-key-file", passedConfig.SSLKeyFile) + Equals(t, "override-my-token", passedConfig.TFEToken) } func TestExecute_FlagConfigOverride(t *testing.T) { @@ -647,6 +655,7 @@ require-approval: true require-mergeable: true ssl-cert-file: cert-file ssl-key-file: key-file +tfe-token: my-token `) defer os.Remove(tmpFile) // nolint: errcheck @@ -674,6 +683,7 @@ ssl-key-file: key-file cmd.RequireMergeableFlag: false, cmd.SSLCertFileFlag: "override-cert-file", cmd.SSLKeyFileFlag: "override-key-file", + cmd.TFETokenFlag: "override-my-token", }) err := c.Execute() Ok(t, err) @@ -699,6 +709,7 @@ ssl-key-file: key-file Equals(t, false, passedConfig.RequireMergeable) Equals(t, "override-cert-file", passedConfig.SSLCertFile) Equals(t, "override-key-file", passedConfig.SSLKeyFile) + Equals(t, "override-my-token", passedConfig.TFEToken) } func TestExecute_FlagEnvVarOverride(t *testing.T) { @@ -728,6 +739,7 @@ func TestExecute_FlagEnvVarOverride(t *testing.T) { "REQUIRE_MERGEABLE": "true", "SSL_CERT_FILE": "cert-file", "SSL_KEY_FILE": "key-file", + "TFE_TOKEN": "my-token", } for name, value := range envVars { os.Setenv("ATLANTIS_"+name, value) // nolint: errcheck @@ -763,6 +775,7 @@ func TestExecute_FlagEnvVarOverride(t *testing.T) { cmd.RequireMergeableFlag: false, cmd.SSLCertFileFlag: "override-cert-file", cmd.SSLKeyFileFlag: "override-key-file", + cmd.TFETokenFlag: "override-my-token", }) err := c.Execute() Ok(t, err) @@ -790,6 +803,7 @@ func TestExecute_FlagEnvVarOverride(t *testing.T) { Equals(t, false, passedConfig.RequireMergeable) Equals(t, "override-cert-file", passedConfig.SSLCertFile) Equals(t, "override-key-file", passedConfig.SSLKeyFile) + Equals(t, "override-my-token", passedConfig.TFEToken) } // If using bitbucket cloud, webhook secrets are not supported. diff --git a/server/events/terraform/terraform_client.go b/server/events/terraform/terraform_client.go index 19019a62a..a5dd0ed26 100644 --- a/server/events/terraform/terraform_client.go +++ b/server/events/terraform/terraform_client.go @@ -16,6 +16,8 @@ package terraform import ( "fmt" + "github.com/mitchellh/go-homedir" + "io/ioutil" "os" "os/exec" "path/filepath" @@ -49,7 +51,7 @@ const terraformPluginCacheDirName = "plugin-cache" // => 0.11.10 var versionRegex = regexp.MustCompile("Terraform v(.*?)(\\s.*)?\n") -func NewClient(dataDir string) (*DefaultClient, error) { +func NewClient(dataDir string, tfeToken string) (*DefaultClient, error) { _, err := exec.LookPath("terraform") if err != nil { return nil, errors.New("terraform not found in $PATH. \n\nDownload terraform from https://www.terraform.io/downloads.html") @@ -69,6 +71,17 @@ func NewClient(dataDir string) (*DefaultClient, error) { return nil, errors.Wrap(err, "parsing terraform version") } + // If tfeToken is set, we try to create a ~/.terraformrc file. + if tfeToken != "" { + home, err := homedir.Dir() + if err != nil { + return nil, errors.Wrap(err, "getting home dir to write ~/.terraformrc file") + } + if err := generateRCFile(tfeToken, home); err != nil { + return nil, err + } + } + // We will run terraform with the TF_PLUGIN_CACHE_DIR env var set to this // directory inside our data dir. cacheDir := filepath.Join(dataDir, terraformPluginCacheDirName) @@ -82,6 +95,32 @@ func NewClient(dataDir string) (*DefaultClient, error) { }, nil } +// generateRCFile generates a .terraformrc file containing config for tfeToken. +// It will create the file in home/.terraformrc. +func generateRCFile(tfeToken string, home string) error { + const rcFilename = ".terraformrc" + rcFile := filepath.Join(home, rcFilename) + + // If there is already a .terraformrc file and its contents aren't exactly + // what we would have written to it, then we error out because we don't + // want to overwrite anything. + newContents := fmt.Sprintf(rcFileContents, tfeToken) + if _, err := os.Stat(rcFile); err == nil { + currContents, err := ioutil.ReadFile(rcFile) + if err != nil { + return errors.Wrapf(err, "trying to read %s to ensure we're not overwriting it", rcFile) + } + if newContents != string(currContents) { + return fmt.Errorf("can't write TFE token to %s because that file has contents that would be overwritten", rcFile) + } + } + + if err := ioutil.WriteFile(rcFile, []byte(newContents), 0600); err != nil { + return errors.Wrapf(err, "writing generated %s file with TFE token to %s", rcFilename, rcFile) + } + return nil +} + // Version returns the version of the terraform executable in our $PATH. func (c *DefaultClient) Version() *version.Version { return c.defaultVersion @@ -145,3 +184,10 @@ func MustConstraint(v string) version.Constraints { } return c } + +// rcFileContents is a format string to be used with Sprintf that can be used +// to generate the contents of a ~/.terraformrc file for authenticating with +// Terraform Enterprise. +var rcFileContents = `credentials "app.terraform.io" { + token = %q +}` diff --git a/server/events/terraform/terraform_client_internal_test.go b/server/events/terraform/terraform_client_internal_test.go new file mode 100644 index 000000000..df0a2fd7a --- /dev/null +++ b/server/events/terraform/terraform_client_internal_test.go @@ -0,0 +1,80 @@ +package terraform + +import ( + "fmt" + . "github.com/runatlantis/atlantis/testing" + "io/ioutil" + "path/filepath" + "testing" +) + +// Test that we write the file as expected +func TestGenerateRCFile_WritesFile(t *testing.T) { + tmp, cleanup := TempDir(t) + defer cleanup() + + err := generateRCFile("token", tmp) + Ok(t, err) + + expContents := `credentials "app.terraform.io" { + token = "token" +}` + actContents, err := ioutil.ReadFile(filepath.Join(tmp, ".terraformrc")) + Ok(t, err) + Equals(t, expContents, string(actContents)) +} + +// Test that if the file already exists and its contents will be modified if +// we write our config that we error out. +func TestGenerateRCFile_WillNotOverwrite(t *testing.T) { + tmp, cleanup := TempDir(t) + defer cleanup() + + rcFile := filepath.Join(tmp, ".terraformrc") + err := ioutil.WriteFile(rcFile, []byte("contents"), 0600) + Ok(t, err) + + actErr := generateRCFile("token", tmp) + expErr := fmt.Sprintf("can't write TFE token to %s because that file has contents that would be overwritten", tmp+"/.terraformrc") + ErrEquals(t, expErr, actErr) +} + +// Test that if the file already exists and its contents will NOT be modified if +// we write our config that we don't error. +func TestGenerateRCFile_NoErrIfContentsSame(t *testing.T) { + tmp, cleanup := TempDir(t) + defer cleanup() + + rcFile := filepath.Join(tmp, ".terraformrc") + contents := `credentials "app.terraform.io" { + token = "token" +}` + err := ioutil.WriteFile(rcFile, []byte(contents), 0600) + Ok(t, err) + + err = generateRCFile("token", tmp) + Ok(t, err) +} + +// Test that if we can't read the existing file to see if the contents will be +// the same that we just error out. +func TestGenerateRCFile_ErrIfCannotRead(t *testing.T) { + tmp, cleanup := TempDir(t) + defer cleanup() + + rcFile := filepath.Join(tmp, ".terraformrc") + err := ioutil.WriteFile(rcFile, []byte("can't see me!"), 0000) + Ok(t, err) + + expErr := fmt.Sprintf("trying to read %s to ensure we're not overwriting it: open %s: permission denied", rcFile, rcFile) + actErr := generateRCFile("token", tmp) + ErrEquals(t, expErr, actErr) +} + +// Test that if we can't write, we error out. +func TestGenerateRCFile_ErrIfCannotWrite(t *testing.T) { + rcFile := "/this/dir/does/not/exist/.terraformrc" + expErr := fmt.Sprintf("writing generated .terraformrc file with TFE token to %s: open %s: no such file or directory", rcFile, rcFile) + actErr := generateRCFile("token", "/this/dir/does/not/exist") + ErrEquals(t, expErr, actErr) +} diff --git a/server/server.go b/server/server.go index d021cc0e1..957bd2ec6 100644 --- a/server/server.go +++ b/server/server.go @@ -165,7 +165,7 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { } vcsClient := vcs.NewDefaultClientProxy(githubClient, gitlabClient, bitbucketCloudClient, bitbucketServerClient) commitStatusUpdater := &events.DefaultCommitStatusUpdater{Client: vcsClient} - terraformClient, err := terraform.NewClient(userConfig.DataDir) + terraformClient, err := terraform.NewClient(userConfig.DataDir, userConfig.TFEToken) // The flag.Lookup call is to detect if we're running in a unit test. If we // are, then we don't error out because we don't have/want terraform // installed on our CI system where the unit tests run. diff --git a/server/user_config.go b/server/user_config.go index 3de10b9a6..6cf174c31 100644 --- a/server/user_config.go +++ b/server/user_config.go @@ -35,6 +35,7 @@ type UserConfig struct { SlackToken string `mapstructure:"slack-token"` SSLCertFile string `mapstructure:"ssl-cert-file"` SSLKeyFile string `mapstructure:"ssl-key-file"` + TFEToken string `mapstructure:"tfe-token"` Webhooks []WebhookConfig `mapstructure:"webhooks"` }