This commit is contained in:
Beau
2024-02-26 12:44:50 +00:00
commit 3cd7e44cb3
22 changed files with 2158 additions and 0 deletions

249
cmd/generate.go Normal file
View File

@@ -0,0 +1,249 @@
package cmd
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/3bbbeau/tfvars-atlantis-config/logger"
"github.com/3bbbeau/tfvars-atlantis-config/repocfg"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"gopkg.in/yaml.v2"
)
// Flags represents the flags for the `generate` command
type Flags struct {
AutoMerge bool
AutoPlan bool
DefaultTerraformVersion string
MultiEnv bool
Output string
Parallel bool
Root string
UseWorkspaces bool
}
// NewFlags returns a default Flags struct
func NewFlags() (*Flags, error) {
pwd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("default flags for git root: %w", err)
}
return &Flags{
AutoMerge: false,
AutoPlan: false,
DefaultTerraformVersion: "",
Root: pwd,
MultiEnv: false,
Output: "",
Parallel: false,
UseWorkspaces: false,
}, nil
}
// AddFlags registers flags for the `generate` command
func (flags *Flags) AddFlags(cmd *cobra.Command) {
cmd.Flags().BoolVar(&flags.AutoPlan, "autoplan", flags.AutoPlan, "Enable auto plan. Default is disabled")
cmd.Flags().BoolVar(&flags.AutoMerge, "automerge", flags.AutoMerge, "Enable auto merge. Default is disabled")
cmd.Flags().BoolVar(&flags.Parallel, "parallel", flags.Parallel, "Enables plans and applys to happen in parallel. Default is disabled")
cmd.Flags().StringVar(&flags.Output, "output", flags.Output, "Path of the file where configuration will be generated. Default is stdout")
cmd.Flags().StringVar(&flags.Root, "root", flags.Root, "Path to the root directory of the git repo you want to build config for. Default is current dir")
cmd.Flags().BoolVar(&flags.MultiEnv, "multienv", flags.MultiEnv, "Enable injection of environment specific environment variables to each workflow. Default is disabled")
cmd.Flags().StringVar(&flags.DefaultTerraformVersion, "terraform-version", flags.DefaultTerraformVersion, "Default terraform version to run for Atlantis. Default is determined by the Terraform version constraints.")
cmd.Flags().BoolVar(&flags.UseWorkspaces, "use-workspaces", flags.UseWorkspaces, "Use workspaces for projects. Default is disabled")
cmd.Flags().VisitAll(func(f *pflag.Flag) {
logger.FromContext(cmd.Context()).Sugar().Debugf("Set flag: %s = %v", f.Name, f.Value)
})
}
// toOptions converts the flags provided for usage to Options within the repocfg package
func (flags *Flags) toOptions() repocfg.Options {
return repocfg.Options{
Automerge: flags.AutoMerge,
Autoplan: flags.AutoPlan,
DefaultTerraformVersion: flags.DefaultTerraformVersion,
MultiEnv: flags.MultiEnv,
Parallel: flags.Parallel,
UseWorkspaces: flags.UseWorkspaces,
}
}
// NewGenerateCmd creates a new `generate` command, while applying all flags
// with their defaults overlayed by the flags passed in by the caller.
func NewGenerateCmd() (*cobra.Command, error) {
flags, err := NewFlags()
if err != nil {
return nil, fmt.Errorf("new flags: %w", err)
}
cmd := &cobra.Command{
Use: "generate",
Short: "Makes Atlantis config for tfvar backed Terraform projects",
Long: "Logs Yaml representing Atlantis config to stderr",
RunE: func(cmd *cobra.Command, args []string) error {
err := generate(cmd, flags)
if err != nil {
return err
}
return nil
},
}
flags.AddFlags(cmd)
return cmd, nil
}
// generate is used to generate an Atlantis config, called from the `generate`
// command, creating an Atlantis configuration written to flags.OutputPath
func generate(cmd *cobra.Command, flags *Flags) error {
logger := logger.FromContext(cmd.Context())
tree, err := discover(cmd.Context(), flags)
if err != nil {
return err
}
opts := flags.toOptions()
cfg, err := repocfg.NewRepoCfg(tree, opts)
if err != nil {
return err
}
cfgBytes, err := yaml.Marshal(&cfg)
if err != nil {
return fmt.Errorf("repocfg: %w", err)
}
switch flags.Output {
case "":
fmt.Println(string(cfgBytes))
default:
logger.Sugar().Debugf("writing config to %s", flags.Output)
err := os.WriteFile(flags.Output, cfgBytes, 0o644)
if err != nil {
return err
}
}
return nil
}
const (
TF_EXT = ".tf"
TFVARS_EXT = ".tfvars"
TFVARS_JSON_EXT = ".tfvars.json"
)
// discover walks the directory tree and creates a slice of Terraform components
// and their dependencies based on the .tfvars files in the directory and
// subdirectories.
//
// However, if nested directories contain a .tf file, the .tfvars files in the
// subdirectories are ignored, as they are assumed to be part of a different
// parent node (Terraform component).
//
// For example, if the function is invoked at the path defined by the `root` flag:
//
// .
// ├── components
// │ ├── component1
// │ │ ├── main.tf
// │ │ └── dev.tfvars
// │ └── component2
// │ ├── extraVars
// │ │ └── stg.tfvars
// │ ├── main.tf
// │ └── dev.tfvars
//
// Then, the resulting slice of components would look like:
//
// Path: "components/component1"
// VarFiles: "components/component1/dev.tfvars"
//
// Path: "components/component2"
// VarFiles: []string{"components/component2/dev.tfvars", "components/component2/extraVars/stg.tfvars"}
func discover(ctx context.Context, flags *Flags) ([]repocfg.Component, error) {
logger := logger.FromContext(ctx)
var discovered []repocfg.Component
// Walk the directory tree from the root
err := filepath.Walk(flags.Root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Each parent should be a path containing a Terraform component
if !info.IsDir() && strings.HasSuffix(info.Name(), TF_EXT) {
logger.Debug(fmt.Sprintf("found .tf file: %s", path))
// Add the directory of the Terraform component as the parent
parent, err := filepath.Rel(flags.Root, filepath.Dir(path))
if err != nil {
return err
}
// Each edge should be a path containing Terraform variables files
// relative to the Terraform component
err = filepath.Walk(filepath.Dir(path), func(subpath string, subinfo os.FileInfo, suberr error) error {
if suberr != nil {
return suberr
}
// Filter out Terraform variable files
if !subinfo.IsDir() && (strings.HasSuffix(subinfo.Name(), TFVARS_EXT) || strings.HasSuffix(subinfo.Name(), TFVARS_JSON_EXT)) {
// Ignore nested Terraform variable files that might belong to nested components
if filepath.Dir(subpath) != filepath.Dir(path) && tfExistsInDir(filepath.Dir(subpath)) {
logger.Sugar().Debugf(`ignoring nested %s: %s
which belongs to the component at: %s
not: %s`, filepath.Ext(subpath), subpath, filepath.Dir(subpath), filepath.Dir(path),
)
return filepath.SkipDir
}
logger.Sugar().Debugf("component parent is %s", parent)
found := repocfg.Component{
Path: parent,
}
logger.Sugar().Debugf("found %s file: %s", filepath.Ext(subpath), subpath)
child, err := filepath.Rel(flags.Root, subpath)
if err != nil {
return err
}
// VarFile is a member of this component
logger.Sugar().Debugf("component %s has var file %s", parent, child)
found.VarFiles = append(found.VarFiles, child)
discovered = append(discovered, found)
}
return err
})
return filepath.SkipDir
}
return nil
})
if err != nil {
return nil, err
}
return discovered, nil
}
// tfExistsInDir returns true if a directory contains a Terraform file
func tfExistsInDir(dir string) bool {
files, err := filepath.Glob(filepath.Join(dir, "*"+TF_EXT))
if err != nil {
return false
}
return len(files) > 0
}

56
cmd/root.go Normal file
View File

@@ -0,0 +1,56 @@
package cmd
import (
"fmt"
"github.com/3bbbeau/tfvars-atlantis-config/logger"
"github.com/spf13/cobra"
"go.uber.org/zap"
)
// New returns the new root command for this utility.
//
// It initializes the root command and adds all subcommands.
func New() (*cobra.Command, error) {
cmd := &cobra.Command{
Use: "tfvars-atlantis-config",
Short: "Generates Atlantis Config for Terraform projects",
Long: `tfvars-atlantis-config is a utility that generates Atlantis configurations for Terraform projects
that use tfvars files per environment.`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
setLogger(cmd)
},
Run: func(cmd *cobra.Command, args []string) {
err := cmd.Help()
if err != nil {
logger.FromContext(cmd.Context()).Error("help cmd", zap.Error(err))
}
},
}
cmd.PersistentFlags().Bool("debug", false, "Enable debug mode")
cmd.AddCommand(NewVersionCmd())
gCmd, err := NewGenerateCmd()
if err != nil {
return nil, fmt.Errorf("creating generate command: %w", err)
}
cmd.AddCommand(gCmd)
return cmd, nil
}
// setLogger sets the logger for the command's lifespan, based on the debug flag.
func setLogger(cmd *cobra.Command) {
ctx := cmd.Context()
debug, err := cmd.Flags().GetBool("debug")
if err != nil || !debug {
z := zap.Must(zap.NewProductionConfig().Build())
ctx = logger.WithContext(ctx, z)
} else {
z := zap.Must(zap.NewDevelopmentConfig().Build())
ctx = logger.WithContext(ctx, z)
}
cmd.SetContext(ctx)
}

22
cmd/version.go Normal file
View File

@@ -0,0 +1,22 @@
package cmd
import (
"github.com/spf13/cobra"
)
// Build-time version string, overriden by ldflags.
var v string = "devel"
// NewVersionCmd returns the command for retrieving the version of this utility.
func NewVersionCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "version",
Short: "Version of terragrunt-atlantis-config",
Long: "Version of terragrunt-atlantis-config",
Run: func(cmd *cobra.Command, args []string) {
cmd.Println(v)
},
}
return cmd
}

24
cmd/version_test.go Normal file
View File

@@ -0,0 +1,24 @@
package cmd
import (
"bytes"
"testing"
)
// Tests the NewVersionCmd function returns the version of the utility.
func Test_NewVersionCmd(t *testing.T) {
t.Parallel()
got := new(bytes.Buffer)
cmd, _ := New()
cmd.SetArgs([]string{"version"})
cmd.SetOutput(got)
cmd.Execute() // nolint:errcheck
want := "devel\n"
if got.String() != want {
t.Errorf(`NewVersionCmd()
got %s
want %v`, got.String(), want)
}
}