// Copyright 2017 HootSuite Media Inc. // // Licensed under the Apache License, Version 2.0 (the License); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // http://www.apache.org/licenses/LICENSE-2.0 // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an AS IS BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified hereafter by contributors to runatlantis/atlantis. // // Package testdrive is used by the testdrive command as a quick-start of // Atlantis. package testdrive import ( "context" "fmt" "os" "os/exec" "os/signal" "regexp" "runtime" "strings" "sync" "syscall" "time" "github.com/briandowns/spinner" "github.com/google/go-github/v31/github" "github.com/mitchellh/colorstring" "github.com/pkg/errors" ) var terraformExampleRepoOwner = "runatlantis" var terraformExampleRepo = "atlantis-example" var bootstrapDescription = `Welcome to Atlantis testdrive! This mode sets up Atlantis on a test repo so you can try it out. We will - fork an example terraform project to your username - install terraform (if not already in your PATH) - install ngrok so we can expose Atlantis to GitHub - start Atlantis [bold]Press Ctrl-c at any time to exit ` var pullRequestBody = strings.Replace(` In this pull request we will learn how to use Atlantis. 1. In a couple of seconds you should see the output of Atlantis automatically running $terraform plan$. 1. You can manually run $plan$ by typing a comment: $$$ atlantis plan $$$ Usually you'll let Atlantis automatically run plan for you though. 1. To see all the comment commands available, type: $$$ atlantis help $$$ 1. To see the help for a specific command, for example $atlantis plan$, type: $$$ atlantis plan --help $$$ 1. Atlantis holds a "Lock" on this directory to prevent other pull requests modifying the Terraform state until this pull request is merged. To view the lock, go to the Atlantis UI: [http://localhost:4141](http://localhost:4141). If you wanted, you could manually delete the plan and lock from the UI if you weren't ready to apply. Instead, we will apply it! 1. To $terraform apply$ this change (which does nothing because it is creating a $null_resource$), type: $$$ atlantis apply $$$ **NOTE:** Because this example isn't using [remote state storage](https://www.terraform.io/docs/state/remote.html) the state will be lost once the pull request is merged. To use Atlantis properly, you **must** be using remote state. 1. Finally, merge the pull request to unlock this directory. Thank you for trying out Atlantis! Next, try using Atlantis on your own repositories: [www.runatlantis.io/guide/getting-started.html](https://www.runatlantis.io/guide/getting-started.html).`, "$", "`", -1) // Start begins the testdrive process. // nolint: errcheck func Start() error { s := spinner.New(spinner.CharSets[14], 100*time.Millisecond) colorstring.Println(bootstrapDescription) colorstring.Print("\n[bold]github.com username: ") fmt.Scanln(&githubUsername) if githubUsername == "" { return fmt.Errorf("please enter a valid github username") } colorstring.Println(` To continue, we need you to create a GitHub personal access token with [green]"repo" [reset]scope so we can fork an example terraform project. Follow these instructions to create a token (we don't store any tokens): [green]https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token#creating-a-token[reset] - use "atlantis" for the token description - add "repo" scope - copy the access token `) // Read github token, check for error later. colorstring.Print("[bold]GitHub access token (will be hidden): ") githubToken, _ = readPassword() tp := github.BasicAuthTransport{ Username: strings.TrimSpace(githubUsername), Password: strings.TrimSpace(githubToken), } githubClient := &Client{client: github.NewClient(tp.Client()), ctx: context.Background()} // Fork terraform example repo. colorstring.Println("\n=> forking repo ") s.Start() if err := githubClient.CreateFork(terraformExampleRepoOwner, terraformExampleRepo); err != nil { return errors.Wrapf(err, "forking repo %s/%s", terraformExampleRepoOwner, terraformExampleRepo) } if !githubClient.CheckForkSuccess(terraformExampleRepoOwner, terraformExampleRepo) { return fmt.Errorf("didn't find forked repo %s/%s. fork unsuccessful", terraformExampleRepoOwner, terraformExampleRepoOwner) } s.Stop() colorstring.Println("[green]=> fork completed![reset]") // Detect terraform and install it if not installed. _, err := exec.LookPath("terraform") if err != nil { colorstring.Println("[yellow]=> terraform not found in $PATH.[reset]") colorstring.Println("=> downloading terraform ") s.Start() terraformDownloadURL := fmt.Sprintf("%s/terraform/%s/terraform_%s_%s_%s.zip", hashicorpReleasesURL, terraformVersion, terraformVersion, runtime.GOOS, runtime.GOARCH) if err = downloadAndUnzip(terraformDownloadURL, "/tmp/terraform.zip", "/tmp"); err != nil { return errors.Wrapf(err, "downloading and unzipping terraform") } colorstring.Println("[green]=> downloaded terraform successfully![reset]") s.Stop() err = executeCmd("mv", "/tmp/terraform", "/usr/local/bin/") if err != nil { return errors.Wrapf(err, "moving terraform binary into /usr/local/bin") } colorstring.Println("[green]=> installed terraform successfully at /usr/local/bin[reset]") } else { colorstring.Println("[green]=> terraform found in $PATH![reset]") } // Download ngrok. colorstring.Println("=> downloading ngrok ") s.Start() ngrokURL := fmt.Sprintf("%s/ngrok-stable-%s-%s.zip", ngrokDownloadURL, runtime.GOOS, runtime.GOARCH) if err = downloadAndUnzip(ngrokURL, "/tmp/ngrok.zip", "/tmp"); err != nil { return errors.Wrapf(err, "downloading and unzipping ngrok") } s.Stop() colorstring.Println("[green]=> downloaded ngrok successfully![reset]") // Create ngrok tunnel. colorstring.Println("=> creating secure tunnel") s.Start() // We use a config file so we can set ngrok's API port (web_addr). We use // the API to get the public URL and if there's already ngrok running, it // will just choose a random API port and we won't be able to get the right // url. ngrokConfig := fmt.Sprintf(` web_addr: %s tunnels: atlantis: addr: %d bind_tls: true proto: http `, ngrokAPIURL, atlantisPort) ngrokConfigFile, err := os.CreateTemp("", "") if err != nil { return errors.Wrap(err, "creating ngrok config file") } err = os.WriteFile(ngrokConfigFile.Name(), []byte(ngrokConfig), 0600) if err != nil { return errors.Wrap(err, "writing ngrok config file") } // Used to ensure proper termination of all background commands. var wg sync.WaitGroup defer wg.Wait() tunnelReadyLog := regexp.MustCompile("client session established") tunnelTimeout := 20 * time.Second cancelNgrok, ngrokErrors, err := execAndWaitForStderr(&wg, tunnelReadyLog, tunnelTimeout, "/tmp/ngrok", "start", "atlantis", "--config", ngrokConfigFile.Name(), "--log", "stderr", "--log-format", "term") // Check if we got a fast error. Move on if we haven't (the command is still running). if err != nil { s.Stop() return errors.Wrap(err, "creating ngrok tunnel") } // When this function returns, ngrok tunnel should be stopped. defer cancelNgrok() // The tunnel is up! s.Stop() colorstring.Println("[green]=> started tunnel![reset]") // There's a 1s delay between tunnel starting and API being up. time.Sleep(1 * time.Second) tunnelURL, err := getTunnelAddr() if err != nil { return errors.Wrapf(err, "getting tunnel url") } // Start atlantis server. colorstring.Println("=> starting atlantis server") s.Start() tmpDir, err := os.MkdirTemp("", "") if err != nil { return errors.Wrap(err, "creating a temporary data directory for Atlantis") } defer os.RemoveAll(tmpDir) serverReadyLog := regexp.MustCompile("Atlantis started - listening on port 4141") serverReadyTimeout := 5 * time.Second cancelAtlantis, atlantisErrors, err := execAndWaitForStderr(&wg, serverReadyLog, serverReadyTimeout, os.Args[0], "server", "--gh-user", githubUsername, "--gh-token", githubToken, "--data-dir", tmpDir, "--atlantis-url", tunnelURL, "--repo-allowlist", fmt.Sprintf("github.com/%s/%s", githubUsername, terraformExampleRepo)) // Check if we got a fast error. Move on if we haven't (the command is still running). if err != nil { return errors.Wrap(err, "creating atlantis server") } // When this function returns atlantis server should be stopped. defer cancelAtlantis() colorstring.Printf("[green]=> atlantis server is now securely exposed at [bold][underline]%s\n[reset]", tunnelURL) fmt.Println("") // Create atlantis webhook. colorstring.Println("=> creating atlantis webhook") s.Start() err = githubClient.CreateWebhook(githubUsername, terraformExampleRepo, fmt.Sprintf("%s/events", tunnelURL)) if err != nil { return errors.Wrapf(err, "creating atlantis webhook") } s.Stop() colorstring.Println("[green]=> atlantis webhook created![reset]") // Create a new pr in the example repo. colorstring.Println("=> creating a new pull request") s.Start() pullRequestURL, err := githubClient.CreatePullRequest(githubUsername, terraformExampleRepo, "example", "master") if err != nil { return errors.Wrapf(err, "creating new pull request for repo %s/%s", githubUsername, terraformExampleRepo) } s.Stop() colorstring.Println("[green]=> pull request created![reset]") // Open new pull request in the browser. colorstring.Println("=> opening pull request") s.Start() time.Sleep(2 * time.Second) err = executeCmd("open", pullRequestURL) if err != nil { colorstring.Printf("[red]=> opening pull request failed. please go to: %s on the browser\n[reset]", pullRequestURL) } s.Stop() // Wait for ngrok and atlantis server process to finish. colorstring.Println("[_green_][light_green]atlantis is running [reset]") s.Start() colorstring.Println("[green] [press Ctrl-c to exit][reset]") // Wait for SIGINT or SIGTERM signals meaning the user has Ctrl-C'd the // testdrive process and want's to stop. signalChan := make(chan os.Signal, 1) signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) // Keep checking for errors from ngrok or atlantis server. Exit normally on shutdown signal. select { case <-signalChan: colorstring.Println("\n[red]shutdown signal received, exiting....[reset]") colorstring.Println("\n[green]Thank you for using atlantis :) \n[reset]For more information about how to use atlantis in production go to: https://www.runatlantis.io") return nil case err := <-ngrokErrors: return errors.Wrap(err, "ngrok tunnel") case err := <-atlantisErrors: return errors.Wrap(err, "atlantis server") } }