github.com/diggerhq/digger/libs@v0.0.0-20240604170430-9d61cdf01cc5/digger_config/digger_config.go (about) 1 package digger_config 2 3 import ( 4 "errors" 5 "fmt" 6 "github.com/samber/lo" 7 "log" 8 "os" 9 "path" 10 "path/filepath" 11 "strings" 12 13 "github.com/diggerhq/digger/libs/digger_config/terragrunt/atlantis" 14 15 "github.com/dominikbraun/graph" 16 "gopkg.in/yaml.v3" 17 ) 18 19 type DirWalker interface { 20 GetDirs(workingDir string, config DiggerConfigYaml) ([]string, error) 21 } 22 23 type FileSystemTopLevelTerraformDirWalker struct { 24 } 25 26 type FileSystemTerragruntDirWalker struct { 27 } 28 29 type FileSystemModuleDirWalker struct { 30 } 31 32 func GetFilesWithExtension(workingDir string, ext string) ([]string, error) { 33 var files []string 34 listOfFiles, err := os.ReadDir(workingDir) 35 if err != nil { 36 return nil, errors.New(fmt.Sprintf("error reading directory %s: %v", workingDir, err)) 37 } 38 for _, f := range listOfFiles { 39 if !f.IsDir() { 40 r, err := filepath.Match("*"+ext, f.Name()) 41 if err == nil && r { 42 files = append(files, f.Name()) 43 } 44 } 45 } 46 47 return files, nil 48 } 49 50 func (walker *FileSystemTopLevelTerraformDirWalker) GetDirs(workingDir string, configYaml *DiggerConfigYaml) ([]string, error) { 51 var dirs []string 52 err := filepath.Walk(workingDir, 53 func(path string, info os.FileInfo, err error) error { 54 55 if err != nil { 56 return err 57 } 58 if info.IsDir() { 59 if info.Name() == "modules" { 60 return filepath.SkipDir 61 } 62 terraformFiles, _ := GetFilesWithExtension(path, ".tf") 63 if len(terraformFiles) > 0 { 64 dirs = append(dirs, strings.ReplaceAll(path, workingDir+string(os.PathSeparator), "")) 65 if configYaml.TraverseToNestedProjects != nil && !*configYaml.TraverseToNestedProjects { 66 return filepath.SkipDir 67 } 68 } 69 } 70 return nil 71 }) 72 if err != nil { 73 return nil, err 74 } 75 return dirs, nil 76 } 77 78 func (walker *FileSystemModuleDirWalker) GetDirs(workingDir string, configYaml *DiggerConfigYaml) ([]string, error) { 79 var dirs []string 80 err := filepath.Walk(workingDir, 81 func(path string, info os.FileInfo, err error) error { 82 83 if err != nil { 84 return err 85 } 86 if info.IsDir() && info.Name() == "modules" { 87 dirs = append(dirs, strings.ReplaceAll(path, workingDir+string(os.PathSeparator), "")) 88 return filepath.SkipDir 89 } 90 return nil 91 }) 92 if err != nil { 93 return nil, err 94 } 95 return dirs, nil 96 } 97 98 func (walker *FileSystemTerragruntDirWalker) GetDirs(workingDir string, configYaml *DiggerConfigYaml) ([]string, error) { 99 var dirs []string 100 err := filepath.Walk(workingDir, 101 func(path string, info os.FileInfo, err error) error { 102 103 if err != nil { 104 return err 105 } 106 if info.IsDir() { 107 if info.Name() == "modules" { 108 return filepath.SkipDir 109 } 110 terragruntFiles, _ := GetFilesWithExtension(path, "terragrunt.hcl") 111 if len(terragruntFiles) > 0 { 112 for _, f := range terragruntFiles { 113 terragruntFile := path + string(os.PathSeparator) + f 114 fileContent, err := os.ReadFile(terragruntFile) 115 if err != nil { 116 return err 117 } 118 if strings.Contains(string(fileContent), "include \"root\"") { 119 dirs = append(dirs, strings.ReplaceAll(path, workingDir+string(os.PathSeparator), "")) 120 return filepath.SkipDir 121 } 122 } 123 } 124 } 125 return nil 126 }) 127 if err != nil { 128 return nil, err 129 } 130 return dirs, nil 131 } 132 133 var ErrDiggerConfigConflict = errors.New("more than one digger digger_config file detected, please keep either 'digger.yml' or 'digger.yaml'") 134 135 func LoadDiggerConfig(workingDir string, generateProjects bool) (*DiggerConfig, *DiggerConfigYaml, graph.Graph[string, Project], error) { 136 config := &DiggerConfig{} 137 configYaml, err := LoadDiggerConfigYaml(workingDir, generateProjects) 138 if err != nil { 139 return nil, nil, nil, err 140 } 141 142 config, projectDependencyGraph, err := ConvertDiggerYamlToConfig(configYaml) 143 if err != nil { 144 return nil, nil, nil, err 145 } 146 147 err = ValidateDiggerConfig(config) 148 if err != nil { 149 return config, configYaml, projectDependencyGraph, err 150 } 151 return config, configYaml, projectDependencyGraph, nil 152 } 153 154 func LoadDiggerConfigFromString(yamlString string, terraformDir string) (*DiggerConfig, *DiggerConfigYaml, graph.Graph[string, Project], error) { 155 config := &DiggerConfig{} 156 configYaml, err := LoadDiggerConfigYamlFromString(yamlString) 157 if err != nil { 158 return nil, nil, nil, err 159 } 160 161 err = ValidateDiggerConfigYaml(configYaml, "loaded_yaml_string") 162 if err != nil { 163 return nil, nil, nil, err 164 } 165 166 err = HandleYamlProjectGeneration(configYaml, terraformDir) 167 if err != nil { 168 return nil, nil, nil, err 169 } 170 171 config, projectDependencyGraph, err := ConvertDiggerYamlToConfig(configYaml) 172 if err != nil { 173 return nil, nil, nil, err 174 } 175 176 err = ValidateDiggerConfig(config) 177 if err != nil { 178 return config, configYaml, projectDependencyGraph, err 179 } 180 return config, configYaml, projectDependencyGraph, nil 181 } 182 183 func LoadDiggerConfigYamlFromString(yamlString string) (*DiggerConfigYaml, error) { 184 configYaml := &DiggerConfigYaml{} 185 if err := yaml.Unmarshal([]byte(yamlString), configYaml); err != nil { 186 return nil, fmt.Errorf("error parsing yaml: %v", err) 187 } 188 189 return configYaml, nil 190 } 191 192 func HandleYamlProjectGeneration(config *DiggerConfigYaml, terraformDir string) error { 193 if config.GenerateProjectsConfig != nil && config.GenerateProjectsConfig.TerragruntParsingConfig != nil { 194 err := hydrateDiggerConfigYamlWithTerragrunt(config, *config.GenerateProjectsConfig.TerragruntParsingConfig, terraformDir) 195 if err != nil { 196 return err 197 } 198 } else if config.GenerateProjectsConfig != nil && config.GenerateProjectsConfig.Terragrunt { 199 err := hydrateDiggerConfigYamlWithTerragrunt(config, TerragruntParsingConfig{}, terraformDir) 200 if err != nil { 201 return err 202 } 203 } else if config.GenerateProjectsConfig != nil { 204 var dirWalker = &FileSystemTopLevelTerraformDirWalker{} 205 dirs, err := dirWalker.GetDirs(terraformDir, config) 206 207 if err != nil { 208 fmt.Printf("Error while walking through directories: %v", err) 209 } 210 211 var includePatterns []string 212 var excludePatterns []string 213 if config.GenerateProjectsConfig.Include != "" || config.GenerateProjectsConfig.Exclude != "" { 214 includePatterns = []string{config.GenerateProjectsConfig.Include} 215 excludePatterns = []string{config.GenerateProjectsConfig.Exclude} 216 for _, dir := range dirs { 217 if MatchIncludeExcludePatternsToFile(dir, includePatterns, excludePatterns) { 218 projectName := strings.ReplaceAll(dir, "/", "_") 219 project := ProjectYaml{Name: projectName, Dir: dir, Workflow: defaultWorkflowName, Workspace: "default", AwsRoleToAssume: config.GenerateProjectsConfig.AwsRoleToAssume} 220 config.Projects = append(config.Projects, &project) 221 } 222 } 223 } 224 if config.GenerateProjectsConfig.Blocks != nil && len(config.GenerateProjectsConfig.Blocks) > 0 { 225 // if blocks of include/exclude patterns defined 226 for _, b := range config.GenerateProjectsConfig.Blocks { 227 includePatterns = []string{b.Include} 228 excludePatterns = []string{b.Exclude} 229 workflow := "default" 230 if b.Workflow != "" { 231 workflow = b.Workflow 232 } 233 234 for _, dir := range dirs { 235 if MatchIncludeExcludePatternsToFile(dir, includePatterns, excludePatterns) { 236 projectName := strings.ReplaceAll(dir, "/", "_") 237 project := ProjectYaml{Name: projectName, Dir: dir, Workflow: workflow, Workspace: "default", AwsRoleToAssume: b.AwsRoleToAssume} 238 config.Projects = append(config.Projects, &project) 239 } 240 } 241 } 242 } 243 } 244 return nil 245 } 246 247 func LoadDiggerConfigYaml(workingDir string, generateProjects bool) (*DiggerConfigYaml, error) { 248 configYaml := &DiggerConfigYaml{} 249 fileName, err := retrieveConfigFile(workingDir) 250 if err != nil { 251 if errors.Is(err, ErrDiggerConfigConflict) { 252 return nil, fmt.Errorf("error while retrieving digger_config file: %v", err) 253 } 254 } 255 256 if fileName == "" { 257 configYaml, err = AutoDetectDiggerConfig(workingDir) 258 if err != nil { 259 return nil, fmt.Errorf("failed to auto detect digger digger_config: %v", err) 260 } 261 marshalledConfig, err := yaml.Marshal(configYaml) 262 if err != nil { 263 log.Printf("failed to marshal auto detected digger digger_config: %v", err) 264 } else { 265 log.Printf("Auto detected digger digger_config: \n%v", string(marshalledConfig)) 266 } 267 } else { 268 data, err := os.ReadFile(fileName) 269 if err != nil { 270 return nil, fmt.Errorf("failed to read digger_config file %s: %v", fileName, err) 271 } 272 273 if err := yaml.Unmarshal(data, configYaml); err != nil { 274 return nil, fmt.Errorf("error parsing '%s': %v", fileName, err) 275 } 276 } 277 278 err = ValidateDiggerConfigYaml(configYaml, fileName) 279 if err != nil { 280 return configYaml, err 281 } 282 283 if generateProjects == true { 284 err = HandleYamlProjectGeneration(configYaml, workingDir) 285 if err != nil { 286 return configYaml, err 287 } 288 } 289 290 return configYaml, nil 291 } 292 293 func ValidateDiggerConfigYaml(configYaml *DiggerConfigYaml, fileName string) error { 294 if (configYaml.Projects == nil || len(configYaml.Projects) == 0) && configYaml.GenerateProjectsConfig == nil { 295 return fmt.Errorf("no projects digger_config found in '%s'", fileName) 296 } 297 if configYaml.DependencyConfiguration != nil { 298 if configYaml.DependencyConfiguration.Mode != DependencyConfigurationHard && configYaml.DependencyConfiguration.Mode != DependencyConfigurationSoft { 299 return fmt.Errorf("dependency digger_config mode can only be '%s' or '%s'", DependencyConfigurationHard, DependencyConfigurationSoft) 300 } 301 } 302 303 if configYaml.GenerateProjectsConfig != nil { 304 if configYaml.GenerateProjectsConfig.Include != "" && 305 configYaml.GenerateProjectsConfig.Exclude != "" && 306 len(configYaml.GenerateProjectsConfig.Blocks) != 0 { 307 return fmt.Errorf("if include/exclude patterns are used for project generation, blocks of include/exclude can't be used") 308 } 309 } 310 311 return nil 312 } 313 314 func ValidateDiggerConfig(config *DiggerConfig) error { 315 316 if config.CommentRenderMode != CommentRenderModeBasic && config.CommentRenderMode != CommentRenderModeGroupByModule { 317 return fmt.Errorf("invalid value for comment_render_mode, %v expecting %v, %v", config.CommentRenderMode, CommentRenderModeBasic, CommentRenderModeGroupByModule) 318 } 319 320 for _, p := range config.Projects { 321 _, ok := config.Workflows[p.Workflow] 322 if !ok { 323 return fmt.Errorf("failed to find workflow digger_config '%s' for project '%s'", p.Workflow, p.Name) 324 } 325 } 326 327 for _, w := range config.Workflows { 328 for _, s := range w.Plan.Steps { 329 if s.Action == "" { 330 return fmt.Errorf("plan step's action can't be empty") 331 } 332 } 333 } 334 335 for _, w := range config.Workflows { 336 for _, s := range w.Apply.Steps { 337 if s.Action == "" { 338 return fmt.Errorf("apply step's action can't be empty") 339 } 340 } 341 } 342 return nil 343 } 344 345 func hydrateDiggerConfigYamlWithTerragrunt(configYaml *DiggerConfigYaml, parsingConfig TerragruntParsingConfig, workingDir string) error { 346 root := workingDir 347 if parsingConfig.GitRoot != nil { 348 root = path.Join(workingDir, *parsingConfig.GitRoot) 349 } 350 projectExternalChilds := true 351 352 if parsingConfig.CreateHclProjectExternalChilds != nil { 353 projectExternalChilds = *parsingConfig.CreateHclProjectExternalChilds 354 } 355 356 parallel := true 357 if parsingConfig.Parallel != nil { 358 parallel = *parsingConfig.Parallel 359 } 360 361 ignoreParentTerragrunt := true 362 if parsingConfig.IgnoreParentTerragrunt != nil { 363 ignoreParentTerragrunt = *parsingConfig.IgnoreParentTerragrunt 364 } 365 366 cascadeDependencies := true 367 if parsingConfig.CascadeDependencies != nil { 368 cascadeDependencies = *parsingConfig.CascadeDependencies 369 } 370 371 executionOrderGroups := false 372 if parsingConfig.ExecutionOrderGroups != nil { 373 executionOrderGroups = *parsingConfig.ExecutionOrderGroups 374 } 375 376 atlantisConfig, _, err := atlantis.Parse( 377 root, 378 parsingConfig.ProjectHclFiles, 379 projectExternalChilds, 380 parsingConfig.AutoMerge, 381 parallel, 382 parsingConfig.FilterPath, 383 parsingConfig.CreateHclProjectChilds, 384 ignoreParentTerragrunt, 385 parsingConfig.IgnoreDependencyBlocks, 386 cascadeDependencies, 387 parsingConfig.DefaultWorkflow, 388 parsingConfig.DefaultApplyRequirements, 389 parsingConfig.AutoPlan, 390 parsingConfig.DefaultTerraformVersion, 391 parsingConfig.CreateProjectName, 392 parsingConfig.CreateWorkspace, 393 parsingConfig.PreserveProjects, 394 parsingConfig.UseProjectMarkers, 395 executionOrderGroups, 396 ) 397 if err != nil { 398 return fmt.Errorf("failed to autogenerate digger_config, error during parse: %v", err) 399 } 400 401 if err != nil { 402 log.Printf("failed to autogenerate digger_config: %v", err) 403 } 404 405 if atlantisConfig.Projects == nil { 406 return fmt.Errorf("atlantisConfig.Projects is nil") 407 } 408 409 configYaml.AutoMerge = &atlantisConfig.AutoMerge 410 411 pathPrefix := "" 412 if parsingConfig.GitRoot != nil { 413 pathPrefix = *parsingConfig.GitRoot 414 } 415 416 for _, atlantisProject := range atlantisConfig.Projects { 417 418 // normalize paths 419 projectDir := path.Join(pathPrefix, atlantisProject.Dir) 420 atlantisProject.Autoplan.WhenModified, err = GetPatternsRelativeToRepo(projectDir, atlantisProject.Autoplan.WhenModified) 421 if err != nil { 422 return fmt.Errorf("could not normalize patterns: %v", err) 423 } 424 425 configYaml.Projects = append(configYaml.Projects, &ProjectYaml{ 426 Name: atlantisProject.Name, 427 Dir: projectDir, 428 Workspace: atlantisProject.Workspace, 429 Terragrunt: true, 430 Workflow: atlantisProject.Workflow, 431 IncludePatterns: atlantisProject.Autoplan.WhenModified, 432 }) 433 } 434 return nil 435 } 436 437 func AutoDetectDiggerConfig(workingDir string) (*DiggerConfigYaml, error) { 438 configYaml := &DiggerConfigYaml{} 439 telemetry := true 440 configYaml.Telemetry = &telemetry 441 442 TraverseToNestedProjects := false 443 configYaml.TraverseToNestedProjects = &TraverseToNestedProjects 444 445 AllowDraftPRs := false 446 configYaml.AllowDraftPRs = &AllowDraftPRs 447 448 terragruntDirWalker := &FileSystemTerragruntDirWalker{} 449 terraformDirWalker := &FileSystemTopLevelTerraformDirWalker{} 450 moduleDirWalker := &FileSystemModuleDirWalker{} 451 452 terragruntDirs, err := terragruntDirWalker.GetDirs(workingDir, configYaml) 453 454 if err != nil { 455 return nil, err 456 } 457 458 terraformDirs, err := terraformDirWalker.GetDirs(workingDir, configYaml) 459 if err != nil { 460 return nil, err 461 } 462 463 moduleDirs, err := moduleDirWalker.GetDirs(workingDir, configYaml) 464 465 var modulePatterns []string 466 for _, dir := range moduleDirs { 467 modulePatterns = append(modulePatterns, dir+"/**") 468 } 469 470 if err != nil { 471 return nil, err 472 } 473 if len(terragruntDirs) > 0 { 474 configYaml.GenerateProjectsConfig = &GenerateProjectsConfigYaml{ 475 Terragrunt: true, 476 } 477 return configYaml, nil 478 } else if len(terraformDirs) > 0 { 479 for _, dir := range terraformDirs { 480 var projectName string 481 if dir == "./" { 482 projectName = "default" 483 } else { 484 projectName = strings.ReplaceAll(dir, "/", "_") 485 } 486 project := ProjectYaml{Name: projectName, Dir: dir, Workflow: defaultWorkflowName, Workspace: "default", Terragrunt: false, IncludePatterns: modulePatterns} 487 configYaml.Projects = append(configYaml.Projects, &project) 488 } 489 return configYaml, nil 490 } else { 491 return nil, fmt.Errorf("no terragrunt or terraform project detected in the repository") 492 } 493 } 494 495 func (c *DiggerConfig) GetProject(projectName string) *Project { 496 for _, project := range c.Projects { 497 if projectName == project.Name { 498 return &project 499 } 500 } 501 return nil 502 } 503 504 func (c *DiggerConfig) GetProjects(projectName string) []Project { 505 if projectName == "" { 506 return c.Projects 507 } 508 project := c.GetProject(projectName) 509 if project == nil { 510 return nil 511 } 512 return []Project{*project} 513 } 514 515 type ProjectToSourceMapping struct { 516 ImpactingLocations []string `json:"impacting_locations"` 517 CommentIds map[string]string `json:"comment_ids"` // impactingLocation => PR commentId 518 } 519 520 func (c *DiggerConfig) GetModifiedProjects(changedFiles []string) ([]Project, map[string]ProjectToSourceMapping) { 521 var result []Project 522 mapping := make(map[string]ProjectToSourceMapping) 523 for _, project := range c.Projects { 524 sourceChangesForProject := make([]string, 0) 525 isProjectAdded := false 526 for _, changedFile := range changedFiles { 527 includePatterns := project.IncludePatterns 528 excludePatterns := project.ExcludePatterns 529 if !project.Terragrunt { 530 includePatterns = append(includePatterns, filepath.Join(project.Dir, "**", "*")) 531 } else { 532 includePatterns = append(includePatterns, filepath.Join(project.Dir, "*")) 533 } 534 // all our patterns are the globale dir pattern + the include patterns specified by user 535 if MatchIncludeExcludePatternsToFile(changedFile, includePatterns, excludePatterns) { 536 if !isProjectAdded { 537 result = append(result, project) 538 isProjectAdded = true 539 } 540 changedDir := filepath.Dir(changedFile) 541 if !lo.Contains(sourceChangesForProject, changedDir) { 542 sourceChangesForProject = append(sourceChangesForProject, changedDir) 543 } 544 } 545 } 546 mapping[project.Name] = ProjectToSourceMapping{ 547 ImpactingLocations: sourceChangesForProject, 548 } 549 } 550 return result, mapping 551 } 552 553 func (c *DiggerConfig) GetDirectory(projectName string) string { 554 project := c.GetProject(projectName) 555 if project == nil { 556 return "" 557 } 558 return project.Dir 559 } 560 561 func (c *DiggerConfig) GetWorkflow(workflowName string) *Workflow { 562 workflows := c.Workflows 563 564 workflow, ok := workflows[workflowName] 565 if !ok { 566 return nil 567 } 568 return &workflow 569 570 } 571 572 type File struct { 573 Filename string 574 } 575 576 func isFileExists(path string) bool { 577 fi, err := os.Stat(path) 578 if os.IsNotExist(err) { 579 return false 580 } 581 // file exists make sure it's not a directory 582 return !fi.IsDir() 583 } 584 585 func retrieveConfigFile(workingDir string) (string, error) { 586 var fileName string = "digger" 587 customConfigFile := os.Getenv("DIGGER_FILENAME") != "" 588 589 if customConfigFile { 590 fileName = os.Getenv("DIGGER_FILENAME") 591 } 592 593 if workingDir != "" { 594 fileName = path.Join(workingDir, fileName) 595 } 596 597 if !customConfigFile { 598 // Make sure we don't have more than one digger digger_config file 599 ymlCfg := fileName + ".yml" 600 yamlCfg := fileName + ".yaml" 601 ymlCfgExists := isFileExists(ymlCfg) 602 yamlCfgExists := isFileExists(yamlCfg) 603 604 if ymlCfgExists && yamlCfgExists { 605 return "", ErrDiggerConfigConflict 606 } else if ymlCfgExists { 607 return ymlCfg, nil 608 } else if yamlCfgExists { 609 return yamlCfg, nil 610 } 611 } else { 612 return fileName, nil 613 } 614 615 // Passing this point means digger digger_config file is 616 // missing which is a non-error 617 return "", nil 618 } 619 620 func CollectTerraformEnvConfig(envs *TerraformEnvConfig) (map[string]string, map[string]string) { 621 stateEnvVars := map[string]string{} 622 commandEnvVars := map[string]string{} 623 624 if envs != nil { 625 for _, envvar := range envs.State { 626 if envvar.Value != "" { 627 stateEnvVars[envvar.Name] = envvar.Value 628 } else if envvar.ValueFrom != "" { 629 stateEnvVars[envvar.Name] = os.Getenv(envvar.ValueFrom) 630 } 631 } 632 633 for _, envvar := range envs.Commands { 634 if envvar.Value != "" { 635 commandEnvVars[envvar.Name] = envvar.Value 636 } else if envvar.ValueFrom != "" { 637 commandEnvVars[envvar.Name] = os.Getenv(envvar.ValueFrom) 638 } 639 } 640 } 641 642 return stateEnvVars, commandEnvVars 643 }