diff --git a/CHANGELOG.md b/CHANGELOG.md index fc99816502..95db6b164e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/server/events/mocks/mock_working_dir.go b/server/events/mocks/mock_working_dir.go index f9040a3303..8a84ea236f 100644 --- a/server/events/mocks/mock_working_dir.go +++ b/server/events/mocks/mock_working_dir.go @@ -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 +} diff --git a/server/events/project_command_builder.go b/server/events/project_command_builder.go index 9c89478396..456825f0c2 100644 --- a/server/events/project_command_builder.go +++ b/server/events/project_command_builder.go @@ -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 - } - 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) + hasConfigFile, err := p.ParserValidator.HasConfigFile(repoDir) + if err != nil { + return nil, nil, errors.Wrapf(err, "looking for %s file in %q", yaml.AtlantisYAMLFilename, repoDir) } - 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 != "" { diff --git a/server/events/project_finder.go b/server/events/project_finder.go index 254e51c3c6..07dfcd6c64 100644 --- a/server/events/project_finder.go +++ b/server/events/project_finder.go @@ -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 } diff --git a/server/events/project_finder_test.go b/server/events/project_finder_test.go index 82aa84b847..df84fd0945 100644 --- a/server/events/project_finder_test.go +++ b/server/events/project_finder_test.go @@ -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) + } + }) + } +} diff --git a/server/events/working_dir.go b/server/events/working_dir.go index 77fcc56985..191eb3c247 100644 --- a/server/events/working_dir.go +++ b/server/events/working_dir.go @@ -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 { diff --git a/server/events/yaml/parser_validator.go b/server/events/yaml/parser_validator.go index d53e6d7716..c8ad9ca096 100644 --- a/server/events/yaml/parser_validator.go +++ b/server/events/yaml/parser_validator.go @@ -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 { diff --git a/server/events/yaml/parser_validator_test.go b/server/events/yaml/parser_validator_test.go index a25f4cf2ab..43b5270631 100644 --- a/server/events/yaml/parser_validator_test.go +++ b/server/events/yaml/parser_validator_test.go @@ -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) { diff --git a/server/locks_controller.go b/server/locks_controller.go index abeb490c05..140cb30460 100644 --- a/server/locks_controller.go +++ b/server/locks_controller.go @@ -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) } diff --git a/server/locks_controller_test.go b/server/locks_controller_test.go index fe26ba7cc4..c7d6c1fa2e 100644 --- a/server/locks_controller_test.go +++ b/server/locks_controller_test.go @@ -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") } diff --git a/server/server.go b/server/server.go index 7adafb8a95..7eaceb7e23 100644 --- a/server/server.go +++ b/server/server.go @@ -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, diff --git a/testing/temp_files.go b/testing/temp_files.go index 1bfd0f15f4..7a242010f2 100644 --- a/testing/temp_files.go +++ b/testing/temp_files.go @@ -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) + } + } +}