diff --git a/command/init.go b/command/init.go new file mode 100644 index 0000000000..2a9c6f45d9 --- /dev/null +++ b/command/init.go @@ -0,0 +1,108 @@ +package command + +import ( + "flag" + "fmt" + "os" + "strings" + + "github.com/hashicorp/terraform/config" + "github.com/hashicorp/terraform/config/module" +) + +// InitCommand is a Command implementation that takes a Terraform +// module and clones it to the working directory. +type InitCommand struct { + Meta +} + +func (c *InitCommand) Run(args []string) int { + args = c.Meta.process(args, false) + + cmdFlags := flag.NewFlagSet("init", flag.ContinueOnError) + cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } + if err := cmdFlags.Parse(args); err != nil { + return 1 + } + + var path string + args = cmdFlags.Args() + if len(args) > 2 { + c.Ui.Error("The init command expects at most two arguments.\n") + cmdFlags.Usage() + return 1 + } else if len(args) < 1 { + c.Ui.Error("The init command expects at least one arguments.\n") + cmdFlags.Usage() + return 1 + } + + if len(args) == 2 { + path = args[1] + } else { + var err error + path, err = os.Getwd() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error getting pwd: %s", err)) + } + } + + source := args[0] + + // Get our pwd since we need it + pwd, err := os.Getwd() + if err != nil { + c.Ui.Error(fmt.Sprintf( + "Error reading working directory: %s", err)) + return 1 + } + + // Verify the directory is empty + if empty, err := config.IsEmptyDir(path); err != nil { + c.Ui.Error(fmt.Sprintf( + "Error checking on destination path: %s", err)) + return 1 + } else if !empty { + c.Ui.Error( + "The destination path has Terraform configuration files. The\n" + + "init command can only be used on a directory without existing Terraform\n" + + "files.") + return 1 + } + + // Detect + source, err = module.Detect(source, pwd) + if err != nil { + c.Ui.Error(fmt.Sprintf( + "Error with module source: %s", err)) + return 1 + } + + // Get it! + if err := module.GetCopy(path, source); err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + return 0 +} + +func (c *InitCommand) Help() string { + helpText := ` +Usage: terraform init [options] SOURCE [PATH] + + Downloads the module given by SOURCE into the PATH. The PATH defaults + to the working directory. PATH must be empty of any Terraform files. + Any conflicting non-Terraform files will be overwritten. + + The module downloaded is a copy. If you're downloading a module from + Git, it will not preserve the Git history, it will only copy the + latest files. + +` + return strings.TrimSpace(helpText) +} + +func (c *InitCommand) Synopsis() string { + return "Initializes Terraform configuration from a module" +} diff --git a/command/init_test.go b/command/init_test.go new file mode 100644 index 0000000000..2db54b0477 --- /dev/null +++ b/command/init_test.go @@ -0,0 +1,102 @@ +package command + +import ( + "os" + "path/filepath" + "testing" + + "github.com/mitchellh/cli" +) + +func TestInit(t *testing.T) { + dir := tempDir(t) + + ui := new(cli.MockUi) + c := &InitCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(testProvider()), + Ui: ui, + }, + } + + args := []string{ + testFixturePath("init"), + dir, + } + if code := c.Run(args); code != 0 { + t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + } + + if _, err := os.Stat(filepath.Join(dir, "hello.tf")); err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestInit_cwd(t *testing.T) { + dir := tempDir(t) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("err: %s", err) + } + + // Change to the temporary directory + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("err: %s", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("err: %s", err) + } + defer os.Chdir(cwd) + + ui := new(cli.MockUi) + c := &InitCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(testProvider()), + Ui: ui, + }, + } + + args := []string{ + testFixturePath("init"), + } + if code := c.Run(args); code != 0 { + t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + } + + if _, err := os.Stat("hello.tf"); err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestInit_multipleArgs(t *testing.T) { + ui := new(cli.MockUi) + c := &InitCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(testProvider()), + Ui: ui, + }, + } + + args := []string{ + "bad", + "bad", + } + if code := c.Run(args); code != 1 { + t.Fatalf("bad: \n%s", ui.OutputWriter.String()) + } +} + +func TestInit_noArgs(t *testing.T) { + ui := new(cli.MockUi) + c := &InitCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(testProvider()), + Ui: ui, + }, + } + + args := []string{} + if code := c.Run(args); code != 1 { + t.Fatalf("bad: \n%s", ui.OutputWriter.String()) + } +} diff --git a/command/test-fixtures/init/hello.tf b/command/test-fixtures/init/hello.tf new file mode 100644 index 0000000000..e69de29bb2 diff --git a/commands.go b/commands.go index fa9294df37..a8251e547c 100644 --- a/commands.go +++ b/commands.go @@ -52,6 +52,12 @@ func init() { }, nil }, + "init": func() (cli.Command, error) { + return &command.InitCommand{ + Meta: meta, + }, nil + }, + "output": func() (cli.Command, error) { return &command.OutputCommand{ Meta: meta, diff --git a/config/loader.go b/config/loader.go index 5cc27e998c..740d0ec5f6 100644 --- a/config/loader.go +++ b/config/loader.go @@ -42,62 +42,10 @@ func Load(path string) (*Config, error) { // // Files are loaded in lexical order. func LoadDir(root string) (*Config, error) { - var files, overrides []string - - f, err := os.Open(root) - if err != nil { - return nil, err - } - - fi, err := f.Stat() + files, overrides, err := dirFiles(root) if err != nil { return nil, err } - if !fi.IsDir() { - return nil, fmt.Errorf( - "configuration path must be a directory: %s", - root) - } - - err = nil - for err != io.EOF { - var fis []os.FileInfo - fis, err = f.Readdir(128) - if err != nil && err != io.EOF { - f.Close() - return nil, err - } - - for _, fi := range fis { - // Ignore directories - if fi.IsDir() { - continue - } - - // Only care about files that are valid to load - name := fi.Name() - extValue := ext(name) - if extValue == "" { - continue - } - - // Determine if we're dealing with an override - nameNoExt := name[:len(name)-len(extValue)] - override := nameNoExt == "override" || - strings.HasSuffix(nameNoExt, "_override") - - path := filepath.Join(root, name) - if override { - overrides = append(overrides, path) - } else { - files = append(files, path) - } - } - } - - // Close the directory, we're done with it - f.Close() - if len(files) == 0 { return nil, fmt.Errorf( "No Terraform configuration files found in directory: %s", @@ -152,6 +100,21 @@ func LoadDir(root string) (*Config, error) { return result, nil } +// IsEmptyDir returns true if the directory given has no Terraform +// configuration files. +func IsEmptyDir(root string) (bool, error) { + if _, err := os.Stat(root); err != nil && os.IsNotExist(err) { + return true, nil + } + + fs, os, err := dirFiles(root) + if err != nil { + return false, err + } + + return len(fs) == 0 && len(os) == 0, nil +} + // Ext returns the Terraform configuration extension of the given // path, or a blank string if it is an invalid function. func ext(path string) string { @@ -163,3 +126,59 @@ func ext(path string) string { return "" } } + +func dirFiles(dir string) ([]string, []string, error) { + f, err := os.Open(dir) + if err != nil { + return nil, nil, err + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return nil, nil, err + } + if !fi.IsDir() { + return nil, nil, fmt.Errorf( + "configuration path must be a directory: %s", + dir) + } + + var files, overrides []string + err = nil + for err != io.EOF { + var fis []os.FileInfo + fis, err = f.Readdir(128) + if err != nil && err != io.EOF { + return nil, nil, err + } + + for _, fi := range fis { + // Ignore directories + if fi.IsDir() { + continue + } + + // Only care about files that are valid to load + name := fi.Name() + extValue := ext(name) + if extValue == "" { + continue + } + + // Determine if we're dealing with an override + nameNoExt := name[:len(name)-len(extValue)] + override := nameNoExt == "override" || + strings.HasSuffix(nameNoExt, "_override") + + path := filepath.Join(dir, name) + if override { + overrides = append(overrides, path) + } else { + files = append(files, path) + } + } + } + + return files, overrides, nil +} diff --git a/config/loader_test.go b/config/loader_test.go index f95235d66e..70dba0a166 100644 --- a/config/loader_test.go +++ b/config/loader_test.go @@ -6,6 +6,36 @@ import ( "testing" ) +func TestIsEmptyDir(t *testing.T) { + val, err := IsEmptyDir(fixtureDir) + if err != nil { + t.Fatalf("err: %s", err) + } + if val { + t.Fatal("should not be empty") + } +} + +func TestIsEmptyDir_noExist(t *testing.T) { + val, err := IsEmptyDir(filepath.Join(fixtureDir, "nopenopenope")) + if err != nil { + t.Fatalf("err: %s", err) + } + if !val { + t.Fatal("should be empty") + } +} + +func TestIsEmptyDir_noConfigs(t *testing.T) { + val, err := IsEmptyDir(filepath.Join(fixtureDir, "dir-empty")) + if err != nil { + t.Fatalf("err: %s", err) + } + if !val { + t.Fatal("should be empty") + } +} + func TestLoad_badType(t *testing.T) { _, err := Load(filepath.Join(fixtureDir, "bad_type.tf.nope")) if err == nil { diff --git a/config/module/copy_dir.go b/config/module/copy_dir.go index 6d4cb82017..981bba1ba7 100644 --- a/config/module/copy_dir.go +++ b/config/module/copy_dir.go @@ -4,17 +4,32 @@ import ( "io" "os" "path/filepath" + "strings" ) // copyDir copies the src directory contents into dst. Both directories // should already exist. func copyDir(dst, src string) error { + src, err := filepath.EvalSymlinks(src) + if err != nil { + return err + } + walkFn := func(path string, info os.FileInfo, err error) error { if err != nil { return err } + if path == src { + return nil + } + + basePath := filepath.Base(path) + if strings.HasPrefix(basePath, ".") { + // Skip any dot files + return nil + } - dstPath := filepath.Join(dst, filepath.Base(path)) + dstPath := filepath.Join(dst, basePath) // If we have a directory, make that subdirectory, then continue // the walk. diff --git a/config/module/detect.go b/config/module/detect.go index 99b2af0a73..bdf54f9f80 100644 --- a/config/module/detect.go +++ b/config/module/detect.go @@ -3,6 +3,7 @@ package module import ( "fmt" "net/url" + "path/filepath" ) // Detector defines the interface that an invalid URL or a URL with a blank @@ -34,6 +35,9 @@ func init() { func Detect(src string, pwd string) (string, error) { getForce, getSrc := getForcedGetter(src) + // Separate out the subdir if there is one, we don't pass that to detect + getSrc, subDir := getDirSubdir(getSrc) + u, err := url.Parse(getSrc) if err == nil && u.Scheme != "" { // Valid URL @@ -51,6 +55,25 @@ func Detect(src string, pwd string) (string, error) { var detectForce string detectForce, result = getForcedGetter(result) + result, detectSubdir := getDirSubdir(result) + + // If we have a subdir from the detection, then prepend it to our + // requested subdir. + if detectSubdir != "" { + if subDir != "" { + subDir = filepath.Join(detectSubdir, subDir) + } else { + subDir = detectSubdir + } + } + if subDir != "" { + u, err := url.Parse(result) + if err != nil { + return "", fmt.Errorf("Error parsing URL: %s", err) + } + u.Path += "//" + subDir + result = u.String() + } // Preserve the forced getter if it exists. We try to use the // original set force first, followed by any force set by the diff --git a/config/module/detect_test.go b/config/module/detect_test.go index 8f62f6618b..69a5a6fef7 100644 --- a/config/module/detect_test.go +++ b/config/module/detect_test.go @@ -13,7 +13,24 @@ func TestDetect(t *testing.T) { }{ {"./foo", "/foo", "file:///foo/foo", false}, {"git::./foo", "/foo", "git::file:///foo/foo", false}, - {"git::github.com/hashicorp/foo", "", "git::https://github.com/hashicorp/foo.git", false}, + { + "git::github.com/hashicorp/foo", + "", + "git::https://github.com/hashicorp/foo.git", + false, + }, + { + "./foo//bar", + "/foo", + "file:///foo/foo//bar", + false, + }, + { + "git::github.com/hashicorp/foo//bar", + "", + "git::https://github.com/hashicorp/foo.git//bar", + false, + }, } for i, tc := range cases { diff --git a/config/module/get.go b/config/module/get.go index cd553f2671..14f105548c 100644 --- a/config/module/get.go +++ b/config/module/get.go @@ -3,8 +3,11 @@ package module import ( "bytes" "fmt" + "io/ioutil" "net/url" + "os" "os/exec" + "path/filepath" "regexp" "strings" "syscall" @@ -51,6 +54,24 @@ func Get(dst, src string) error { var force string force, src = getForcedGetter(src) + // If there is a subdir component, then we download the root separately + // and then copy over the proper subdir. + var realDst string + src, subDir := getDirSubdir(src) + if subDir != "" { + tmpDir, err := ioutil.TempDir("", "tf") + if err != nil { + return err + } + if err := os.RemoveAll(tmpDir); err != nil { + return err + } + defer os.RemoveAll(tmpDir) + + realDst = dst + dst = subDir + } + u, err := url.Parse(src) if err != nil { return err @@ -68,9 +89,52 @@ func Get(dst, src string) error { err = g.Get(dst, u) if err != nil { err = fmt.Errorf("error downloading module '%s': %s", src, err) + return err + } + + // If we have a subdir, copy that over + if subDir != "" { + if err := os.RemoveAll(realDst); err != nil { + return err + } + if err := os.MkdirAll(realDst, 0755); err != nil { + return err + } + + return copyDir(realDst, filepath.Join(dst, subDir)) + } + + return nil +} + +// GetCopy is the same as Get except that it downloads a copy of the +// module represented by source. +// +// This copy will omit and dot-prefixed files (such as .git/, .hg/) and +// can't be updated on its own. +func GetCopy(dst, src string) error { + // Create the temporary directory to do the real Get to + tmpDir, err := ioutil.TempDir("", "tf") + if err != nil { + return err + } + if err := os.RemoveAll(tmpDir); err != nil { + return err + } + defer os.RemoveAll(tmpDir) + + // Get to that temporary dir + if err := Get(tmpDir, src); err != nil { + return err + } + + // Make sure the destination exists + if err := os.MkdirAll(dst, 0755); err != nil { + return err } - return err + // Copy to the final location + return copyDir(dst, tmpDir) } // getRunCommand is a helper that will run a command and capture the output diff --git a/config/module/get_test.go b/config/module/get_test.go index 85488577db..cf34f1ae8a 100644 --- a/config/module/get_test.go +++ b/config/module/get_test.go @@ -46,6 +46,34 @@ func TestGet_fileForced(t *testing.T) { } } +func TestGet_fileSubdir(t *testing.T) { + dst := tempDir(t) + u := testModule("basic//subdir") + + if err := Get(dst, u); err != nil { + t.Fatalf("err: %s", err) + } + + mainPath := filepath.Join(dst, "sub.tf") + if _, err := os.Stat(mainPath); err != nil { + t.Fatalf("err: %s", err) + } +} + +func TestGetCopy_file(t *testing.T) { + dst := tempDir(t) + u := testModule("basic") + + if err := GetCopy(dst, u); err != nil { + t.Fatalf("err: %s", err) + } + + mainPath := filepath.Join(dst, "main.tf") + if _, err := os.Stat(mainPath); err != nil { + t.Fatalf("err: %s", err) + } +} + func TestGetDirSubdir(t *testing.T) { cases := []struct { Input string diff --git a/website/source/docs/commands/init.html.markdown b/website/source/docs/commands/init.html.markdown new file mode 100644 index 0000000000..a157a047a7 --- /dev/null +++ b/website/source/docs/commands/init.html.markdown @@ -0,0 +1,24 @@ +--- +layout: "docs" +page_title: "Command: init" +sidebar_current: "docs-commands-init" +--- + +# Command: init + +The `terraform init` command is used to initialize a Terraform configuration +using another +[module](/docs/modules/index.html) +as a skeleton. + +## Usage + +Usage: `terraform init [options] SOURCE [DIR]` + +Init will download the module from SOURCE and copy it into the DIR +(which defaults to the current working directory). Version control +information from the module (such as Git history) will not be copied. + +The directory being initialized must be empty of all Terraform configurations. +If the module has other files which conflict with what is already in the +directory, they _will be overwritten_. diff --git a/website/source/layouts/docs.erb b/website/source/layouts/docs.erb index 3b1c92fe60..9536b29406 100644 --- a/website/source/layouts/docs.erb +++ b/website/source/layouts/docs.erb @@ -63,6 +63,10 @@ graph + > + init + + > output