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

feat(1259): sort projects based on dependencies #2146

Closed
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ require (
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/ulikunitz/xz v0.5.8 // indirect
github.com/yourbasic/graph v0.0.0-20210606180040-8ecfec1c2869 // indirect
github.com/zclconf/go-cty v1.8.0 // indirect
go.opencensus.io v0.23.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,8 @@ github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60Nt
github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc=
github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/yourbasic/graph v0.0.0-20210606180040-8ecfec1c2869 h1:7v7L5lsfw4w8iqBBXETukHo4IPltmD+mWoLRYUmeGN8=
github.com/yourbasic/graph v0.0.0-20210606180040-8ecfec1c2869/go.mod h1:Rfzr+sqaDreiCaoQbFCu3sTXxeFq/9kXRuyOoSlGQHE=
github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4=
github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI=
github.com/xanzy/go-gitlab v0.69.0 h1:sPci9xHzlX+lcJvPqNu3y3BQpePuR2R694Bal4AeyB8=
Expand Down
79 changes: 61 additions & 18 deletions server/events/pending_plan_finder.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"

"github.com/pkg/errors"
"github.com/runatlantis/atlantis/server/core/runtime"
Expand Down Expand Up @@ -41,6 +43,47 @@ func (p *DefaultPendingPlanFinder) Find(pullDir string) ([]PendingPlan, error) {
return plans, err
}

func sortPendingPlansByModTime(repoDir, lsOut string) ([]string, error) {
type fileModTime struct {
File string
ModTime time.Time
}

var files []fileModTime
for _, file := range strings.Split(lsOut, "\n") {
if filepath.Ext(file) != ".tfplan" {
continue
}
// Ignore .terragrunt-cache dirs (#487)
if strings.Contains(file, ".terragrunt-cache/") {
Copy link
Member

Choose a reason for hiding this comment

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

Instead of ignoring this directory, can we list only the files, not directories, so we can do less filtering in lsOut

continue
}

fileInfo, err := os.Stat(filepath.Join(repoDir, file))

if err != nil {
return nil, err
}

files = append(files, fileModTime{
File: file,
ModTime: fileInfo.ModTime(),
})
}

sort.Slice(files, func(i, j int) bool {
return 0 < files[j].ModTime.Sub(files[i].ModTime)
})

var sortedFiles []string

for _, el := range files {
sortedFiles = append(sortedFiles, el.File)
}

return sortedFiles, nil
}

func (p *DefaultPendingPlanFinder) findWithAbsPaths(pullDir string) ([]PendingPlan, []string, error) {
workspaceDirs, err := os.ReadDir(pullDir)
if err != nil {
Expand All @@ -61,25 +104,25 @@ func (p *DefaultPendingPlanFinder) findWithAbsPaths(pullDir string) ([]PendingPl
return nil, nil, errors.Wrapf(err, "running git ls-files . "+
"--others: %s", string(lsOut))
}
for _, file := range strings.Split(string(lsOut), "\n") {
if filepath.Ext(file) == ".tfplan" {
// Ignore .terragrunt-cache dirs (#487)
if strings.Contains(file, ".terragrunt-cache/") {
continue
}

projectName, err := runtime.ProjectNameFromPlanfile(workspace, filepath.Base(file))
if err != nil {
return nil, nil, err
}
plans = append(plans, PendingPlan{
RepoDir: repoDir,
RepoRelDir: filepath.Dir(file),
Workspace: workspace,
ProjectName: projectName,
})
absPaths = append(absPaths, filepath.Join(repoDir, file))

files, err := sortPendingPlansByModTime(repoDir, string(lsOut))

if err != nil {
return nil, nil, errors.Wrapf(err, "sort pending plans by modTime")
}

for _, file := range files {
projectName, err := runtime.ProjectNameFromPlanfile(workspace, filepath.Base(file))
if err != nil {
return nil, nil, err
}
plans = append(plans, PendingPlan{
RepoDir: repoDir,
RepoRelDir: filepath.Dir(file),
Workspace: workspace,
ProjectName: projectName,
})
absPaths = append(absPaths, filepath.Join(repoDir, file))
}
}
return plans, absPaths, nil
Expand Down
171 changes: 151 additions & 20 deletions server/events/project_finder.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"strings"

"github.com/runatlantis/atlantis/server/core/config/valid"
"github.com/yourbasic/graph"

"github.com/moby/moby/pkg/fileutils"
"github.com/pkg/errors"
Expand Down Expand Up @@ -171,6 +172,7 @@ func (p *DefaultProjectFinder) DetermineProjects(log logging.SimpleLogging, modi
// See ProjectFinder.DetermineProjectsViaConfig.
func (p *DefaultProjectFinder) DetermineProjectsViaConfig(log logging.SimpleLogging, modifiedFiles []string, config valid.RepoCfg, absRepoDir string) ([]valid.Project, error) {
var projects []valid.Project
var projectsToDependentFiles [][]string
for _, project := range config.Projects {
log.Debug("checking if project at dir %q workspace %q was modified", project.Dir, project.Workspace)
var whenModifiedRelToRepoRoot []string
Expand Down Expand Up @@ -198,6 +200,8 @@ func (p *DefaultProjectFinder) DetermineProjectsViaConfig(log logging.SimpleLogg
return nil, errors.Wrapf(err, "matching modified files with patterns: %v", project.Autoplan.WhenModified)
}

var matchedFiles []string

// If any of the modified files matches the pattern then this project is
// considered modified.
for _, file := range modifiedFiles {
Expand All @@ -206,32 +210,159 @@ func (p *DefaultProjectFinder) DetermineProjectsViaConfig(log logging.SimpleLogg
log.Debug("match err for file %q: %s", file, err)
continue
}
if match {
log.Debug("file %q matched pattern", file)
// If we're checking using an atlantis.yaml file we downloaded
// directly from the repo (when doing a no-clone check) then
// absRepoDir will be empty. Since we didn't clone the repo
// yet we can't do this check. If there was a file modified
// in a deleted directory then when we finally do clone the repo
// we'll call this function again and then we'll detect the
// directory was deleted.
if absRepoDir != "" {
_, err := os.Stat(filepath.Join(absRepoDir, 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)
}
} else {
projects = append(projects, project)
}
break

if !match {
continue
}

log.Debug("file %q matched pattern", file)

// If we're checking using an atlantis.yaml file we downloaded
// directly from the repo (when doing a no-clone check) then
// absRepoDir will be empty. Since we didn't clone the repo
// yet we can't do this check. If there was a file modified
// in a deleted directory then when we finally do clone the repo
// we'll call this function again and then we'll detect the
// directory was deleted.
if projectDirDeleted(absRepoDir, project.Dir) {
log.Debug("project at dir %q not included because dir does not exist", project.Dir)
continue
}

matchedFiles = append(matchedFiles, file)
}

if 0 < len(matchedFiles) {
projects = append(projects, project)
projectsToDependentFiles = append(projectsToDependentFiles, matchedFiles)
}
}

if 1 < len(projects) {
sorted, err := sortProjectsByDependencies(log, projects, projectsToDependentFiles, absRepoDir)

if err != nil {
// return nil, errors.Wrapf(err, "Unable to sort plans: %s", err)
// Used unsorted projects instead
} else {
projects = sorted
}
}

return projects, nil
}

func projectDirDeleted(absRepoDir, projectDir string) bool {
if absRepoDir == "" {
return false
}

if _, err := os.Stat(filepath.Join(absRepoDir, projectDir)); err != nil {
return true
}

return false
}

func projectToBeDestroyed(project valid.Project, absRepoDir string) (bool, error) {
terragruntFile := filepath.Join(absRepoDir, project.Dir, "terragrunt.hcl")

if _, err := os.Stat(terragruntFile); err != nil {
if os.IsNotExist(err) {
return false, nil
}

return false, errors.Wrapf(err, "determining existance of %q", terragruntFile)
}

data, err := os.ReadFile(terragruntFile)
if err != nil {
return false, errors.Wrapf(err, "reading file %q", terragruntFile)
}

if strings.HasPrefix(strings.TrimSpace(string(data)), "# ATLANTIS_PLEASE_DESTROY_STACK") {
return true, nil
}

return false, nil
}

func printGraph(g *graph.Mutable, projects []valid.Project) string {
output := "digraph G {\n"

for i := 0; i < g.Order(); i++ {
output += fmt.Sprintf(" %d [label=\"%s\"];\n", i, projects[i].Dir)
}
for i := 0; i < g.Order(); i++ {
g.Visit(i, func(w int, to int64) bool { output += fmt.Sprintf(" %d -> %d;\n", i, w); return false })
}
output += "}\n"

return output
}

func sortProjectsByDependencies(log logging.SimpleLogging, projects []valid.Project, projectsToDependentFiles [][]string, absRepoDir string) ([]valid.Project, error) {
g := graph.New(len(projects))

for currentProjectIndex, files := range projectsToDependentFiles {
if 0 == len(files) {
continue
}

toBeDestroyed, err := projectToBeDestroyed(projects[currentProjectIndex], absRepoDir)

if err != nil {
return nil, errors.Wrapf(err, "determining whether or not to apply destroy order for topological sort")
}

for _, file := range files {
if !strings.Contains(file, string(os.PathSeparator)+"terragrunt.hcl") {
continue
}
from, err := FindProjectNo(projects, file)

if err != nil {
return nil, errors.Wrapf(err, "creating dependency graph for topological sort")
}

if currentProjectIndex == from {
continue // don't add an edge to itself e.g. (u,u)
}
if toBeDestroyed {
g.Add(currentProjectIndex, from)
} else {
g.Add(from, currentProjectIndex)
}
}
}

// TODO before contributing to github, make this a Debug() or delete
log.Info("Dependency Graph:\n%s\n", printGraph(g, projects))
sortedIndices, isSorted := graph.TopSort(g)

if !isSorted {
return nil, fmt.Errorf("topological sort failed on %#v", projectsToDependentFiles)
}

var sortedProjects []valid.Project

for _, index := range sortedIndices {
sortedProjects = append(sortedProjects, projects[index])
}

return sortedProjects, nil
}

func FindProjectNo(projects []valid.Project, file string) (int, error) {
for i, project := range projects {
if strings.Contains(file, project.Dir+string(os.PathSeparator)) {
return i, nil
}
}

return -1, fmt.Errorf("Did not find %q in %#v", file, projects)
}

// filterToFileList filters out files not included in the file list
func (p *DefaultProjectFinder) filterToFileList(log logging.SimpleLogging, files []string, fileList string) []string {
var filtered []string
Expand Down
Loading