github.com/keikoproj/manny@v0.0.0-20210726112440-8571e4c99ced/configurator/configurator.go (about) 1 package configurator 2 3 import ( 4 "encoding/json" 5 "errors" 6 "fmt" 7 "io/ioutil" 8 "net/http" 9 "net/url" 10 "os" 11 "path" 12 "path/filepath" 13 "regexp" 14 "strconv" 15 "strings" 16 "time" 17 18 "github.com/aws/aws-sdk-go/aws/session" 19 "github.com/aws/aws-sdk-go/service/s3/s3iface" 20 "go.uber.org/zap" 21 "gopkg.in/yaml.v3" 22 23 "github.com/aws/aws-sdk-go/aws" 24 "github.com/aws/aws-sdk-go/service/s3" 25 "github.com/aws/aws-sdk-go/service/s3/s3manager" 26 "github.com/imdario/mergo" 27 gitopsv1alpha1 "github.com/keikoproj/cloudresource-manager/api/v1alpha1" 28 29 "github.com/keikoproj/manny/config" 30 "github.com/keikoproj/manny/configurator/mocks" 31 ) 32 33 const ( 34 // MannyConfigName is a special name in manny that denotes the name of the configuration file 35 MannyConfigName = "config.yaml" 36 37 // EmptyResourceRegExp Regex details : Should start with "Resources". 38 //Can have space in between. 39 //Should have ":" delimiter. 40 //Can have space in between. 41 //Can have "{}" or not. Space allowed between curly braces. 42 EmptyResourceRegExp = "^(Resources(\"{0,1})(\\s*):(\\s*)({(\\s*)}){0,1})$" 43 ) 44 45 var counter int 46 var baseDir []string 47 var AcceptedExtensions = []string{".yaml", ".yml", ".json"} 48 49 type SL struct { 50 // stack lists 51 StackList []map[string]string 52 } 53 54 var s SL 55 56 type CloudResources []*CloudResourceDeployment 57 58 // Validate runs various validations against the deployments 59 func (c CloudResources) Validate() error { 60 lookupTable := map[string]bool{} 61 62 for _, resource := range c { 63 // find duplicate stack names 64 name := resource.Spec.Cloudformation.Stackname 65 if lookupTable[name] { 66 return errors.New("duplicate stack name found: " + name) 67 } 68 69 lookupTable[name] = true 70 } 71 72 return nil 73 } 74 75 76 77 // Render returns a json or yaml byte array of the resources 78 func (c CloudResources) Render(format string) (bytes []byte, err error) { 79 switch format { 80 case "json": 81 for _, d := range c { 82 deployment, err := json.Marshal(d) 83 if err != nil { 84 return nil, err 85 } 86 87 bytes = append(bytes, deployment...) 88 } 89 default: 90 for _, d := range c { 91 bytes = append(bytes, []byte("---\n")...) 92 93 deployment, err := yaml.Marshal(d) 94 if err != nil { 95 return nil, err 96 } 97 98 bytes = append(bytes, deployment...) 99 } 100 } 101 102 return 103 } 104 105 // CloudResourceDeployment is a Custom Resource duplicated from the GitOps controller. 106 // We had to duplicate it here because the built-in Kubernetes types are made for machine processing 107 // with JSON. In order to produce a YAML manifest that someone can read we have to represent that 108 // structure in our code. 109 type CloudResourceDeployment struct { 110 Kind string `yaml:"kind" json:"kind"` 111 APIVersion string `yaml:"apiVersion" json:"apiVersion"` 112 Metadata Metadata `yaml:"metadata" json:"metadata"` 113 Spec gitopsv1alpha1.CloudResourceDeploymentSpec `yaml:"spec" json:"spec"` 114 } 115 116 type Metadata struct { 117 Name string `yaml:"name" json:"name"` 118 Namespace string `yaml:"namespace,omitempty" json:"namespace,omitempty"` 119 Labels map[string]string `yaml:"labels,omitempty" json:"labels,omitempty"` 120 Annotations map[string]string `yaml:"annotations,omitempty" json:"annotations,omitempty"` 121 } 122 123 type TemplatePath string 124 125 func (t TemplatePath) Parse() Template { 126 var template Template 127 128 switch true { 129 // s3 handler 130 case strings.HasPrefix(t.String(), "s3://"): 131 template.Type = "s3" 132 template.Path = t.String() 133 // http(s) handler 134 case strings.HasPrefix(t.String(), "http://"): 135 template.Type = "http" 136 template.Path = t.String() 137 // default is the file handler 138 default: 139 template.Type = "file" 140 template.Path = strings.TrimPrefix(t.String(), "file://") 141 } 142 143 return template 144 } 145 146 func contains(arr []string, str string) bool { 147 for _, a := range arr { 148 if a == str { 149 return true 150 } 151 } 152 return false 153 } 154 155 func (m *MannyConfig) CheckForEmptyResource() (bool,error) { 156 regex,err := regexp.Compile(EmptyResourceRegExp) 157 if err != nil { 158 return true,err 159 } 160 // Don't generate manifests for empty resources 161 indexOfResource := strings.Index(string(m.CloudFormation), "Resource") 162 if indexOfResource == -1 { 163 return false,nil 164 } 165 resourceSubString := m.CloudFormation[indexOfResource:] 166 167 return regex.Match(resourceSubString),nil 168 } 169 170 func (m *MannyConfig) runResolvers(stackList []map[string]string, fPath string) error { 171 m.OutputParameters = map[string]string{} 172 173 for key, value := range m.InputParameters { 174 var actualValue string 175 m.OutputParameters[key] = value.Value 176 switch value.Tag { 177 case "!environment_variable": 178 actualValue = os.Getenv(value.Value) 179 m.OutputParameters[key] = actualValue 180 case "!file_contents": 181 target := filepath.Join(filepath.Dir(fPath), value.Value) 182 data, err := ioutil.ReadFile(target) 183 if err != nil { 184 return err 185 } 186 m.OutputParameters[key] = string(data) 187 case "!stack_output": 188 sOutput := strings.Split(value.Value, "::") 189 sOutput[0] = filepath.Base(sOutput[0]) 190 for _, v := range stackList { 191 if v[sOutput[0]] != "" { 192 m.OutputParameters[key] = value.Tag + " " + v[sOutput[0]] + "::" + sOutput[1] 193 break 194 } else { 195 m.OutputParameters[key] = value.Tag + " " + value.Value 196 } 197 } 198 } 199 } 200 201 return nil 202 } 203 204 func (c Configurator) mannyConfigValidate(path string) bool { 205 inputConfigData, err := ioutil.ReadFile(path) 206 if err != nil { 207 return false 208 } 209 210 _, err = config.ConfigValidate(inputConfigData, config.Schemaconfig) 211 if err != nil { 212 c.logger.Info("mannyConfig validation failed", zap.Error(err)) 213 return false 214 } 215 216 c.logger.Debug("mannyConfig validation successful", zap.String("mannyConfig", path)) 217 return true 218 } 219 220 func (t TemplatePath) IsEmpty() bool { 221 return t == "" 222 } 223 224 func (t TemplatePath) String() string { 225 return string(t) 226 } 227 228 type Template struct { 229 Type string `yaml:"type"` 230 Path string `yaml:"path"` 231 Version string `yaml:"version"` 232 Name string `yaml:"name"` 233 } 234 235 // MannyConfig is a manny config. This is only read in yaml but can the Custom Resource that it gets written to can 236 // output in YAML or JSON. 237 type MannyConfig struct { 238 // FoundAt is the directory where Manny found the config 239 FoundAt string 240 // Template is the CloudFormation template provider config. 241 // Can be one of "file", "s3" 242 Template Template `yaml:"template"` 243 // TemplatePath provides the same functionality as Template but in one parseable string 244 TemplatePath TemplatePath `yaml:"template_path"` 245 // SyncWave 246 SyncWave int `yaml:"syncwave,omitempty"` 247 // InputParameters map to CloudFormation parameter values 248 InputParameters map[string]yaml.Node `yaml:"parameters,omitempty"` 249 // OutputParameters are derived from input parameters when using resolvers. These values are the ones that show up 250 // in the custom resource. 251 OutputParameters map[string]string 252 // Tags are CloudFormation tags to apply 253 Tags map[string]string `yaml:"tags"` 254 // StackName is the stack name to be used during execution 255 StackName string `yaml:"stackname"` 256 // RoleArn is the role to execute the CloudFormation with 257 RoleArn string `yaml:"rolearn"` 258 // ServiceRole that Cloudformation will assume. This is to have more security. 259 ServiceRoleARN string `yaml:"servicerolearn"` 260 // Expiry duration of the STS credentials. Defaults to 15 minutes if not set. 261 Duration time.Duration `yaml:"duration"` 262 // Optional ExternalID to pass along, defaults to nil if not set. 263 ExternalID string `yaml:"externalID,omitempty"` 264 // Optional AccountID. 265 AccountID string `yaml:"acctnum"` 266 // Optional Environment. 267 Environment string `yaml:"env,omitempty"` 268 // If ExpiryWindow is 0 or less it will be ignored. 269 ExpiryWindow time.Duration `yaml:"expirywindow,omitempty"` 270 // Timeout support 271 Timeout int `yaml:"timeout"` 272 //Region support 273 Region string `yaml:"region"` 274 // Base refers to additional configuration that should be loaded 275 Base string `yaml:"base"` 276 CloudFormation []byte 277 } 278 279 // Configurator builds final configuration from many configurations 280 type Configurator struct { 281 // Bases is the list of configs to be merged 282 Bases []MannyConfig 283 // Global is the config with all bases consumed 284 Global MannyConfig 285 // Stacks are the stack specific configs 286 Stacks []MannyConfig 287 // Origin is the original path that contains a config.yaml 288 Origin string 289 // GitURL is the remote URL used by Git 290 GitURL string 291 // References is a list of bases used 292 References []string 293 // StackPrefix is the generated prefix of the stack 294 StackPrefix string 295 // StackTable is a reference table for stacks 296 StackTable map[string]bool 297 298 logger *zap.Logger 299 s3Client s3iface.S3API 300 } 301 302 type Config struct { 303 Path string 304 GitURL string 305 Logger *zap.Logger 306 S3Client s3iface.S3API 307 } 308 309 // New creates a new configurator 310 func New(config Config) Configurator { 311 c := Configurator{ 312 logger: config.Logger, 313 GitURL: config.GitURL, 314 } 315 316 // Set the origin to an absolute path 317 path, _ := filepath.Abs(config.Path) 318 c.Origin = path + "/" 319 320 switch config.S3Client.(type) { 321 case *mocks.S3API: 322 c.s3Client = config.S3Client 323 default: 324 // use user credentials unless otherwise specified 325 config.Logger.Debug("S3Client not set, setting default") 326 327 sess := session.Must(session.NewSessionWithOptions(session.Options{ 328 Config: aws.Config{Region: aws.String("us-west-2")}, 329 SharedConfigState: session.SharedConfigEnable, 330 })) 331 332 c.s3Client = s3.New(sess) 333 } 334 335 return c 336 } 337 338 func (c *Configurator) CreateDeployments() (CloudResources, error) { 339 // build the config 340 err := c.loadBases() 341 if err != nil { 342 return nil, err 343 } 344 345 // render a manifest for deployment 346 return c.loadStacks() 347 } 348 349 // loadBases resolves base configs into the Global config 350 func (c *Configurator) loadBases() error { 351 // Abs() does not include a trailing slash 352 config := c.Origin + MannyConfigName 353 354 c.logger.Debug("Finding config", zap.String("path", config)) 355 356 // check to see if the config file exists 357 _, err := os.Stat(config) 358 if !os.IsNotExist(err) { 359 // Unmarshal the given config to determine if there are bases and recurses them 360 if err := c.unmarshal(config); err != nil { 361 return err 362 } 363 } 364 365 // Merge all the configs into one, starting with the bases 366 c.Global, err = c.mergeBases(c.Bases) 367 if err != nil { 368 return err 369 } 370 371 c.determineStackPrefix() 372 373 c.logger.Debug("Merged base configs", zap.Int("BaseConfigs", len(c.Bases)), 374 zap.Any("Global Config", c.Global)) 375 376 return nil 377 } 378 379 func (c *Configurator) determineStackPrefix() { 380 if counter == 0 { 381 baseDir = strings.Split(filepath.Clean(c.Origin), "/") 382 } 383 counter++ 384 dir := strings.Split(filepath.Clean(c.Origin), "/") 385 a := len(dir) - len(baseDir) 386 if a >= 0{ 387 c.StackPrefix = strings.Join(dir[len(dir)-(a):], "-") 388 } 389 c.logger.Debug("Stack prefix determined", zap.String("StackPrefix", c.StackPrefix)) 390 } 391 392 func (c Configurator) mergeBases(bases []MannyConfig) (MannyConfig, error) { 393 var global MannyConfig 394 395 for _, config := range bases { 396 if err := mergo.Merge(&global, config); err != nil { 397 return MannyConfig{}, err 398 } 399 } 400 401 return global, nil 402 } 403 404 func (c *Configurator) unmarshal(parentPath string) error { 405 c.logger.Debug("Reading file", zap.String("path", parentPath)) 406 407 data, err := ioutil.ReadFile(parentPath) 408 if err != nil { 409 return err 410 } 411 412 var config MannyConfig 413 if err := yaml.Unmarshal(data, &config); err != nil { 414 return err 415 } 416 417 config.FoundAt = filepath.Clean(filepath.Dir(parentPath)) 418 c.logger.Debug("Storing base location", zap.String("path", config.FoundAt)) 419 420 // Recurse bases 421 if config.Base != "" { 422 c.logger.Debug("Base detected", zap.String("path", config.Base)) 423 424 // determine absolute path 425 target := filepath.Join(filepath.Dir(parentPath), config.Base) 426 427 c.logger.Debug("Determining target", zap.String("ConfigPath", config.Base), 428 zap.String("TargetPath", target)) 429 430 // @ToDo: Convert to map/lookup table 431 for _, basePath := range c.References { 432 if target == basePath { 433 return errors.New("circular dependency found in " + target) 434 } 435 } 436 437 if len(c.References)+1 > 10 { 438 return errors.New("more than 10 referenced bases") 439 } 440 441 // track references 442 c.References = append(c.References, target) 443 444 if err := c.unmarshal(target); err != nil { 445 return err 446 } 447 } 448 449 c.Bases = append(c.Bases, config) 450 451 return nil 452 } 453 454 // loadStacks loads stacks from the local directory and creates CloudResourceDeployments from them 455 func (c *Configurator) loadStacks() (CloudResources, error) { 456 var resources CloudResources 457 458 c.logger.Debug("Looking for stack configs", zap.String("path", c.Origin)) 459 460 // find stack files in the origin directory 461 files, err := ioutil.ReadDir(c.Origin) 462 if err != nil { 463 return nil, err 464 } 465 // process VPC stacks first and then dir 466 var files_inorder, d []os.FileInfo 467 for i, f := range files { 468 if f.IsDir() { 469 d = append(d, files[i]) 470 }else { 471 files_inorder = append(files_inorder, f) 472 } 473 } 474 files_inorder = append(files_inorder, d...) 475 476 // load the stack configs 477 for _, f := range files_inorder { 478 // Resolve relative directories 479 target := filepath.Join(filepath.Dir(c.Origin+"/"), f.Name()) 480 481 if f.Name() == MannyConfigName { 482 continue 483 } 484 485 // Recurse sub directories 486 if f.IsDir() { 487 c.logger.Debug("Walking directory for config", zap.String("path", target)) 488 489 config := New(Config{ 490 Path: target + "/", 491 Logger: c.logger, 492 GitURL: c.GitURL, 493 }) 494 495 if err := config.loadBases(); err != nil { 496 c.logger.Info("Unable to load base from higher level directory", zap.Error(err)) 497 continue 498 } 499 500 deployments, err := config.loadStacks() 501 if err != nil { 502 c.logger.Info("Unable to load stacks from higher level directory", zap.Error(err)) 503 continue 504 } 505 506 resources = append(resources, deployments...) 507 508 continue 509 } 510 511 extension := filepath.Ext(target) 512 if contains(AcceptedExtensions, extension) && c.mannyConfigValidate(target) { 513 // Handle stack generation 514 c.logger.Debug("Reading stack config", zap.String("path", target)) 515 516 data, err := ioutil.ReadFile(target) 517 if err != nil { 518 return nil, err 519 } 520 521 var config MannyConfig 522 // unmarshal the stack config 523 err = yaml.Unmarshal(data, &config) 524 if err != nil { 525 return nil, err 526 } 527 528 // If no stack name is found, generate one 529 m := make(map[string]string) 530 if config.StackName == "" { 531 config.StackName = strings.TrimSuffix(f.Name(), filepath.Ext(f.Name())) 532 if c.StackPrefix != "" { 533 config.StackName = c.StackPrefix + "-" + config.StackName 534 } 535 m[f.Name()] = config.StackName 536 s.StackList = append(s.StackList, m) 537 } else { 538 m[f.Name()] = config.StackName 539 s.StackList = append(s.StackList, m) 540 } 541 542 // determine whether the template block or template_path is used 543 if !config.TemplatePath.IsEmpty() { 544 config.Template = config.TemplatePath.Parse() 545 } 546 547 switch config.Template.Type { 548 case "file": 549 // Resolve relative directories 550 target := filepath.Join(filepath.Dir(target), config.Template.Path) 551 552 // Unmarshal the CloudFormation 553 config.CloudFormation, err = ioutil.ReadFile(target) 554 if err != nil { 555 return nil, err 556 } 557 case "http": 558 resp, err := http.Get(config.Template.Path) 559 if err != nil { 560 return nil, err 561 } 562 defer resp.Body.Close() 563 if resp.StatusCode == http.StatusOK { 564 config.CloudFormation, err = ioutil.ReadAll(resp.Body) 565 if err != nil { 566 return nil, err 567 } 568 } else { 569 c.logger.Debug("http handler template download: http_status_code", zap.String("http_status", resp.Status)) 570 } 571 case "s3": 572 //Parsing s3 bucket and key 573 u, _ := url.Parse(config.Template.Path) 574 if u.Host == "" || u.Path == "/" || u.Path == "" { 575 return nil, errors.New("s3 Bucket or Key not found: " + config.Template.Path) 576 } 577 578 // Support s3 http url format 579 if strings.Contains(u.Host, ".s3.amazonaws.com") { 580 u.Host = strings.ReplaceAll(u.Host, ".s3.amazonaws.com", "") 581 } 582 583 // tmpfile 584 fPath, err := ioutil.TempFile("", "s3-"+path.Base(config.Template.Path)) 585 if err != nil { 586 return nil, err 587 } 588 defer os.Remove(fPath.Name()) // clean up 589 590 //Download cfn template from s3 591 downloader := s3manager.NewDownloaderWithClient(c.s3Client) 592 _, err = downloader.Download(fPath, 593 &s3.GetObjectInput{ 594 Bucket: aws.String(u.Host), 595 Key: aws.String(u.Path), 596 }) 597 if err != nil { 598 return nil, err 599 } 600 601 // Unmarshal the CloudFormation 602 config.CloudFormation, err = ioutil.ReadFile(fPath.Name()) 603 if err != nil { 604 return nil, err 605 } 606 } 607 // Don't generate manifests for empty resources 608 resourceEmpty,err := config.CheckForEmptyResource() 609 if err != nil { 610 c.logger.Error("Error while check for resource count") 611 } 612 if !resourceEmpty { 613 c.Stacks = append(c.Stacks, config) 614 } 615 616 } else { 617 c.logger.Debug("Skipping file", zap.String("path", target)) 618 } 619 } 620 621 r, err := c.generateCR(c.Stacks) 622 if err != nil { 623 return nil, err 624 } 625 626 resources = append(resources, r...) 627 628 return resources, nil 629 } 630 631 func (c Configurator) generateCR(stacks []MannyConfig) (CloudResources, error) { 632 var manifests CloudResources 633 634 c.logger.With(zap.String("GitRemote", c.GitURL)).Debug("Writing git remote") 635 636 for _, stack := range stacks { 637 // Run resolvers 638 if err := stack.runResolvers(s.StackList, c.Origin); err != nil { 639 return nil, err 640 } 641 642 // Merge the Global config and the Stack config 643 if err := mergo.Merge(&stack, c.Global); err != nil { 644 return nil, err 645 } 646 if stack.SyncWave != 0 { 647 manifests = append(manifests, &CloudResourceDeployment{ 648 Kind: "CloudResourceDeployment", 649 APIVersion: "cloudresource.keikoproj.io/v1alpha1", 650 Metadata: Metadata{ 651 Name: stack.StackName, 652 Annotations: map[string]string{ 653 "source": c.GitURL, 654 "argocd.argoproj.io/sync-wave": strconv.Itoa(stack.SyncWave), 655 }, 656 }, 657 Spec: gitopsv1alpha1.CloudResourceDeploymentSpec{ 658 Cloudformation: &gitopsv1alpha1.StackSpec{ 659 Parameters: stack.OutputParameters, 660 Tags: stack.Tags, 661 Template: fmt.Sprintf("%s", stack.CloudFormation), 662 Stackname: stack.StackName, 663 CARole: gitopsv1alpha1.AssumeRoleProvider{ 664 RoleARN: stack.RoleArn, 665 RoleSessionName: "gitops-deployment", 666 ServiceRoleARN: stack.ServiceRoleARN, 667 ExternalID: stack.ExternalID, 668 AccountID: stack.AccountID, 669 Environment: stack.Environment, 670 Duration: stack.Duration, 671 ExpiryWindow: stack.ExpiryWindow, 672 }, 673 Timeout: stack.Timeout, 674 Region: stack.Region, 675 }, 676 }, 677 }) 678 } else { 679 manifests = append(manifests, &CloudResourceDeployment{ 680 Kind: "CloudResourceDeployment", 681 APIVersion: "cloudresource.keikoproj.io/v1alpha1", 682 Metadata: Metadata{ 683 Name: stack.StackName, 684 Annotations: map[string]string{ 685 "source": c.GitURL, 686 }, 687 }, 688 Spec: gitopsv1alpha1.CloudResourceDeploymentSpec{ 689 Cloudformation: &gitopsv1alpha1.StackSpec{ 690 Parameters: stack.OutputParameters, 691 Tags: stack.Tags, 692 Template: fmt.Sprintf("%s", stack.CloudFormation), 693 Stackname: stack.StackName, 694 CARole: gitopsv1alpha1.AssumeRoleProvider{ 695 RoleARN: stack.RoleArn, 696 RoleSessionName: "gitops-deployment", 697 ServiceRoleARN: stack.ServiceRoleARN, 698 ExternalID: stack.ExternalID, 699 AccountID: stack.AccountID, 700 Environment: stack.Environment, 701 Duration: stack.Duration, 702 ExpiryWindow: stack.ExpiryWindow, 703 }, 704 Timeout: stack.Timeout, 705 Region: stack.Region, 706 }, 707 }, 708 }) 709 } 710 } 711 712 return manifests, nil 713 }