From 4f42ff3fabb9b9cbf6cb9134893c16721019527d Mon Sep 17 00:00:00 2001 From: Luke Kysow <1034429+lkysow@users.noreply.github.com> Date: Fri, 12 Jul 2019 14:47:01 +0100 Subject: [PATCH] Add flag for setting Terraform Enterprise hostname --tfe-hostname will allow for creating a .terraformrc file with a different hostname than app.terraform.io --- cmd/server.go | 18 +++++++- cmd/server_test.go | 12 ++++++ runatlantis.io/docs/server-configuration.md | 9 ++++ runatlantis.io/docs/terraform-cloud.md | 41 ++++++++++++------- server/events/terraform/terraform_client.go | 20 ++++++--- .../terraform_client_internal_test.go | 12 +++--- .../events/terraform/terraform_client_test.go | 16 ++++---- server/events_controller_e2e_test.go | 2 +- server/server.go | 9 +++- server/user_config.go | 1 + 10 files changed, 101 insertions(+), 39 deletions(-) diff --git a/cmd/server.go b/cmd/server.go index 60f57ea9b6..497b48e8a7 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -68,9 +68,11 @@ const ( SlackTokenFlag = "slack-token" SSLCertFileFlag = "ssl-cert-file" SSLKeyFileFlag = "ssl-key-file" + TFEHostnameFlag = "tfe-hostname" TFETokenFlag = "tfe-token" // Flag defaults. + // NOTE: Must manually set these as defaults in the setDefaults function. DefaultCheckoutStrategy = "branch" DefaultBitbucketBaseURL = bitbucketcloud.BaseURL DefaultDataDir = "~/.atlantis" @@ -78,6 +80,7 @@ const ( DefaultGitlabHostname = "gitlab.com" DefaultLogLevel = "info" DefaultPort = 4141 + DefaultTFEHostname = "app.terraform.io" ) var stringFlags = map[string]stringFlag{ @@ -175,9 +178,13 @@ var stringFlags = map[string]stringFlag{ SSLKeyFileFlag: { description: fmt.Sprintf("File containing x509 private key matching --%s.", SSLCertFileFlag), }, + TFEHostnameFlag: { + description: "Hostname of your Terraform Enterprise installation. If using Terraform Cloud no need to set.", + defaultValue: DefaultTFEHostname, + }, TFETokenFlag: { - description: "API token for Terraform Enterprise. This will be used to generate a ~/.terraformrc file." + - " Only set if using TFE as a backend." + + description: "API token for Terraform Cloud/Enterprise. This will be used to generate a ~/.terraformrc file." + + " Only set if using TFC/E as a remote backend." + " Should be specified via the ATLANTIS_TFE_TOKEN environment variable for security.", }, DefaultTFVersionFlag: { @@ -418,6 +425,9 @@ func (s *ServerCmd) setDefaults(c *server.UserConfig) { if c.Port == 0 { c.Port = DefaultPort } + if c.TFEHostname == "" { + c.TFEHostname = DefaultTFEHostname + } } func (s *ServerCmd) validate(userConfig server.UserConfig) error { @@ -486,6 +496,10 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error { } } + if userConfig.TFEHostname != DefaultTFEHostname && userConfig.TFEToken == "" { + return fmt.Errorf("if setting --%s, must set --%s", TFEHostnameFlag, TFETokenFlag) + } + return nil } diff --git a/cmd/server_test.go b/cmd/server_test.go index 218a4e6260..8ac047cdc6 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -941,6 +941,18 @@ func TestExecute_RepoCfgFlags(t *testing.T) { ErrEquals(t, "cannot use --repo-config and --repo-config-json at the same time", err) } +// Can't use both --tfe-hostname flag without --tfe-token. +func TestExecute_TFEHostnameOnly(t *testing.T) { + c := setup(map[string]interface{}{ + cmd.GHUserFlag: "user", + cmd.GHTokenFlag: "token", + cmd.RepoWhitelistFlag: "github.com", + cmd.TFEHostnameFlag: "not-app.terraform.io", + }) + err := c.Execute() + ErrEquals(t, "if setting --tfe-hostname, must set --tfe-token", err) +} + func setup(flags map[string]interface{}) *cobra.Command { vipr := viper.New() for k, v := range flags { diff --git a/runatlantis.io/docs/server-configuration.md b/runatlantis.io/docs/server-configuration.md index 94b432c621..1a5131d2f5 100644 --- a/runatlantis.io/docs/server-configuration.md +++ b/runatlantis.io/docs/server-configuration.md @@ -385,6 +385,15 @@ Values are chosen in this order: atlantis server --ssl-cert-file="/etc/ssl/private/my-cert.key" ``` File containing x509 private key matching `--ssl-cert-file`. + +* ### `--tfe-hostname` + ```bash + atlantis server --tfe-hostname="my-terraform-enterprise.company.com" + ``` + Hostname of your Terraform Enterprise installation to be used in conjunction with + `--tfe-token`. See [Terraform Cloud](terraform-cloud.html) for more details. + If using Terraform Cloud (i.e. you don't have your own Terraform Enterprise installation) + no need to set since it defaults to `app.terraform.io`. * ### `--tfe-token` ```bash diff --git a/runatlantis.io/docs/terraform-cloud.md b/runatlantis.io/docs/terraform-cloud.md index cd2e8cfd57..68a6b366de 100644 --- a/runatlantis.io/docs/terraform-cloud.md +++ b/runatlantis.io/docs/terraform-cloud.md @@ -1,13 +1,14 @@ -# Terraform Cloud +# Terraform Cloud/Enterprise -::: tip -Terraform Enterprise was [recently renamed](https://www.hashicorp.com/blog/introducing-terraform-cloud-remote-state-management) Terraform Cloud. +::: tip NOTE +Terraform Enterprise was [recently renamed](https://www.hashicorp.com/blog/introducing-terraform-cloud-remote-state-management) Terraform Cloud +and Private Terraform Enteprise was renamed Terraform Enterprise. ::: -Atlantis integrates seamlessly with Terraform Cloud, whether you're using: +Atlantis integrates seamlessly with Terraform Cloud and Terraform Enterprise, whether you're using: * [Free Remote State Management](https://app.terraform.io/signup) * Terraform Cloud Paid Tiers -* Private Terraform Enterprise +* A Private Installation of Terraform Enterprise Read the docs below :point_down: depending on your use-case. [[toc]] @@ -22,14 +23,15 @@ To use Atlantis with Free Remote State Storage, you need to: That's it! Atlantis will run as normal and your state will be stored in Terraform Cloud. -## Using Atlantis With Terraform Cloud Paid Tiers -Atlantis integrates with the full version of Terraform Cloud via its [remote backend](https://www.terraform.io/docs/backends/types/remote.html). +## Using Atlantis With Terraform Cloud Remote Operations or Terraform Enterprise +Atlantis integrates with the full version of Terraform Cloud and Terraform Enterprise +via the [remote backend](https://www.terraform.io/docs/backends/types/remote.html). Atlantis will run `terraform` commands as usual, however those commands will -actually be executed *remotely* in Terraform Cloud. +actually be executed *remotely* in Terraform Cloud or Terraform Enterprise. ### Why? -Using Atlantis with Terraform Cloud gives you access to features like: +Using Atlantis with Terraform Cloud or Terraform Enterprise gives you access to features like: * Real-time streaming output * Ability to cancel in-progress commands * Secret variables @@ -38,14 +40,14 @@ Using Atlantis with Terraform Cloud gives you access to features like: **Without** having to change your pull request workflow. ### Getting Started -To use Atlantis with Terraform Cloud Paid Tiers, you need to: -1. Migrate your state to Terraform Cloud. See [Migrating State from Terraform Open Source](https://www.terraform.io/docs/enterprise/migrate/index.html) +To use Atlantis with Terraform Cloud Remote Operations or Terraform Enterprise, you need to: +1. Migrate your state to Terraform Cloud/Enterprise. See [Migrating State from Terraform Open Source](https://www.terraform.io/docs/enterprise/migrate/index.html) 1. Update any projects that are referencing the state you migrated to use the new location -1. [Generate a Terraform Cloud Token](#generating-a-terraform-cloud-token) +1. [Generate a Terraform Cloud/Enterprise Token](#generating-a-terraform-cloud-token) 1. [Pass the token to Atlantis](#passing-the-token-to-atlantis) -## Generating a Terraform Cloud Token -Atlantis needs a Terraform Cloud Token that it will use to access the API. +## Generating a Terraform Cloud/Enterprise Token +Atlantis needs a Terraform Cloud/Enterprise Token that it will use to access the API. Using a **Team Token is recommended**, however you can also use a User Token. ### Team Token @@ -62,9 +64,18 @@ The token can be passed to Atlantis via the `ATLANTIS_TFE_TOKEN` environment var You can also use the `--tfe-token` flag, however your token would then be easily viewable in the process list. -That's it! Atlantis should be able to perform Terraform operations using Terraform Cloud's +If you're hosting your own Terraform Enterprise installation, set the `--tfe-hostname` +flag to its hostname. + +That's it! Atlantis should be able to perform Terraform operations using Terraform Cloud/Enterprise's remote state backend now. +:::warning +The Terraform Cloud/Enterprise integration only works with the built-in +`plan` and `apply` steps. It does not work with custom `run` steps that replace +plan or apply. +::: + :::tip NOTE Under the hood, Atlantis is generating a `~/.terraformrc` file. If you already had a `~/.terraformrc` file where Atlantis is running, diff --git a/server/events/terraform/terraform_client.go b/server/events/terraform/terraform_client.go index a42d7bd825..1677cf632a 100644 --- a/server/events/terraform/terraform_client.go +++ b/server/events/terraform/terraform_client.go @@ -97,7 +97,14 @@ var versionRegex = regexp.MustCompile("Terraform v(.*?)(\\s.*)?\n") // version. // tfDownloader is used to download terraform versions. // Will asynchronously download the required version if it doesn't exist already. -func NewClient(log *logging.SimpleLogger, dataDir string, tfeToken string, defaultVersionStr string, defaultVersionFlagName string, tfDownloader Downloader) (*DefaultClient, error) { +func NewClient( + log *logging.SimpleLogger, + dataDir string, + tfeToken string, + tfeHostname string, + defaultVersionStr string, + defaultVersionFlagName string, + tfDownloader Downloader) (*DefaultClient, error) { var finalDefaultVersion *version.Version var localVersion *version.Version versions := make(map[string]string) @@ -149,7 +156,7 @@ func NewClient(log *logging.SimpleLogger, dataDir string, tfeToken string, defau if err != nil { return nil, errors.Wrap(err, "getting home dir to write ~/.terraformrc file") } - if err := generateRCFile(tfeToken, home); err != nil { + if err := generateRCFile(tfeToken, tfeHostname, home); err != nil { return nil, err } } @@ -383,12 +390,13 @@ func ensureVersion(log *logging.SimpleLogger, dl Downloader, versions map[string return dest, nil } -// generateRCFile generates a .terraformrc file containing config for tfeToken. +// generateRCFile generates a .terraformrc file containing config for tfeToken +// and hostname tfeHostname. // It will create the file in home/.terraformrc. -func generateRCFile(tfeToken string, home string) error { +func generateRCFile(tfeToken string, tfeHostname string, home string) error { const rcFilename = ".terraformrc" rcFile := filepath.Join(home, rcFilename) - config := fmt.Sprintf(rcFileContents, tfeToken) + config := fmt.Sprintf(rcFileContents, tfeHostname, tfeToken) // 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 @@ -428,7 +436,7 @@ func getVersion(tfBinary string) (*version.Version, error) { // 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" { +var rcFileContents = `credentials "%s" { token = %q }` diff --git a/server/events/terraform/terraform_client_internal_test.go b/server/events/terraform/terraform_client_internal_test.go index 2881a2e23a..ca58664bff 100644 --- a/server/events/terraform/terraform_client_internal_test.go +++ b/server/events/terraform/terraform_client_internal_test.go @@ -18,10 +18,10 @@ func TestGenerateRCFile_WritesFile(t *testing.T) { tmp, cleanup := TempDir(t) defer cleanup() - err := generateRCFile("token", tmp) + err := generateRCFile("token", "hostname", tmp) Ok(t, err) - expContents := `credentials "app.terraform.io" { + expContents := `credentials "hostname" { token = "token" }` actContents, err := ioutil.ReadFile(filepath.Join(tmp, ".terraformrc")) @@ -39,7 +39,7 @@ func TestGenerateRCFile_WillNotOverwrite(t *testing.T) { err := ioutil.WriteFile(rcFile, []byte("contents"), 0600) Ok(t, err) - actErr := generateRCFile("token", tmp) + actErr := generateRCFile("token", "hostname", 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) } @@ -57,7 +57,7 @@ func TestGenerateRCFile_NoErrIfContentsSame(t *testing.T) { err := ioutil.WriteFile(rcFile, []byte(contents), 0600) Ok(t, err) - err = generateRCFile("token", tmp) + err = generateRCFile("token", "app.terraform.io", tmp) Ok(t, err) } @@ -72,7 +72,7 @@ func TestGenerateRCFile_ErrIfCannotRead(t *testing.T) { 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) + actErr := generateRCFile("token", "hostname", tmp) ErrEquals(t, expErr, actErr) } @@ -80,7 +80,7 @@ func TestGenerateRCFile_ErrIfCannotRead(t *testing.T) { 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") + actErr := generateRCFile("token", "hostname", "/this/dir/does/not/exist") ErrEquals(t, expErr, actErr) } diff --git a/server/events/terraform/terraform_client_test.go b/server/events/terraform/terraform_client_test.go index b532163767..099182f70f 100644 --- a/server/events/terraform/terraform_client_test.go +++ b/server/events/terraform/terraform_client_test.go @@ -67,7 +67,7 @@ is 0.11.13. You can update by downloading from www.terraform.io/downloads.html Ok(t, err) defer tempSetEnv(t, "PATH", fmt.Sprintf("%s:%s", tmp, os.Getenv("PATH")))() - c, err := terraform.NewClient(nil, tmp, "", "", cmd.DefaultTFVersionFlag, nil) + c, err := terraform.NewClient(nil, tmp, "", "", "", cmd.DefaultTFVersionFlag, nil) Ok(t, err) Ok(t, err) @@ -95,7 +95,7 @@ is 0.11.13. You can update by downloading from www.terraform.io/downloads.html Ok(t, err) defer tempSetEnv(t, "PATH", fmt.Sprintf("%s:%s", tmp, os.Getenv("PATH")))() - c, err := terraform.NewClient(nil, tmp, "", "0.11.10", cmd.DefaultTFVersionFlag, nil) + c, err := terraform.NewClient(nil, tmp, "", "", "0.11.10", cmd.DefaultTFVersionFlag, nil) Ok(t, err) Ok(t, err) @@ -115,7 +115,7 @@ func TestNewClient_NoTF(t *testing.T) { // Set PATH to only include our empty directory. defer tempSetEnv(t, "PATH", tmp)() - _, err := terraform.NewClient(nil, tmp, "", "", cmd.DefaultTFVersionFlag, nil) + _, err := terraform.NewClient(nil, tmp, "", "", "", cmd.DefaultTFVersionFlag, nil) ErrEquals(t, "terraform not found in $PATH. Set --default-tf-version or download terraform from https://www.terraform.io/downloads.html", err) } @@ -132,7 +132,7 @@ func TestNewClient_DefaultTFFlagInPath(t *testing.T) { Ok(t, err) defer tempSetEnv(t, "PATH", fmt.Sprintf("%s:%s", tmp, os.Getenv("PATH")))() - c, err := terraform.NewClient(nil, tmp, "", "0.11.10", cmd.DefaultTFVersionFlag, nil) + c, err := terraform.NewClient(nil, tmp, "", "", "0.11.10", cmd.DefaultTFVersionFlag, nil) Ok(t, err) Ok(t, err) @@ -156,7 +156,7 @@ func TestNewClient_DefaultTFFlagInBinDir(t *testing.T) { Ok(t, err) defer tempSetEnv(t, "PATH", fmt.Sprintf("%s:%s", tmp, os.Getenv("PATH")))() - c, err := terraform.NewClient(nil, tmp, "", "0.11.10", cmd.DefaultTFVersionFlag, nil) + c, err := terraform.NewClient(nil, tmp, "", "", "0.11.10", cmd.DefaultTFVersionFlag, nil) Ok(t, err) Ok(t, err) @@ -182,7 +182,7 @@ func TestNewClient_DefaultTFFlagDownload(t *testing.T) { err := ioutil.WriteFile(params[0].(string), []byte("#!/bin/sh\necho '\nTerraform v0.11.10\n'"), 0755) return []pegomock.ReturnValue{err} }) - c, err := terraform.NewClient(nil, tmp, "", "0.11.10", cmd.DefaultTFVersionFlag, mockDownloader) + c, err := terraform.NewClient(nil, tmp, "", "", "0.11.10", cmd.DefaultTFVersionFlag, mockDownloader) Ok(t, err) Ok(t, err) @@ -206,7 +206,7 @@ func TestNewClient_DefaultTFFlagDownload(t *testing.T) { func TestNewClient_BadVersion(t *testing.T) { tmp, cleanup := TempDir(t) defer cleanup() - _, err := terraform.NewClient(nil, tmp, "", "malformed", cmd.DefaultTFVersionFlag, nil) + _, err := terraform.NewClient(nil, tmp, "", "", "malformed", cmd.DefaultTFVersionFlag, nil) ErrEquals(t, "Malformed version: malformed", err) } @@ -229,7 +229,7 @@ func TestRunCommandWithVersion_DLsTF(t *testing.T) { return []pegomock.ReturnValue{err} }) - c, err := terraform.NewClient(nil, tmp, "", "0.11.10", cmd.DefaultTFVersionFlag, mockDownloader) + c, err := terraform.NewClient(nil, tmp, "", "", "0.11.10", cmd.DefaultTFVersionFlag, mockDownloader) Ok(t, err) Equals(t, "0.11.10", c.DefaultVersion().String()) diff --git a/server/events_controller_e2e_test.go b/server/events_controller_e2e_test.go index 7fc732b96c..bbe2ae2e58 100644 --- a/server/events_controller_e2e_test.go +++ b/server/events_controller_e2e_test.go @@ -400,7 +400,7 @@ func setupE2E(t *testing.T, repoDir string) (server.EventsController, *vcsmocks. GithubUser: "github-user", GitlabUser: "gitlab-user", } - terraformClient, err := terraform.NewClient(logger, dataDir, "", "", "default-tf-version", &NoopTFDownloader{}) + terraformClient, err := terraform.NewClient(logger, dataDir, "", "", "", "default-tf-version", &NoopTFDownloader{}) Ok(t, err) boltdb, err := db.New(dataDir) Ok(t, err) diff --git a/server/server.go b/server/server.go index 5289c58957..d02fff318a 100644 --- a/server/server.go +++ b/server/server.go @@ -168,7 +168,14 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) { } vcsClient := vcs.NewClientProxy(githubClient, gitlabClient, bitbucketCloudClient, bitbucketServerClient) commitStatusUpdater := &events.DefaultCommitStatusUpdater{Client: vcsClient} - terraformClient, err := terraform.NewClient(logger, userConfig.DataDir, userConfig.TFEToken, userConfig.DefaultTFVersion, config.DefaultTFVersionFlag, &terraform.DefaultDownloader{}) + terraformClient, err := terraform.NewClient( + logger, + userConfig.DataDir, + userConfig.TFEToken, + userConfig.TFEHostname, + userConfig.DefaultTFVersion, + config.DefaultTFVersionFlag, + &terraform.DefaultDownloader{}) // 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 05d858e1be..9f7f59d386 100644 --- a/server/user_config.go +++ b/server/user_config.go @@ -40,6 +40,7 @@ type UserConfig struct { SlackToken string `mapstructure:"slack-token"` SSLCertFile string `mapstructure:"ssl-cert-file"` SSLKeyFile string `mapstructure:"ssl-key-file"` + TFEHostname string `mapstructure:"tfe-hostname"` TFEToken string `mapstructure:"tfe-token"` DefaultTFVersion string `mapstructure:"default-tf-version"` Webhooks []WebhookConfig `mapstructure:"webhooks"`