Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cache temporary folder #114

Merged
merged 9 commits into from
Feb 1, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 1 addition & 48 deletions cli/cli_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import (
"github.com/urfave/cli"
"github.com/gruntwork-io/terragrunt/options"
"github.com/gruntwork-io/terragrunt/spin"
"io/ioutil"
)

const OPT_TERRAGRUNT_CONFIG = "terragrunt-config"
Expand Down Expand Up @@ -128,7 +127,7 @@ func runTerragrunt(terragruntOptions *options.TerragruntOptions) error {
}

if sourceUrl, hasSourceUrl := getTerraformSourceUrl(terragruntOptions, conf); hasSourceUrl {
if err := checkoutTerraformSource(sourceUrl, terragruntOptions); err != nil {
if err := downloadTerraformSource(sourceUrl, terragruntOptions); err != nil {
return err
}
}
Expand All @@ -151,19 +150,6 @@ func runTerragrunt(terragruntOptions *options.TerragruntOptions) error {
return runTerraformCommandWithLock(conf.Lock, terragruntOptions)
}

// There are two ways a user can tell Terragrunt that it needs to download Terraform configurations from a specific
// URL: via a command-line option or via an entry in the .terragrunt config file. If the user used one of these, this
// method returns the source URL and the boolean true; if not, this method returns an empty string and false.
func getTerraformSourceUrl(terragruntOptions *options.TerragruntOptions, terragruntConfig *config.TerragruntConfig) (string, bool) {
if terragruntOptions.Source != "" {
return terragruntOptions.Source, true
} else if terragruntConfig.Terraform != nil && terragruntConfig.Terraform.Source != "" {
return terragruntConfig.Terraform.Source, true
} else {
return "", false
}
}

// Returns true if the command the user wants to execute is supposed to affect multiple Terraform modules, such as the
// spin-up or tear-down command.
func isMultiModuleCommand(command string) bool {
Expand Down Expand Up @@ -233,39 +219,6 @@ func configureRemoteState(remoteState *remote.RemoteState, terragruntOptions *op
return nil
}

// 1. Check out the given source URL, which should use Terraform's module source syntax, into a temporary folder
// 2. Copy the contents of terragruntOptions.WorkingDir into the temporary folder.
// 3. Set terragruntOptions.WorkingDir to the temporary folder.
func checkoutTerraformSource(source string, terragruntOptions *options.TerragruntOptions) error {
tmpFolder, err := ioutil.TempDir("", "terragrunt-tmp-checkout")
if err != nil {
return errors.WithStackTrace(err)
}

terragruntOptions.Logger.Printf("Downloading Terraform configurations from %s into %s", source, tmpFolder)
if err := terraformInit(source, tmpFolder, terragruntOptions); err != nil {
return err
}

terragruntOptions.Logger.Printf("Copying files from %s into %s", terragruntOptions.WorkingDir, tmpFolder)
if err := util.CopyFolderContents(terragruntOptions.WorkingDir, tmpFolder); err != nil {
return err
}

terragruntOptions.Logger.Printf("Setting working directory to %s", tmpFolder)
terragruntOptions.WorkingDir = tmpFolder

return nil
}

// Download the code from source into dest using the terraform init command
func terraformInit(source string, dest string, terragruntOptions *options.TerragruntOptions) error {
terragruntInitOptions := terragruntOptions.Clone(terragruntOptions.TerragruntConfigPath)
terragruntInitOptions.TerraformCliArgs = []string{"init", source, dest}

return runTerraformCommand(terragruntInitOptions)
}

// Run the given Terraform command with the given lock (if the command requires locking)
func runTerraformCommandWithLock(lock locks.Lock, terragruntOptions *options.TerragruntOptions) error {
switch firstArg(terragruntOptions.TerraformCliArgs) {
Expand Down
260 changes: 260 additions & 0 deletions cli/download_source.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
package cli

import (
"github.com/gruntwork-io/terragrunt/options"
"github.com/gruntwork-io/terragrunt/util"
"github.com/gruntwork-io/terragrunt/config"
"os"
"github.com/gruntwork-io/terragrunt/errors"
"path/filepath"
"github.com/hashicorp/go-getter"
urlhelper "github.com/hashicorp/go-getter/helper/url"
"io/ioutil"
"net/url"
"fmt"
)

// This struct represents information about Terraform source code that needs to be downloaded
type TerraformSource struct {
// A canonical version of RawSource, in URL format
CanonicalSourceURL *url.URL

// The folder where we should download the source to
DownloadDir string

// The path to a file in DownloadDir that stores the version number of the code
VersionFile string
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be helpful to add a comment on what this struct as a whole represents.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added: 6de18ef

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be helpful to add a comment on what this struct as a whole represents. It took me a minute to figure out this represents a set of "source code" for Terraform.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added: 6de18ef


func (src *TerraformSource) String() string {
return fmt.Sprintf("TerraformSource{CanonicalSourceURL = %v, DownloadDir = %v, VersionFile = %v}", src.CanonicalSourceURL, src.DownloadDir, src.VersionFile)
}

// 1. Download the given source URL, which should use Terraform's module source syntax, into a temporary folder
// 2. Copy the contents of terragruntOptions.WorkingDir into the temporary folder.
// 3. Set terragruntOptions.WorkingDir to the temporary folder.
//
// See the processTerraformSource method for how we determine the temporary folder so we can reuse it across multiple
// runs of Terragrunt to avoid downloading everything from scratch every time.
func downloadTerraformSource(source string, terragruntOptions *options.TerragruntOptions) error {
terraformSource, err := processTerraformSource(source, terragruntOptions)
if err != nil {
return err
}

if err := downloadTerraformSourceIfNecessary(terraformSource, terragruntOptions); err != nil {
return err
}

terragruntOptions.Logger.Printf("Copying files from %s into %s", terragruntOptions.WorkingDir, terraformSource.DownloadDir)
if err := util.CopyFolderContents(terragruntOptions.WorkingDir, terraformSource.DownloadDir); err != nil {
return err
}

terragruntOptions.Logger.Printf("Setting working directory to %s", terraformSource.DownloadDir)
terragruntOptions.WorkingDir = terraformSource.DownloadDir

return nil
}

// Download the specified TerraformSource if the latest code hasn't already been downloaded.
func downloadTerraformSourceIfNecessary(terraformSource *TerraformSource, terragruntOptions *options.TerragruntOptions) error {
alreadyLatest, err := alreadyHaveLatestCode(terraformSource)
if err != nil {
return err
}

if alreadyLatest {
terragruntOptions.Logger.Printf("Terraform files in %s are up to date. Will not download again.", terraformSource.DownloadDir)
return nil
}

if err := cleanupTerraformFiles(terraformSource.DownloadDir, terragruntOptions); err != nil {
return err
}

if err := terraformInit(terraformSource, terragruntOptions); err != nil {
return err
}

if err := writeVersionFile(terraformSource); err != nil {
return err
}

return nil
}

// Returns true if the specified TerraformSource, of the exact same version, has already been downloaded into the
// DownloadFolder. This helps avoid downloading the same code multiple times. Note that if the TerraformSource points
// to a local file path, we assume the user is doing local development and always return false to ensure the latest
// code is downloaded (or rather, copied) every single time. See the processTerraformSource method for more info.
func alreadyHaveLatestCode(terraformSource *TerraformSource) (bool, error) {
if isLocalSource(terraformSource.CanonicalSourceURL) ||
!util.FileExists(terraformSource.DownloadDir) ||
!util.FileExists(terraformSource.VersionFile) {

return false, nil
}

currentVersion := encodeSourceVersion(terraformSource.CanonicalSourceURL)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the definition of encodeSourceVersion(), you appear to be hashing the URL. But what if the URL is the same but its contents are different? This is the case, for example, when we re-release the same git tag. I believe this is the thought behind the -update in terraform get -update.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I considered this. In the future, if this is a common case, we could add a terragrunt-update or similar flag. For now, the trivial workaround is to delete the relevant tmp folder manually. My hope, however, is that this is a relatively rare case.

previousVersion, err := readVersionFile(terraformSource)

if err != nil {
return false, err
}

return previousVersion == currentVersion, nil
}

// Return the version number stored in the DownloadDir. This version number can be used to check if the Terraform code
// that has already been downloaded is the same as the version the user is currently requesting. The version number is
// calculated using the encodeSourceVersion method.
func readVersionFile(terraformSource *TerraformSource) (string, error) {
return util.ReadFileAsString(terraformSource.VersionFile)
}

// Write a file into the DownloadDir that contains the version number of this source code. The version number is
// calculated using the encodeSourceVersion method.
func writeVersionFile(terraformSource *TerraformSource) error {
version := encodeSourceVersion(terraformSource.CanonicalSourceURL)
return errors.WithStackTrace(ioutil.WriteFile(terraformSource.VersionFile, []byte(version), 0640))
}

// Take the given source path and create a TerraformSource struct from it, including the folder where the source should
// be downloaded to. Our goal is to reuse the download folder for the same source URL between Terragrunt runs.
// Otherwise, for every Terragrunt command, you'd have to wait for Terragrunt to download your Terraform code, download
// that code's dependencies (terraform get), and configure remote state (terraform remote config), which is very slow.
//
// To maximize reuse, given a working directory w and a source URL s, we download the code into the folder /T/W/S where:
//
// 1. T is the OS temp dir (e.g. /tmp).
// 2. W is the base 64 encoded sha1 hash of w. This ensures that if you are running Terragrunt concurrently in
// multiple folders (e.g. during automated tests), then even if those folders are using the same source URL s, they
// do not overwrite each other.
// 3. S is the base 64 encoded sha1 of s without its query string. For remote source URLs (e.g. Git
// URLs), this is based on the assumption that the scheme/host/path of the URL
// (e.g. git::github.com/foo/bar//some-module) identifies the module name, and we always want to download the same
// module name into the same folder (see the encodeSourceName method). We also assume the version of the module is
// stored in the query string (e.g. ref=v0.0.3), so we store the base 64 encoded sha1 of the query string in a
// file called .terragrunt-source-version within S.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/S is the base 64 encoded sha1 has of s/S is the base 64 encoded sha1 of s/

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: f937e39

//
// The downloadTerraformSourceIfNecessary decides when we should download the Terraform code and when not to. It uses
// the following rules:
//
// 1. Always download source URLs pointing to local file paths.
// 2. Only download source URLs pointing to remote paths if /T/W/S doesn't already exist or, if it does exist, if the
// version number in /T/W/S/.terragrunt-source-version doesn't match the current version.
func processTerraformSource(source string, terragruntOptions *options.TerragruntOptions) (*TerraformSource, error) {
canonicalWorkingDir, err := util.CanonicalPath(terragruntOptions.WorkingDir, "")
if err != nil {
return nil, err
}

rawSourceUrl, err := getter.Detect(source, canonicalWorkingDir, getter.Detectors)
if err != nil {
return nil, errors.WithStackTrace(err)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call using go-getter to normalize URLs. Looks like a great library.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's actually the library Terraform/Packer/etc use to download files, including the terraform init command.


canonicalSourceUrl, err := urlhelper.Parse(rawSourceUrl)
if err != nil {
return nil, errors.WithStackTrace(err)
}

if isLocalSource(canonicalSourceUrl) {
// Always use canonical file paths for local source folders, rather than relative paths, to ensure
// that the same local folder always maps to the same download folder, no matter how the local folder
// path is specified
canonicalFilePath, err := util.CanonicalPath(canonicalSourceUrl.Path, "")
if err != nil {
return nil, err
}
canonicalSourceUrl.Path = canonicalFilePath
}

moduleName, err := encodeSourceName(canonicalSourceUrl)
if err != nil {
return nil, err
}

encodedWorkingDir := util.EncodeBase64Sha1(canonicalWorkingDir)
downloadDir := filepath.Join(os.TempDir(), "terragrunt-download", encodedWorkingDir, moduleName)
versionFile := filepath.Join(downloadDir, ".terragrunt-source-version")

return &TerraformSource{
CanonicalSourceURL: canonicalSourceUrl,
DownloadDir: downloadDir,
VersionFile: versionFile,
}, nil
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, lots of nuance here. Nicely handled.


// Encode a version number for the given source URL. When calculating a version number, we simply take the query
// string of the source URL, calculate its sha1, and base 64 encode it. For remote URLs (e.g. Git URLs), this is
// based on the assumption that the scheme/host/path of the URL (e.g. git::github.com/foo/bar//some-module) identifies
// the module name and the query string (e.g. ?ref=v0.0.3) identifies the version. For local file paths, there is no
// query string, so the same file path (/foo/bar) is always considered the same version. See also the encodeSourceName
// and processTerraformSource methods.
func encodeSourceVersion(sourceUrl *url.URL) string {
return util.EncodeBase64Sha1(sourceUrl.Query().Encode())
}

// Encode a the module name for the given source URL. When calculating a module name, we calculate the base 64 encoded
// sha1 of the entire source URL without the query string. For remote URLs (e.g. Git URLs), this is based on the
// assumption that the scheme/host/path of the URL (e.g. git::github.com/foo/bar//some-module) identifies
// the module name and the query string (e.g. ?ref=v0.0.3) identifies the version. For local file paths, there is no
// query string, so the same file path (/foo/bar) is always considered the same version. See also the encodeSourceVersion
// and processTerraformSource methods.
func encodeSourceName(sourceUrl *url.URL) (string, error) {
sourceUrlNoQuery, err := urlhelper.Parse(sourceUrl.String())
if err != nil {
return "", errors.WithStackTrace(err)
}

sourceUrlNoQuery.RawQuery = ""

return util.EncodeBase64Sha1(sourceUrlNoQuery.String()), nil
}

// Returns true if the given URL refers to a path on the local file system
func isLocalSource(sourceUrl *url.URL) bool {
return sourceUrl.Scheme == "file"
}

// If this temp folder already exists, simply delete all the Terraform configurations (*.tf) within it
// (the terraform init command will redownload the latest ones), but leave all the other files, such
// as the .terraform folder with the downloaded modules and remote state settings.
func cleanupTerraformFiles(path string, terragruntOptions *options.TerragruntOptions) error {
if !util.FileExists(path) {
return nil
}

terragruntOptions.Logger.Printf("Cleaning up existing *.tf files in %s", path)

files, err := filepath.Glob(filepath.Join(path, "*.tf"))
if err != nil {
return errors.WithStackTrace(err)
}
return util.DeleteFiles(files)
}

// There are two ways a user can tell Terragrunt that it needs to download Terraform configurations from a specific
// URL: via a command-line option or via an entry in the .terragrunt config file. If the user used one of these, this
// method returns the source URL and the boolean true; if not, this method returns an empty string and false.
func getTerraformSourceUrl(terragruntOptions *options.TerragruntOptions, terragruntConfig *config.TerragruntConfig) (string, bool) {
if terragruntOptions.Source != "" {
return terragruntOptions.Source, true
} else if terragruntConfig.Terraform != nil && terragruntConfig.Terraform.Source != "" {
return terragruntConfig.Terraform.Source, true
} else {
return "", false
}
}

// Download the code from the Canonical Source URL into the Download Folder using the terraform init command
func terraformInit(terraformSource *TerraformSource, terragruntOptions *options.TerragruntOptions) error {
terragruntOptions.Logger.Printf("Downloading Terraform configurations from %s into %s", terraformSource.CanonicalSourceURL, terraformSource.DownloadDir)

terragruntInitOptions := terragruntOptions.Clone(terragruntOptions.TerragruntConfigPath)
terragruntInitOptions.TerraformCliArgs = []string{"init", terraformSource.CanonicalSourceURL.String(), terraformSource.DownloadDir}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider reviewing hashicorp/terraform#11286 for forward-compatibility.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like init may be changed in a backwards incompatible way to be an interactive command. Not sure I can do much about that for now. One alternative is to try to use go-getter directly to reimplement the download functionality, but I'd rather wait and see if with the next version of Terraform, we can leverage init with special flags to do what we need.


return runTerraformCommand(terragruntInitOptions)
}
Loading