Add new --tfe-token flag for TFE backend.

The new flag takes in a Terraform Enterprise API token and attempts to
write a config file for that token to ~/.terraformrc on startup.
If the file already exists and its contents would change as a result of
us writing it, we error out.
This flag is useful if you're using the TFE backend for any of your
projects as that requires a .terraformrc file with a token for
authentication.
This commit is contained in:
Luke Kysow
2019-01-09 15:11:01 -05:00
parent 7caa043fe6
commit fb86e89df0
6 changed files with 150 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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