Merge pull request #16160 from hashicorp/jbardin/get-subdir

Handle module source subdirectories in Terraform
This commit is contained in:
James Bardin 2017-09-26 09:23:19 -04:00 committed by GitHub
commit d78b575536
11 changed files with 383 additions and 39 deletions

View File

@ -16,11 +16,29 @@ import (
version "github.com/hashicorp/go-version"
)
// map of module names and version for test module.
// only one version for now, as we only lookup latest from the registry
var testMods = map[string]string{
"registry/foo/bar": "0.2.3",
"registry/foo/baz": "1.10.0",
// Map of module names and location of test modules.
// Only one version for now, as we only lookup latest from the registry.
type testMod struct {
location string
version string
}
// All the locationes from the mockRegistry start with a file:// scheme. If
// the the location string here doesn't have a scheme, the mockRegistry will
// find the absolute path and return a complete URL.
var testMods = map[string]testMod{
"registry/foo/bar": {
location: "file:///download/registry/foo/bar/0.2.3//*?archive=tar.gz",
version: "0.2.3",
},
"registry/foo/baz": {
location: "file:///download/registry/foo/baz/1.10.0//*?archive=tar.gz",
version: "1.10.0",
},
"registry/local/sub": {
location: "test-fixtures/registry-tar-subdir/foo.tgz//*?archive=tar.gz",
version: "0.1.2",
},
}
func latestVersion(versions []string) string {
@ -56,13 +74,19 @@ func mockRegistry() *httptest.Server {
return
}
version, ok := testMods[matches[1]]
mod, ok := testMods[matches[1]]
if !ok {
w.WriteHeader(http.StatusNotFound)
return
}
location := fmt.Sprintf("%s/download/%s/%s", server.URL, matches[1], version)
location := mod.location
if !strings.HasPrefix(location, "file:///") {
// we can't use filepath.Abs because it will clean `//`
wd, _ := os.Getwd()
location = fmt.Sprintf("file://%s/%s", wd, location)
}
w.Header().Set(xTerraformGet, location)
w.WriteHeader(http.StatusNoContent)
// no body
@ -90,12 +114,12 @@ func TestDetectRegistry(t *testing.T) {
}{
{
source: "registry/foo/bar",
location: "download/registry/foo/bar/0.2.3",
location: testMods["registry/foo/bar"].location,
found: true,
},
{
source: "registry/foo/baz",
location: "download/registry/foo/baz/1.10.0",
location: testMods["registry/foo/baz"].location,
found: true,
},
// this should not be found, but not stop detection
@ -177,7 +201,7 @@ func TestDetectors(t *testing.T) {
}{
{
source: "registry/foo/bar",
location: "download/registry/foo/bar/0.2.3",
location: "file:///download/registry/foo/bar/0.2.3//*?archive=tar.gz",
},
// this should not be found, but not stop detection
{
@ -248,6 +272,53 @@ func TestDetectors(t *testing.T) {
}
}
// GitHub archives always contain the module source in a single subdirectory,
// so the registry will return a path with with a `//*` suffix. We need to make
// sure this doesn't intefere with our internal handling of `//` subdir.
func TestRegistryGitHubArchive(t *testing.T) {
server := mockRegistry()
defer server.Close()
regDetector := &registryDetector{
api: server.URL + "/v1/modules/",
client: server.Client(),
}
origDetectors := detectors
defer func() {
detectors = origDetectors
}()
detectors = []getter.Detector{
new(getter.GitHubDetector),
new(getter.BitBucketDetector),
new(getter.S3Detector),
new(localDetector),
regDetector,
}
storage := testStorage(t)
tree := NewTree("", testConfig(t, "registry-tar-subdir"))
if err := tree.Load(storage, GetModeGet); err != nil {
t.Fatalf("err: %s", err)
}
if !tree.Loaded() {
t.Fatal("should be loaded")
}
if err := tree.Load(storage, GetModeNone); err != nil {
t.Fatalf("err: %s", err)
}
actual := strings.TrimSpace(tree.String())
expected := strings.TrimSpace(treeLoadSubdirStr)
if actual != expected {
t.Fatalf("got: \n\n%s\nexpected: \n\n%s", actual, expected)
}
}
func TestAccRegistryDiscover(t *testing.T) {
if os.Getenv("TF_ACC") == "" {
t.Skip("skipping ACC test")
@ -272,3 +343,30 @@ func TestAccRegistryDiscover(t *testing.T) {
t.Fatalf("url doesn't contain 'consul': %s", u.String())
}
}
func TestAccRegistryLoad(t *testing.T) {
if os.Getenv("TF_ACC") == "" {
t.Skip("skipping ACC test")
}
storage := testStorage(t)
tree := NewTree("", testConfig(t, "registry-load"))
if err := tree.Load(storage, GetModeGet); err != nil {
t.Fatalf("err: %s", err)
}
if !tree.Loaded() {
t.Fatal("should be loaded")
}
if err := tree.Load(storage, GetModeNone); err != nil {
t.Fatalf("err: %s", err)
}
// TODO expand this further by fetching some metadata from the registry
actual := strings.TrimSpace(tree.String())
if !strings.Contains(actual, "(path: vault)") {
t.Fatal("missing vault module, got:\n", actual)
}
}

View File

@ -2,6 +2,7 @@ package module
import (
"io/ioutil"
"log"
"os"
"path/filepath"
"testing"
@ -10,6 +11,12 @@ import (
"github.com/hashicorp/terraform/config"
)
func init() {
if os.Getenv("TF_LOG") == "" {
log.SetOutput(ioutil.Discard)
}
}
const fixtureDir = "./test-fixtures"
func tempDir(t *testing.T) string {

Binary file not shown.

View File

@ -0,0 +1,3 @@
module "foo" {
source = "./foo.tgz//sub"
}

View File

@ -0,0 +1,3 @@
module "vault" {
source = "hashicorp/vault/aws"
}

View File

@ -0,0 +1,4 @@
module "foo" {
// the mock test registry will redirect this to the local tar file
source = "registry/local/sub"
}

View File

@ -0,0 +1,4 @@
module "foo" {
// the module in sub references sibling module baz via "../baz"
source = "./foo.tgz//sub"
}

View File

@ -3,7 +3,12 @@ package module
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"os"
"path/filepath"
"strings"
"sync"
@ -176,13 +181,73 @@ func (t *Tree) Load(s getter.Storage, mode GetMode) error {
copy(path, t.path)
path = append(path, m.Name)
source, err := getter.Detect(m.Source, t.config.Dir, detectors)
// The key is the string that will be hashed to uniquely id the Source.
// The leading digit can be incremented to force re-fetch all existing
// modules.
key := fmt.Sprintf("0.root.%s-%s", strings.Join(path, "."), m.Source)
log.Printf("[TRACE] module source %q", m.Source)
// Split out the subdir if we have one.
// Terraform keeps the entire requested tree for now, so that modules can
// reference sibling modules from the same archive or repo.
source, subDir := getter.SourceDirSubdir(m.Source)
// First check if we we need to download anything.
// This is also checked by the getter.Storage implementation, but we
// want to be able to short-circuit the detection as well, since some
// detectors may need to make external calls.
dir, found, err := s.Dir(key)
if err != nil {
return err
}
// looks like we already have it
// In order to load the Tree we need to find out if there was another
// subDir stored from discovery.
if found && mode != GetModeUpdate {
subDir, err := t.getSubdir(dir)
if err != nil {
// If there's a problem with the subdir record, we'll let the
// recordSubdir method fix it up. Any other errors filesystem
// errors will turn up again below.
log.Println("[WARN] error reading subdir record:", err)
} else {
dir := filepath.Join(dir, subDir)
// Load the configurations.Dir(source)
children[m.Name], err = NewTreeModule(m.Name, dir)
if err != nil {
return fmt.Errorf("module %s: %s", m.Name, err)
}
// Set the path of this child
children[m.Name].path = path
continue
}
}
log.Printf("[TRACE] module source: %q", source)
source, err = getter.Detect(source, t.config.Dir, detectors)
if err != nil {
return fmt.Errorf("module %s: %s", m.Name, err)
}
// Get the directory where this module is so we can load it
key := strings.Join(path, ".")
key = fmt.Sprintf("module.%s-%s", key, m.Source)
log.Printf("[TRACE] detected module source %q", source)
// Check if the detector introduced something new.
// For example, the registry always adds a subdir of `//*`,
// indicating that we need to strip off the first component from the
// tar archive, though we may not yet know what it is called.
//
// TODO: This can cause us to lose the previously detected subdir. It
// was never an issue before, since none of the supported detectors
// previously had this behavior, but we may want to add this ability to
// registry modules.
source, subDir2 := getter.SourceDirSubdir(source)
if subDir2 != "" {
subDir = subDir2
}
log.Printf("[TRACE] getting module source %q", source)
dir, ok, err := getStorage(s, key, source, mode)
if err != nil {
@ -193,12 +258,31 @@ func (t *Tree) Load(s getter.Storage, mode GetMode) error {
"module %s: not found, may need to be downloaded using 'terraform get'", m.Name)
}
children[m.Name], err = NewTreeModule(m.Name, dir)
if err != nil {
return fmt.Errorf(
"module %s: %s", m.Name, err)
// expand and record the subDir for later
if subDir != "" {
fullDir, err := getter.SubdirGlob(dir, subDir)
if err != nil {
return err
}
// +1 to account for the pathsep
if len(dir)+1 > len(fullDir) {
return fmt.Errorf("invalid module storage path %q", fullDir)
}
subDir = fullDir[len(dir)+1:]
if err := t.recordSubdir(dir, subDir); err != nil {
return err
}
dir = fullDir
}
// Load the configurations.Dir(source)
children[m.Name], err = NewTreeModule(m.Name, dir)
if err != nil {
return fmt.Errorf("module %s: %s", m.Name, err)
}
// Set the path of this child
children[m.Name].path = path
}
@ -216,6 +300,65 @@ func (t *Tree) Load(s getter.Storage, mode GetMode) error {
return nil
}
func subdirRecordsPath(dir string) string {
const filename = "module-subdir.json"
// Get the parent directory.
// The current FolderStorage implementation needed to be able to create
// this directory, so we can be reasonably certain we can use it.
parent := filepath.Dir(filepath.Clean(dir))
return filepath.Join(parent, filename)
}
// unmarshal the records file in the parent directory. Always returns a valid map.
func loadSubdirRecords(dir string) (map[string]string, error) {
records := map[string]string{}
recordsPath := subdirRecordsPath(dir)
data, err := ioutil.ReadFile(recordsPath)
if err != nil && !os.IsNotExist(err) {
return records, err
}
if len(data) == 0 {
return records, nil
}
if err := json.Unmarshal(data, &records); err != nil {
return records, err
}
return records, nil
}
func (t *Tree) getSubdir(dir string) (string, error) {
records, err := loadSubdirRecords(dir)
if err != nil {
return "", err
}
return records[dir], nil
}
// Mark the location of a detected subdir in a top-level file so we
// can skip detection when not updating the module.
func (t *Tree) recordSubdir(dir, subdir string) error {
records, err := loadSubdirRecords(dir)
if err != nil {
// if there was a problem with the file, we will attempt to write a new
// one. Any non-data related error should surface there.
log.Printf("[WARN] error reading subdir records: %s", err)
}
records[dir] = subdir
js, err := json.Marshal(records)
if err != nil {
return err
}
recordsPath := subdirRecordsPath(dir)
return ioutil.WriteFile(recordsPath, js, 0644)
}
// Path is the full path to this tree.
func (t *Tree) Path() []string {
return t.path

View File

@ -2,7 +2,9 @@ package module
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
@ -209,40 +211,120 @@ func TestTreeLoad_parentRef(t *testing.T) {
}
func TestTreeLoad_subdir(t *testing.T) {
storage := testStorage(t)
tree := NewTree("", testConfig(t, "basic-subdir"))
fixtures := []string{
"basic-subdir",
"basic-tar-subdir",
if tree.Loaded() {
t.Fatal("should not be loaded")
// Passing a subpath to go getter extracts only this subpath. The old
// internal code would keep the entire directory structure, allowing a
// top-level module to reference others through its parent directory.
// TODO: this can be removed as a breaking change in a major release.
"tar-subdir-to-parent",
}
// This should error because we haven't gotten things yet
if err := tree.Load(storage, GetModeNone); err == nil {
t.Fatal("should error")
for _, tc := range fixtures {
t.Run(tc, func(t *testing.T) {
storage := testStorage(t)
tree := NewTree("", testConfig(t, tc))
if tree.Loaded() {
t.Fatal("should not be loaded")
}
// This should error because we haven't gotten things yet
if err := tree.Load(storage, GetModeNone); err == nil {
t.Fatal("should error")
}
if tree.Loaded() {
t.Fatal("should not be loaded")
}
// This should get things
if err := tree.Load(storage, GetModeGet); err != nil {
t.Fatalf("err: %s", err)
}
if !tree.Loaded() {
t.Fatal("should be loaded")
}
// This should no longer error
if err := tree.Load(storage, GetModeNone); err != nil {
t.Fatalf("err: %s", err)
}
actual := strings.TrimSpace(tree.String())
expected := strings.TrimSpace(treeLoadSubdirStr)
if actual != expected {
t.Fatalf("bad: \n\n%s", actual)
}
})
}
}
func TestTree_recordSubDir(t *testing.T) {
td, err := ioutil.TempDir("", "tf-module")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(td)
dir := filepath.Join(td, "0131bf0fef686e090b16bdbab4910ddf")
subDir := "subDirName"
tree := Tree{}
// record and read the subdir path
if err := tree.recordSubdir(dir, subDir); err != nil {
t.Fatal(err)
}
actual, err := tree.getSubdir(dir)
if err != nil {
t.Fatal(err)
}
if tree.Loaded() {
t.Fatal("should not be loaded")
if actual != subDir {
t.Fatalf("expected subDir %q, got %q", subDir, actual)
}
// This should get things
if err := tree.Load(storage, GetModeGet); err != nil {
t.Fatalf("err: %s", err)
// overwrite the path, and nmake sure we get the new one
subDir = "newSubDir"
if err := tree.recordSubdir(dir, subDir); err != nil {
t.Fatal(err)
}
actual, err = tree.getSubdir(dir)
if err != nil {
t.Fatal(err)
}
if !tree.Loaded() {
t.Fatal("should be loaded")
if actual != subDir {
t.Fatalf("expected subDir %q, got %q", subDir, actual)
}
// This should no longer error
if err := tree.Load(storage, GetModeNone); err != nil {
t.Fatalf("err: %s", err)
// create a fake entry
if err := ioutil.WriteFile(subdirRecordsPath(dir), []byte("BAD DATA"), 0644); err != nil {
t.Fatal(err)
}
actual := strings.TrimSpace(tree.String())
expected := strings.TrimSpace(treeLoadSubdirStr)
if actual != expected {
t.Fatalf("bad: \n\n%s", actual)
// this should fail because there aare now 2 entries
actual, err = tree.getSubdir(dir)
if err == nil {
t.Fatal("expected multiple subdir entries")
}
// writing the subdir entry should remove the incorrect value
if err := tree.recordSubdir(dir, subDir); err != nil {
t.Fatal(err)
}
actual, err = tree.getSubdir(dir)
if err != nil {
t.Fatal(err)
}
if actual != subDir {
t.Fatalf("expected subDir %q, got %q", subDir, actual)
}
}