Various fixes

- delete working dir when lock deleted
- check if dir still exists when looking at modified
- check if atlantis.yaml is allowed before parsing
This commit is contained in:
Luke Kysow
2018-07-03 13:32:17 +02:00
parent 8effd01125
commit b2bd90da61
12 changed files with 360 additions and 43 deletions

View File

@@ -1,9 +1,15 @@
# v0.4.0-alpha
## Features
* Autoplanning - Atlantis will automatically run `plan` on new pull requests and
when new commits are pushed to the pull request.
* New repository `atlantis.yaml` format that supports:
* Arbitrary step ordering
* Single config file for whole repository
* Controlling autoplanning
* Moved docs to standalone website from the README.
## Bugfixes
* Won't attempt to run plan in a directory that was deleted.
## Backwards Incompatibilities / Notes:

View File

@@ -63,6 +63,18 @@ func (mock *MockWorkingDir) Delete(r models.Repo, p models.PullRequest) error {
return ret0
}
func (mock *MockWorkingDir) DeleteForWorkspace(r models.Repo, p models.PullRequest, workspace string) error {
params := []pegomock.Param{r, p, workspace}
result := pegomock.GetGenericMockFrom(mock).Invoke("DeleteForWorkspace", params, []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()})
var ret0 error
if len(result) != 0 {
if result[0] != nil {
ret0 = result[0].(error)
}
}
return ret0
}
func (mock *MockWorkingDir) VerifyWasCalledOnce() *VerifierWorkingDir {
return &VerifierWorkingDir{mock, pegomock.Times(1), nil}
}
@@ -189,3 +201,38 @@ func (c *WorkingDir_Delete_OngoingVerification) GetAllCapturedArguments() (_para
}
return
}
func (verifier *VerifierWorkingDir) DeleteForWorkspace(r models.Repo, p models.PullRequest, workspace string) *WorkingDir_DeleteForWorkspace_OngoingVerification {
params := []pegomock.Param{r, p, workspace}
methodInvocations := pegomock.GetGenericMockFrom(verifier.mock).Verify(verifier.inOrderContext, verifier.invocationCountMatcher, "DeleteForWorkspace", params)
return &WorkingDir_DeleteForWorkspace_OngoingVerification{mock: verifier.mock, methodInvocations: methodInvocations}
}
type WorkingDir_DeleteForWorkspace_OngoingVerification struct {
mock *MockWorkingDir
methodInvocations []pegomock.MethodInvocation
}
func (c *WorkingDir_DeleteForWorkspace_OngoingVerification) GetCapturedArguments() (models.Repo, models.PullRequest, string) {
r, p, workspace := c.GetAllCapturedArguments()
return r[len(r)-1], p[len(p)-1], workspace[len(workspace)-1]
}
func (c *WorkingDir_DeleteForWorkspace_OngoingVerification) GetAllCapturedArguments() (_param0 []models.Repo, _param1 []models.PullRequest, _param2 []string) {
params := pegomock.GetGenericMockFrom(c.mock).GetInvocationParams(c.methodInvocations)
if len(params) > 0 {
_param0 = make([]models.Repo, len(params[0]))
for u, param := range params[0] {
_param0[u] = param.(models.Repo)
}
_param1 = make([]models.PullRequest, len(params[1]))
for u, param := range params[1] {
_param1[u] = param.(models.PullRequest)
}
_param2 = make([]string, len(params[2]))
for u, param := range params[2] {
_param2[u] = param.(string)
}
}
return
}

View File

@@ -2,9 +2,9 @@ package events
import (
"fmt"
"os"
"github.com/hashicorp/go-version"
"github.com/pkg/errors"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/vcs"
"github.com/runatlantis/atlantis/server/events/yaml"
@@ -51,19 +51,22 @@ func (p *DefaultProjectCommandBuilder) BuildAutoplanCommands(ctx *CommandContext
}
// Parse config file if it exists.
ctx.Log.Debug("parsing config file")
config, err := p.ParserValidator.ReadConfig(repoDir)
if err != nil && !os.IsNotExist(err) {
return nil, err
var config valid.Config
hasConfigFile, err := p.ParserValidator.HasConfigFile(repoDir)
if err != nil {
return nil, errors.Wrapf(err, "looking for %s file in %q", yaml.AtlantisYAMLFilename, repoDir)
}
noAtlantisYAML := os.IsNotExist(err)
if noAtlantisYAML {
ctx.Log.Info("found no %s file", yaml.AtlantisYAMLFilename)
} else {
ctx.Log.Info("successfully parsed %s file", yaml.AtlantisYAMLFilename)
if hasConfigFile {
if !p.AllowRepoConfig {
return nil, fmt.Errorf("%s files not allowed because Atlantis is not running with --%s", yaml.AtlantisYAMLFilename, p.AllowRepoConfigFlag)
}
config, err = p.ParserValidator.ReadConfig(repoDir)
if err != nil {
return nil, err
}
ctx.Log.Info("successfully parsed %s file", yaml.AtlantisYAMLFilename)
} else {
ctx.Log.Info("found no %s file", yaml.AtlantisYAMLFilename)
}
// We'll need the list of modified files.
@@ -78,7 +81,7 @@ func (p *DefaultProjectCommandBuilder) BuildAutoplanCommands(ctx *CommandContext
// If there is no config file, then we try to plan for each project that
// was modified in the pull request.
if noAtlantisYAML {
if !hasConfigFile {
modifiedProjects := p.ProjectFinder.DetermineProjects(ctx.Log, modifiedFiles, ctx.BaseRepo.FullName, repoDir)
ctx.Log.Info("automatically determined that there were %d projects modified in this pull request: %s", len(modifiedProjects), modifiedProjects)
for _, mp := range modifiedProjects {
@@ -128,12 +131,14 @@ func (p *DefaultProjectCommandBuilder) BuildAutoplanCommands(ctx *CommandContext
func (p *DefaultProjectCommandBuilder) BuildPlanCommand(ctx *CommandContext, cmd *CommentCommand) (models.ProjectCommandContext, error) {
var projCtx models.ProjectCommandContext
ctx.Log.Debug("building plan command")
unlockFn, err := p.WorkingDirLocker.TryLock(ctx.BaseRepo.FullName, cmd.Workspace, ctx.Pull.Num)
if err != nil {
return projCtx, err
}
defer unlockFn()
ctx.Log.Debug("cloning repository")
repoDir, err := p.WorkingDir.Clone(ctx.Log, ctx.BaseRepo, ctx.HeadRepo, ctx.Pull, cmd.Workspace)
if err != nil {
return projCtx, err
@@ -190,21 +195,26 @@ func (p *DefaultProjectCommandBuilder) buildProjectCommandCtx(ctx *CommandContex
}
func (p *DefaultProjectCommandBuilder) getCfg(projectName string, dir string, workspace string, repoDir string) (*valid.Project, *valid.Config, error) {
globalCfg, err := p.ParserValidator.ReadConfig(repoDir)
if err != nil && !os.IsNotExist(err) {
return nil, nil, err
hasConfigFile, err := p.ParserValidator.HasConfigFile(repoDir)
if err != nil {
return nil, nil, errors.Wrapf(err, "looking for %s file in %q", yaml.AtlantisYAMLFilename, repoDir)
}
hasAtlantisYAML := !os.IsNotExist(err)
if !hasAtlantisYAML && projectName != "" {
return nil, nil, fmt.Errorf("cannot specify a project name unless an %s file exists to configure projects", yaml.AtlantisYAMLFilename)
}
if !hasAtlantisYAML {
if !hasConfigFile {
if projectName != "" {
return nil, nil, fmt.Errorf("cannot specify a project name unless an %s file exists to configure projects", yaml.AtlantisYAMLFilename)
}
return nil, nil, nil
}
if !p.AllowRepoConfig {
return nil, nil, fmt.Errorf("%s files not allowed because Atlantis is not running with --%s", yaml.AtlantisYAMLFilename, p.AllowRepoConfigFlag)
}
globalCfg, err := p.ParserValidator.ReadConfig(repoDir)
if err != nil {
return nil, nil, err
}
// If they've specified a project by name we look it up. Otherwise we
// use the dir and workspace.
if projectName != "" {

View File

@@ -106,12 +106,16 @@ func (p *DefaultProjectFinder) DetermineProjectsViaConfig(log *logging.SimpleLog
}
if match {
log.Debug("file %q matched pattern", file)
projects = append(projects, project)
_, err := os.Stat(filepath.Join(repoDir, project.Dir))
if err == nil {
projects = append(projects, project)
} else {
log.Debug("project at dir %q not included because dir does not exist", project.Dir)
}
break
}
}
}
// todo: check if dir is deleted though
return projects, nil
}

View File

@@ -20,6 +20,7 @@ import (
"testing"
"github.com/runatlantis/atlantis/server/events"
"github.com/runatlantis/atlantis/server/events/yaml/valid"
"github.com/runatlantis/atlantis/server/logging"
. "github.com/runatlantis/atlantis/testing"
)
@@ -219,3 +220,162 @@ func TestDetermineProjects(t *testing.T) {
})
}
}
func TestDefaultProjectFinder_DetermineProjectsViaConfig(t *testing.T) {
/*
Create dir structure:
main.tf
project1/
main.tf
project2/
main.tf
modules/
module/
main.tf
*/
tmpDir, cleanup := DirStructure(t, map[string]interface{}{
"main.tf": nil,
"project1": map[string]interface{}{
"main.tf": nil,
},
"project2": map[string]interface{}{
"main.tf": nil,
},
"modules": map[string]interface{}{
"module": map[string]interface{}{
"main.tf": nil,
},
},
})
defer cleanup()
cases := []struct {
description string
config valid.Config
modified []string
expProjPaths []string
}{
{
description: "autoplan disabled",
config: valid.Config{
Projects: []valid.Project{
{
Dir: ".",
Autoplan: valid.Autoplan{
Enabled: false,
},
},
},
},
modified: []string{"main.tf"},
expProjPaths: nil,
},
{
description: "autoplan default",
config: valid.Config{
Projects: []valid.Project{
{
Dir: ".",
Autoplan: valid.Autoplan{
Enabled: true,
WhenModified: []string{"**/*.tf"},
},
},
},
},
modified: []string{"main.tf"},
expProjPaths: []string{"."},
},
{
description: "parent dir modified",
config: valid.Config{
Projects: []valid.Project{
{
Dir: "project",
Autoplan: valid.Autoplan{
Enabled: true,
WhenModified: []string{"**/*.tf"},
},
},
},
},
modified: []string{"main.tf"},
expProjPaths: nil,
},
{
description: "parent dir modified matches",
config: valid.Config{
Projects: []valid.Project{
{
Dir: "project1",
Autoplan: valid.Autoplan{
Enabled: true,
WhenModified: []string{"../**/*.tf"},
},
},
},
},
modified: []string{"main.tf"},
expProjPaths: []string{"project1"},
},
{
description: "dir deleted",
config: valid.Config{
Projects: []valid.Project{
{
Dir: "project3",
Autoplan: valid.Autoplan{
Enabled: true,
WhenModified: []string{"*.tf"},
},
},
},
},
modified: []string{"project3/main.tf"},
expProjPaths: nil,
},
{
description: "multiple projects",
config: valid.Config{
Projects: []valid.Project{
{
Dir: ".",
Autoplan: valid.Autoplan{
Enabled: true,
WhenModified: []string{"*.tf"},
},
},
{
Dir: "project1",
Autoplan: valid.Autoplan{
Enabled: true,
WhenModified: []string{"../modules/module/*.tf", "**/*.tf"},
},
},
{
Dir: "project2",
Autoplan: valid.Autoplan{
Enabled: true,
WhenModified: []string{"**/*.tf"},
},
},
},
},
modified: []string{"main.tf", "modules/module/another.tf", "project2/nontf.txt"},
expProjPaths: []string{".", "project1"},
},
}
for _, c := range cases {
t.Run(c.description, func(t *testing.T) {
pf := events.DefaultProjectFinder{}
projects, err := pf.DetermineProjectsViaConfig(logging.NewNoopLogger(), c.modified, c.config, tmpDir)
Ok(t, err)
Equals(t, len(c.expProjPaths), len(projects))
for i, proj := range projects {
Equals(t, c.expProjPaths[i], proj.Dir)
}
})
}
}

View File

@@ -25,7 +25,7 @@ import (
"github.com/runatlantis/atlantis/server/logging"
)
const workspacePrefix = "repos"
const workingDirPrefix = "repos"
//go:generate pegomock generate -m --use-experimental-model-gen --package mocks -o mocks/mock_working_dir.go WorkingDir
@@ -39,6 +39,7 @@ type WorkingDir interface {
GetWorkingDir(r models.Repo, p models.PullRequest, workspace string) (string, error)
// Delete deletes the workspace for this repo and pull.
Delete(r models.Repo, p models.PullRequest) error
DeleteForWorkspace(r models.Repo, p models.PullRequest, workspace string) error
}
// FileWorkspace implements WorkingDir with the file system.
@@ -64,11 +65,13 @@ func (w *FileWorkspace) Clone(
// If the directory already exists, check if it's at the right commit.
// If so, then we do nothing.
if _, err := os.Stat(cloneDir); err == nil {
log.Debug("clone directory %q already exists, checking if it's at the right commit", cloneDir)
revParseCmd := exec.Command("git", "rev-parse", "HEAD") // #nosec
revParseCmd.Dir = cloneDir
output, err := revParseCmd.CombinedOutput()
if err != nil {
return "", errors.Wrapf(err, "running git rev-parse HEAD: %s", string(output))
log.Err("will re-clone repo, could not determine if was at correct commit: git rev-parse HEAD: %s: %s", err, string(output))
return w.forceClone(log, cloneDir, headRepo, p)
}
currCommit := strings.Trim(string(output), "\n")
if currCommit == p.HeadCommit {
@@ -76,12 +79,21 @@ func (w *FileWorkspace) Clone(
return cloneDir, nil
}
log.Debug("repo was already cloned but is not at correct commit, wanted %q got %q", p.HeadCommit, currCommit)
// We'll fall through to re-clone.
}
// It's okay to delete all plans now since they're out of date.
log.Info("cleaning clone directory %q", cloneDir)
if err := os.RemoveAll(cloneDir); err != nil {
return "", errors.Wrap(err, "deleting old workspace")
}
// Otherwise we clone the repo.
return w.forceClone(log, cloneDir, headRepo, p)
}
func (w *FileWorkspace) forceClone(log *logging.SimpleLogger,
cloneDir string,
headRepo models.Repo,
p models.PullRequest) (string, error) {
err := os.RemoveAll(cloneDir)
if err != nil {
return "", errors.Wrapf(err, "deleting dir %q before cloning", cloneDir)
}
// Create the directory and parents if necessary.
@@ -124,8 +136,13 @@ func (w *FileWorkspace) Delete(r models.Repo, p models.PullRequest) error {
return os.RemoveAll(w.repoPullDir(r, p))
}
// Delete deletes the working dir for this workspace.
func (w *FileWorkspace) DeleteForWorkspace(r models.Repo, p models.PullRequest, workspace string) error {
return os.RemoveAll(w.cloneDir(r, p, workspace))
}
func (w *FileWorkspace) repoPullDir(r models.Repo, p models.PullRequest) string {
return filepath.Join(w.DataDir, workspacePrefix, r.FullName, strconv.Itoa(p.Num))
return filepath.Join(w.DataDir, workingDirPrefix, r.FullName, strconv.Itoa(p.Num))
}
func (w *FileWorkspace) cloneDir(r models.Repo, p models.PullRequest, workspace string) string {

View File

@@ -20,9 +20,10 @@ type ParserValidator struct{}
// ReadConfig returns the parsed and validated atlantis.yaml config for repoDir.
// If there was no config file, then this can be detected by checking the type
// of error: os.IsNotExist(error).
// of error: os.IsNotExist(error) but it's instead preferred to check with
// HasConfigFile.
func (p *ParserValidator) ReadConfig(repoDir string) (valid.Config, error) {
configFile := filepath.Join(repoDir, AtlantisYAMLFilename)
configFile := p.configFilePath(repoDir)
configData, err := ioutil.ReadFile(configFile)
// NOTE: the error we return here must also be os.IsNotExist since that's
@@ -44,6 +45,21 @@ func (p *ParserValidator) ReadConfig(repoDir string) (valid.Config, error) {
return config, err
}
func (p *ParserValidator) HasConfigFile(repoDir string) (bool, error) {
_, err := os.Stat(p.configFilePath(repoDir))
if os.IsNotExist(err) {
return false, nil
}
if err == nil {
return true, nil
}
return false, err
}
func (p *ParserValidator) configFilePath(repoDir string) string {
return filepath.Join(repoDir, AtlantisYAMLFilename)
}
func (p *ParserValidator) parseAndValidate(configData []byte) (valid.Config, error) {
var rawConfig raw.Config
if err := yaml.UnmarshalStrict(configData, &rawConfig); err != nil {

View File

@@ -16,6 +16,10 @@ func TestReadConfig_DirDoesNotExist(t *testing.T) {
r := yaml.ParserValidator{}
_, err := r.ReadConfig("/not/exist")
Assert(t, os.IsNotExist(err), "exp nil ptr")
exists, err := r.HasConfigFile("/not/exist")
Ok(t, err)
Equals(t, false, exists)
}
func TestReadConfig_FileDoesNotExist(t *testing.T) {
@@ -25,6 +29,10 @@ func TestReadConfig_FileDoesNotExist(t *testing.T) {
r := yaml.ParserValidator{}
_, err := r.ReadConfig(tmpDir)
Assert(t, os.IsNotExist(err), "exp nil ptr")
exists, err := r.HasConfigFile(tmpDir)
Ok(t, err)
Equals(t, false, exists)
}
func TestReadConfig_BadPermissions(t *testing.T) {

View File

@@ -7,6 +7,7 @@ import (
"strings"
"github.com/gorilla/mux"
"github.com/runatlantis/atlantis/server/events"
"github.com/runatlantis/atlantis/server/events/locking"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/vcs"
@@ -20,6 +21,8 @@ type LocksController struct {
Logger *logging.SimpleLogger
VCSClient vcs.ClientProxy
LockDetailTemplate TemplateWriter
WorkingDir events.WorkingDir
WorkingDirLocker events.WorkingDirLocker
}
// GetLock is the GET /locks/{id} route. It renders the lock detail view.
@@ -84,20 +87,29 @@ func (l *LocksController) DeleteLock(w http.ResponseWriter, r *http.Request) {
return
}
// Once the lock has been deleted, comment back on the pull request.
comment := fmt.Sprintf("**Warning**: The plan for dir: `%s` workspace: `%s` was **discarded** via the Atlantis UI.\n\n"+
"To `apply` you must run `plan` again.", lock.Project.Path, lock.Workspace)
// NOTE: Because BaseRepo was added to the PullRequest model later, previous
// installations of Atlantis will have locks in their DB that do not have
// this field on PullRequest. We skip commenting in this case.
// this field on PullRequest. We skip commenting and deleting the working dir in this case.
if lock.Pull.BaseRepo != (models.Repo{}) {
unlock, err := l.WorkingDirLocker.TryLock(lock.Pull.BaseRepo.FullName, lock.Workspace, lock.Pull.Num)
if err != nil {
l.Logger.Err("unable to obtain working dir lock when trying to delete old plans: %s", err)
} else {
defer unlock()
err = l.WorkingDir.DeleteForWorkspace(lock.Pull.BaseRepo, lock.Pull, lock.Workspace)
l.Logger.Err("unable to delete workspace: %s", err)
}
// Once the lock has been deleted, comment back on the pull request.
comment := fmt.Sprintf("**Warning**: The plan for dir: `%s` workspace: `%s` was **discarded** via the Atlantis UI.\n\n"+
"To `apply` you must run `plan` again.", lock.Project.Path, lock.Workspace)
err = l.VCSClient.CreateComment(lock.Pull.BaseRepo, lock.Pull.Num, comment)
if err != nil {
l.respond(w, logging.Error, http.StatusInternalServerError, "Failed commenting on pull request: %s", err)
return
}
} else {
l.Logger.Debug("skipping commenting on pull request that lock was deleted because BaseRepo field is empty")
l.Logger.Debug("skipping commenting on pull request and deleting workspace because BaseRepo field is empty")
}
l.respond(w, logging.Info, http.StatusOK, "Deleted lock id %q", id)
}

View File

@@ -11,7 +11,9 @@ import (
"github.com/gorilla/mux"
. "github.com/petergtz/pegomock"
"github.com/runatlantis/atlantis/server"
"github.com/runatlantis/atlantis/server/events"
"github.com/runatlantis/atlantis/server/events/locking/mocks"
mocks2 "github.com/runatlantis/atlantis/server/events/mocks"
"github.com/runatlantis/atlantis/server/events/models"
vcsmocks "github.com/runatlantis/atlantis/server/events/vcs/mocks"
"github.com/runatlantis/atlantis/server/logging"
@@ -187,6 +189,8 @@ func TestDeleteLock_CommentFailed(t *testing.T) {
RegisterMockTestingT(t)
cp := vcsmocks.NewMockClientProxy()
workingDir := mocks2.NewMockWorkingDir()
workingDirLocker := events.NewDefaultWorkingDirLocker()
When(cp.CreateComment(AnyRepo(), AnyInt(), AnyString())).ThenReturn(errors.New("err"))
l := mocks.NewMockLocker()
When(l.Unlock("id")).ThenReturn(&models.ProjectLock{
@@ -195,9 +199,11 @@ func TestDeleteLock_CommentFailed(t *testing.T) {
},
}, nil)
lc := server.LocksController{
Locker: l,
Logger: logging.NewNoopLogger(),
VCSClient: cp,
Locker: l,
Logger: logging.NewNoopLogger(),
VCSClient: cp,
WorkingDir: workingDir,
WorkingDirLocker: workingDirLocker,
}
req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil))
req = mux.SetURLVars(req, map[string]string{"id": "id"})
@@ -212,6 +218,8 @@ func TestDeleteLock_CommentSuccess(t *testing.T) {
cp := vcsmocks.NewMockClientProxy()
l := mocks.NewMockLocker()
workingDir := mocks2.NewMockWorkingDir()
workingDirLocker := events.NewDefaultWorkingDirLocker()
pull := models.PullRequest{
BaseRepo: models.Repo{FullName: "owner/repo"},
}
@@ -224,9 +232,11 @@ func TestDeleteLock_CommentSuccess(t *testing.T) {
},
}, nil)
lc := server.LocksController{
Locker: l,
Logger: logging.NewNoopLogger(),
VCSClient: cp,
Locker: l,
Logger: logging.NewNoopLogger(),
VCSClient: cp,
WorkingDirLocker: workingDirLocker,
WorkingDir: workingDir,
}
req, _ := http.NewRequest("GET", "", bytes.NewBuffer(nil))
req = mux.SetURLVars(req, map[string]string{"id": "id"})
@@ -236,4 +246,5 @@ func TestDeleteLock_CommentSuccess(t *testing.T) {
cp.VerifyWasCalled(Once()).CreateComment(pull.BaseRepo, pull.Num,
"**Warning**: The plan for dir: `path` workspace: `workspace` was **discarded** via the Atlantis UI.\n\n"+
"To `apply` you must run `plan` again.")
workingDir.VerifyWasCalledOnce().DeleteForWorkspace(pull.BaseRepo, pull, "workspace")
}

View File

@@ -275,6 +275,8 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
Logger: logger,
VCSClient: vcsClient,
LockDetailTemplate: lockTemplate,
WorkingDir: workingDir,
WorkingDirLocker: workingDirLocker,
}
eventsController := &EventsController{
CommandRunner: commandRunner,

View File

@@ -3,6 +3,7 @@ package testing
import (
"io/ioutil"
"os"
"path/filepath"
"testing"
)
@@ -17,3 +18,26 @@ func TempDir(t *testing.T) (string, func()) {
os.RemoveAll(tmpDir) // nolint: errcheck
}
}
func DirStructure(t *testing.T, structure map[string]interface{}) (string, func()) {
tmpDir, cleanup := TempDir(t)
dirStructureGo(t, tmpDir, structure)
return tmpDir, cleanup
}
func dirStructureGo(t *testing.T, parentDir string, structure map[string]interface{}) {
for key, val := range structure {
// If val is nil then key is a filename and we just create it
if val == nil {
_, err := os.Create(filepath.Join(parentDir, key))
Ok(t, err)
continue
}
// If val is another map then key is a dir
if dirContents, ok := val.(map[string]interface{}); ok {
subDir := filepath.Join(parentDir, key)
Ok(t, os.Mkdir(subDir, 0700))
// Recurse and create contents.
dirStructureGo(t, subDir, dirContents)
}
}
}