Skip to content

Commit

Permalink
feat(1259): sort projects based on dependencies
Browse files Browse the repository at this point in the history
This implements:
#1259

feat(1259): atlantis destroy order

While creating the directed acyclic graph, it checks if the
terragrunt.hcl file of the stacks contains our destroy flag:
`# ATLANTIS_PLEASE_DESTROY_STACK`

If this flag is found, the corresponding edge will be reverted, i.e.
instead of u -> v, we will get v -> u.

feat(1259): topological sort: fix and debugging

* this will output the dependency graph in *.dot format
* it will make the plan fail if the TopSort() fails for whatever reason
* sorting is not necessary when there is just one project
* dynamic folder/file generation
* remove some unnecessary else branches
* add a test for FindProjectNo()
* and most important: fix FindProjectNo() so that the sorting is correct
  • Loading branch information
Julius Daniel Herrera Glomm committed Mar 16, 2022
1 parent 2453e43 commit 65a68c9
Show file tree
Hide file tree
Showing 6 changed files with 500 additions and 46 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ require (
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/subosito/gotenv v1.2.0 // 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.5.1 // indirect
go.opencensus.io v0.23.0 // indirect
go.uber.org/atomic v1.7.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,8 @@ github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKn
github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/xanzy/go-gitlab v0.59.0 h1:fAr6rT/YIdfmBavYgI42+Op7yAAex2Y4xOfvbjN9hxQ=
github.com/xanzy/go-gitlab v0.59.0/go.mod h1:F0QEXwmqiBUxCgJm8fE9S+1veX4XC9Z4cfaAbqwk4YM=
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/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
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/") {
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
172 changes: 152 additions & 20 deletions server/events/project_finder.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
package events

import (
"fmt"
"os"
"path"
"path/filepath"
"strings"

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

"github.com/docker/docker/pkg/fileutils"
"github.com/pkg/errors"
Expand Down Expand Up @@ -83,6 +85,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 @@ -110,6 +113,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 @@ -118,32 +123,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

0 comments on commit 65a68c9

Please sign in to comment.