diff --git a/bt/README.adoc b/bt/README.adoc new file mode 100644 index 0000000..1a0c8a3 --- /dev/null +++ b/bt/README.adoc @@ -0,0 +1,64 @@ += bt: Build Terraform + +A no commitments Terraform wrapper that provides build caching functionality. + +== Install + +Install the binary into your `~/go/bin`: + +---- +go install github.com/DavidGamba/dgtools/bt@latest +---- + +Then setup the completion. + +For bash: + +---- +complete -o default -C bt bt +---- + +For zsh: + +---- +autoload bashcompinit +bashcompinit +complete -o default -C bt bt +---- + +== Config file + +The config file must be saved in a file named `.bt.cue`. +It will be searched from the current dir upwards. + +Example: + +.Config file .bt.cue +[source, cue] +---- +terraform: { + init: { + backend_config: ["backend.tfvars"] + } + plan: { + var_file: ["vars.tfvars"] + } + workspaces: { + enabled: true + dir: "envs" + } +} +---- + +== Usage + +== Caching Internals + +After running `terraform init` it will save a `.tf.init` file. +It will use that file to determine if any files have changed and if re-running is required. + +After running `terraform plan` it will save a `.tf.plan` or `.tf.plan-` file. +It will use that file to determine if the plan already exists of if needs to be run. + +After running `terraform apply` it will save a `.tf.apply` or `.tf.apply-` file. +It will use that file to determine if the apply has already been made. diff --git a/bt/config/config.go b/bt/config/config.go new file mode 100644 index 0000000..33ec609 --- /dev/null +++ b/bt/config/config.go @@ -0,0 +1,127 @@ +package config + +import ( + "context" + "embed" + "fmt" + "log" + "os" + "path/filepath" + + "github.com/DavidGamba/dgtools/cueutils" +) + +//go:embed schema.cue +var f embed.FS + +var Logger = log.New(os.Stderr, "", log.LstdFlags) + +type Config struct { + Terraform struct { + Init struct { + BackendConfig []string `json:"backend_config"` + } + Plan struct { + VarFile []string `json:"var_file"` + } + Workspaces struct { + Enabled bool + Dir string + } + } +} + +func (c *Config) String() string { + return fmt.Sprintf("backend_config files: %v, var files: %v, workspaces enabled: %t, ws dir: '%s'", + c.Terraform.Init.BackendConfig, + c.Terraform.Plan.VarFile, + c.Terraform.Workspaces.Enabled, + c.Terraform.Workspaces.Dir, + ) +} + +type contextKey string + +const configKey contextKey = "config" + +func NewConfigContext(ctx context.Context, value *Config) context.Context { + return context.WithValue(ctx, configKey, value) +} + +func ConfigFromContext(ctx context.Context) *Config { + v, ok := ctx.Value(configKey).(*Config) + if ok { + return v + } + return &Config{} +} + +func Get(ctx context.Context, filename string) (*Config, string, error) { + f, err := FindFileUpwards(ctx, filename) + if err != nil { + return &Config{}, f, fmt.Errorf("failed to find config file: %w", err) + } + cfg, err := Read(ctx, f) + if err != nil { + return &Config{}, f, fmt.Errorf("failed to read config: %w", err) + } + return cfg, f, nil +} + +func Read(ctx context.Context, filename string) (*Config, error) { + configs := []cueutils.CueConfigFile{} + + schemaFilename := "schema.cue" + schemaFH, err := f.Open(schemaFilename) + if err != nil { + return nil, fmt.Errorf("failed to open '%s': %w", schemaFilename, err) + } + defer schemaFH.Close() + configs = append(configs, cueutils.CueConfigFile{Data: schemaFH, Name: schemaFilename}) + + configFH, err := os.Open(filename) + if err != nil { + return nil, fmt.Errorf("failed to open '%s': %w", filename, err) + } + defer configFH.Close() + configs = append(configs, cueutils.CueConfigFile{Data: configFH, Name: filename}) + + c := Config{} + err = cueutils.Unmarshal(configs, &c) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal: %w", err) + } + + return &c, nil +} + +func FindFileUpwards(ctx context.Context, filename string) (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get cwd: %w", err) + } + check := func(dir string) bool { + f := filepath.Join(dir, filename) + if _, err := os.Stat(f); os.IsNotExist(err) { + return false + } + return true + } + d := cwd + for { + found := check(d) + if found { + return filepath.Join(d, filename), nil + } + a, err := filepath.Abs(d) + if err != nil { + return "", fmt.Errorf("failed to get abs path: %w", err) + } + if a == "/" { + break + } + d = filepath.Join(d, "../") + } + + return "", fmt.Errorf("not found: %s", filename) +} diff --git a/bt/config/schema.cue b/bt/config/schema.cue new file mode 100644 index 0000000..025e327 --- /dev/null +++ b/bt/config/schema.cue @@ -0,0 +1,14 @@ +package bt + +#Terraform: { + init?: { + backend_config: [...string] + } + plan?: { + var_file: [...string] + } + workspaces?: { + enabled: bool + dir: string + } +} diff --git a/bt/go.mod b/bt/go.mod new file mode 100644 index 0000000..6ad06db --- /dev/null +++ b/bt/go.mod @@ -0,0 +1,22 @@ +module github.com/DavidGamba/dgtools/bt + +go 1.21 + +require ( + cuelang.org/go v0.6.0 // indirect + github.com/DavidGamba/dgtools/buildutils v0.2.0 // indirect + github.com/DavidGamba/dgtools/cueutils v0.0.0-20230620071340-793ec59816c0 // indirect + github.com/DavidGamba/dgtools/fsmodtime v0.1.0 // indirect + github.com/DavidGamba/dgtools/run v0.7.0 // indirect + github.com/DavidGamba/go-getoptions v0.27.0 // indirect + github.com/cockroachdb/apd/v2 v2.0.2 // indirect + github.com/cockroachdb/apd/v3 v3.2.0 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de // indirect + github.com/pkg/errors v0.9.1 // indirect + golang.org/x/net v0.14.0 // indirect + golang.org/x/sys v0.11.0 // indirect + golang.org/x/text v0.12.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/bt/go.sum b/bt/go.sum new file mode 100644 index 0000000..66e6200 --- /dev/null +++ b/bt/go.sum @@ -0,0 +1,35 @@ +cuelang.org/go v0.6.0 h1:dJhgKCog+FEZt7OwAYV1R+o/RZPmE8aqFoptmxSWyr8= +cuelang.org/go v0.6.0/go.mod h1:9CxOX8aawrr3BgSdqPj7V0RYoXo7XIb+yDFC6uESrOQ= +github.com/DavidGamba/dgtools/buildutils v0.2.0 h1:YrINv+hsXQFhdfdYFdDVheBLbz3D7OQtNFuH+eKNyo0= +github.com/DavidGamba/dgtools/buildutils v0.2.0/go.mod h1:gEikilH0xsJVMydPErNv4mlUPhJGFT5k6v5Ss+Fn+d4= +github.com/DavidGamba/dgtools/cueutils v0.0.0-20230620071340-793ec59816c0 h1:hrLbd4GGBVUvoQX3gfuNA+kbMyva4omjWNTyFuLAW34= +github.com/DavidGamba/dgtools/cueutils v0.0.0-20230620071340-793ec59816c0/go.mod h1:Ccl14sr0J7MOMsP0JcmBzZbjaxobN2/2nRzd+AV2NBA= +github.com/DavidGamba/dgtools/fsmodtime v0.1.0 h1:aoKDoKesWyDL06dDtRaTNOZqcUPRgQxLpj3zigSGjiY= +github.com/DavidGamba/dgtools/fsmodtime v0.1.0/go.mod h1:ruwqMvW2pWDbSQlAupP7F0QaojfbuXPyUOUKR4Ev3pQ= +github.com/DavidGamba/dgtools/run v0.7.0 h1:ENTskNQvBM1/n/b42PxqJktU9EzQVqPZRGUBtVdEVdE= +github.com/DavidGamba/dgtools/run v0.7.0/go.mod h1:3P1fMJupTWqsiE8IXsXrk2HtgkZBTCFRLbaTjRlmDe0= +github.com/DavidGamba/go-getoptions v0.27.0 h1:hldKJSwO9SwvR+z9pe6ojhEcYECrRiO/bar9B7MnBKA= +github.com/DavidGamba/go-getoptions v0.27.0/go.mod h1:qLaLSYeQ8sUVOfKuu5JT5qKKS3OCwyhkYSJnoG+ggmo= +github.com/cockroachdb/apd/v2 v2.0.2 h1:weh8u7Cneje73dDh+2tEVLUvyBc89iwepWCD8b8034E= +github.com/cockroachdb/apd/v2 v2.0.2/go.mod h1:DDxRlzC2lo3/vSlmSoS7JkqbbrARPuFOGr0B9pvN3Gw= +github.com/cockroachdb/apd/v3 v3.2.0 h1:79kHCn4tO0VGu3W0WujYrMjBDk8a2H4KEUYcXf7whcg= +github.com/cockroachdb/apd/v3 v3.2.0/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de h1:D5x39vF5KCwKQaw+OC9ZPiLVHXz3UFw2+psEX+gYcto= +github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de/go.mod h1:kJun4WP5gFuHZgRjZUWWuH1DTxCtxbHDOIJsudS8jzY= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= +golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/bt/main.go b/bt/main.go new file mode 100644 index 0000000..275dd02 --- /dev/null +++ b/bt/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "errors" + "fmt" + "io" + "log" + "os" + + "github.com/DavidGamba/dgtools/bt/config" + "github.com/DavidGamba/dgtools/bt/terraform" + "github.com/DavidGamba/go-getoptions" +) + +var Logger = log.New(os.Stderr, "", log.LstdFlags) + +func main() { + os.Exit(program(os.Args)) +} + +func program(args []string) int { + ctx, cancel, done := getoptions.InterruptContext() + defer func() { cancel(); <-done }() + + // Read config and store it in context + cfg, _, _ := config.Get(ctx, ".bt.cue") + ctx = config.NewConfigContext(ctx, cfg) + + opt := getoptions.New() + opt.Self("", "Terraform build system built as a no lock-in wrapper") + opt.Bool("quiet", false, opt.GetEnv("QUIET")) + opt.SetUnknownMode(getoptions.Pass) + + terraform.NewCommand(ctx, opt) + + opt.HelpCommand("help", opt.Alias("?")) + remaining, err := opt.Parse(args[1:]) + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %s\n", err) + return 1 + } + if opt.Called("quiet") { + Logger.SetOutput(io.Discard) + } + + err = opt.Dispatch(ctx, remaining) + if err != nil { + if errors.Is(err, getoptions.ErrorHelpCalled) { + return 1 + } + fmt.Fprintf(os.Stderr, "ERROR: %s\n", err) + return 1 + } + return 0 +} diff --git a/bt/terraform/apply.go b/bt/terraform/apply.go new file mode 100644 index 0000000..f675fc9 --- /dev/null +++ b/bt/terraform/apply.go @@ -0,0 +1,91 @@ +package terraform + +import ( + "context" + "fmt" + "os" + + "github.com/DavidGamba/dgtools/bt/config" + "github.com/DavidGamba/dgtools/fsmodtime" + "github.com/DavidGamba/dgtools/run" + "github.com/DavidGamba/go-getoptions" + "github.com/mattn/go-isatty" +) + +func applyCMD(ctx context.Context, parent *getoptions.GetOpt) *getoptions.GetOpt { + cfg := config.ConfigFromContext(ctx) + + opt := parent.NewCommand("apply", "") + opt.SetCommandFn(applyRun) + + wss, err := validWorkspaces(cfg) + if err != nil { + Logger.Printf("WARNING: failed to list workspaces: %s\n", err) + } + opt.String("ws", "", opt.ValidValues(wss...), opt.Description("Workspace to use")) + + return opt +} + +func applyRun(ctx context.Context, opt *getoptions.GetOpt, args []string) error { + ws := opt.Value("ws").(string) + ws, err := updateWSIfSelected(ws) + if err != nil { + return err + } + + cfg := config.ConfigFromContext(ctx) + Logger.Printf("cfg: %s\n", cfg) + + if cfg.Terraform.Workspaces.Enabled { + if !workspaceSelected() { + if ws == "" { + return fmt.Errorf("running in workspace mode but no workspace selected or --ws given") + } + } + } + + applyFile := "" + planFile := "" + if ws == "" { + planFile = ".tf.plan" + applyFile = ".tf.apply" + } else { + planFile = fmt.Sprintf(".tf.plan-%s", ws) + applyFile = fmt.Sprintf(".tf.apply-%s", ws) + } + files, modified, err := fsmodtime.Target(os.DirFS("."), []string{applyFile}, []string{planFile}) + if err != nil { + Logger.Printf("failed to check changes for: '%s'\n", applyFile) + } + if !modified { + Logger.Printf("no changes: skipping apply\n") + return nil + } + Logger.Printf("modified: %v\n", files) + + cmd := []string{"terraform", "apply"} + cmd = append(cmd, "-input", planFile) + if !isatty.IsTerminal(os.Stdout.Fd()) { + cmd = append(cmd, "-no-color") + } + cmd = append(cmd, args...) + ri := run.CMD(cmd...).Ctx(ctx).Stdin().Log() + if ws != "" { + wsEnv := fmt.Sprintf("TF_WORKSPACE=%s", ws) + Logger.Printf("export %s\n", wsEnv) + ri.Env(wsEnv) + } + err = ri.Run() + if err != nil { + os.Remove(planFile) + return fmt.Errorf("failed to run: %w", err) + } + + fh, err := os.Create(applyFile) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + fh.Close() + return nil +} diff --git a/bt/terraform/build.go b/bt/terraform/build.go new file mode 100644 index 0000000..6c24527 --- /dev/null +++ b/bt/terraform/build.go @@ -0,0 +1,99 @@ +package terraform + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + + "github.com/DavidGamba/dgtools/bt/config" + "github.com/DavidGamba/go-getoptions" + "github.com/DavidGamba/go-getoptions/dag" +) + +func buildCMD(ctx context.Context, parent *getoptions.GetOpt) *getoptions.GetOpt { + cfg := config.ConfigFromContext(ctx) + + opt := parent.NewCommand("build", "Wraps init, plan and apply into a single operation with a cache") + opt.SetCommandFn(buildRun) + opt.StringSlice("var-file", 1, 1) + opt.Bool("destroy", false) + opt.Bool("detailed-exitcode", false) + opt.Bool("ignore-cache", false, opt.Description("ignore the cache and re-run the plan"), opt.Alias("ic")) + opt.StringSlice("target", 1, 99) + opt.Bool("apply", false, opt.Description("apply Terraform plan")) + + wss, err := validWorkspaces(cfg) + if err != nil { + Logger.Printf("WARNING: failed to list workspaces: %s\n", err) + } + opt.String("ws", "", opt.ValidValues(wss...), opt.Description("Workspace to use")) + + return opt +} + +func buildRun(ctx context.Context, opt *getoptions.GetOpt, args []string) error { + apply := opt.Value("apply").(bool) + detailedExitcode := opt.Value("detailed-exitcode").(bool) + ws := opt.Value("ws").(string) + ws, err := updateWSIfSelected(ws) + if err != nil { + return err + } + + cfg := config.ConfigFromContext(ctx) + Logger.Printf("cfg: %s\n", cfg) + + if cfg.Terraform.Workspaces.Enabled { + if !workspaceSelected() { + if ws == "" { + return fmt.Errorf("running in workspace mode but no workspace selected or --ws given") + } + } + } + + initFn := func(ctx context.Context, opt *getoptions.GetOpt, args []string) error { + // TODO: Add logic to only run when files have been modified + if _, err := os.Stat(".tf.init"); os.IsNotExist(err) { + return initRun(ctx, opt, args) + } + return nil + } + + tm := dag.NewTaskMap() + tm.Add("init", initFn) + tm.Add("plan", planRun) + if apply { + tm.Add("apply", applyRun) + } + + g := dag.NewGraph("build") + g.TaskDependensOn(tm.Get("plan"), tm.Get("init")) + + if apply { + g.TaskDependensOn(tm.Get("apply"), tm.Get("plan")) + } + err = g.Validate(tm) + if err != nil { + return fmt.Errorf("failed to validate graph: %w", err) + } + + err = g.Run(ctx, opt, args) + if err != nil { + var errs *dag.Errors + if errors.As(err, &errs) { + if len(errs.Errors) == 1 { + // If we are returning an exit code of 2 when asking for terraform plan's detailed-exitcode then pass that exit code + var eerr *exec.ExitError + if detailedExitcode && errors.As(errs.Errors[0], &eerr) && eerr.ExitCode() == 2 { + Logger.Printf("plan has changes\n") + return eerr + } + } + } + return fmt.Errorf("failed to run graph: %w", err) + } + + return nil +} diff --git a/bt/terraform/forceunlock.go b/bt/terraform/forceunlock.go new file mode 100644 index 0000000..fd5525c --- /dev/null +++ b/bt/terraform/forceunlock.go @@ -0,0 +1,40 @@ +package terraform + +import ( + "context" + "fmt" + "os" + "slices" + + "github.com/DavidGamba/dgtools/bt/config" + "github.com/DavidGamba/go-getoptions" +) + +func forceUnlockCMD(ctx context.Context, parent *getoptions.GetOpt) *getoptions.GetOpt { + cfg := config.ConfigFromContext(ctx) + + opt := parent.NewCommand("force-unlock", "") + opt.SetCommandFn(forceUnlockRun) + opt.HelpSynopsisArg("", "Lock ID") + + wss, err := validWorkspaces(cfg) + if err != nil { + Logger.Printf("WARNING: failed to list workspaces: %s\n", err) + } + opt.String("ws", "", opt.ValidValues(wss...), opt.Description("Workspace to use")) + + return opt +} + +func forceUnlockRun(ctx context.Context, opt *getoptions.GetOpt, args []string) error { + if len(args) < 1 { + fmt.Fprintf(os.Stderr, "ERROR: missing \n") + fmt.Fprintf(os.Stderr, "%s", opt.Help(getoptions.HelpSynopsis)) + return getoptions.ErrorHelpCalled + } + lockID := args[0] + args = slices.Delete(args, 0, 1) + + cmd := []string{"terraform", "force-unlock", "-force", lockID} + return wsCMDRun(cmd...)(ctx, opt, args) +} diff --git a/bt/terraform/import.go b/bt/terraform/import.go new file mode 100644 index 0000000..bcd53f2 --- /dev/null +++ b/bt/terraform/import.go @@ -0,0 +1,29 @@ +package terraform + +import ( + "context" + + "github.com/DavidGamba/dgtools/bt/config" + "github.com/DavidGamba/go-getoptions" +) + +func importCMD(ctx context.Context, parent *getoptions.GetOpt) *getoptions.GetOpt { + cfg := config.ConfigFromContext(ctx) + + opt := parent.NewCommand("import", "") + opt.StringSlice("var-file", 1, 1) + opt.SetCommandFn(importRun) + + wss, err := validWorkspaces(cfg) + if err != nil { + Logger.Printf("WARNING: failed to list workspaces: %s\n", err) + } + opt.String("ws", "", opt.ValidValues(wss...), opt.Description("Workspace to use")) + + return opt +} + +func importRun(ctx context.Context, opt *getoptions.GetOpt, args []string) error { + i := invalidatePlan{} + return varFileCMDRun(i, "terraform", "import")(ctx, opt, args) +} diff --git a/bt/terraform/init.go b/bt/terraform/init.go new file mode 100644 index 0000000..a4b9fcc --- /dev/null +++ b/bt/terraform/init.go @@ -0,0 +1,53 @@ +package terraform + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/DavidGamba/dgtools/bt/config" + "github.com/DavidGamba/dgtools/fsmodtime" + "github.com/DavidGamba/dgtools/run" + "github.com/DavidGamba/go-getoptions" + "github.com/mattn/go-isatty" +) + +func initCMD(ctx context.Context, parent *getoptions.GetOpt) *getoptions.GetOpt { + opt := parent.NewCommand("init", "") + opt.SetCommandFn(initRun) + return opt +} + +func initRun(ctx context.Context, opt *getoptions.GetOpt, args []string) error { + cfg := config.ConfigFromContext(ctx) + Logger.Printf("cfg: %s\n", cfg) + + cmd := []string{"terraform", "init"} + + for _, bvars := range cfg.Terraform.Init.BackendConfig { + b := strings.ReplaceAll(bvars, "~", "$HOME") + bb, err := fsmodtime.ExpandEnv([]string{b}) + if err != nil { + return fmt.Errorf("failed to expand: %w", err) + } + if _, err := os.Stat(bb[0]); err == nil { + cmd = append(cmd, "-backend-config", bb[0]) + } + } + if !isatty.IsTerminal(os.Stdout.Fd()) { + cmd = append(cmd, "-no-color") + } + cmd = append(cmd, args...) + err := run.CMD(cmd...).Ctx(ctx).Stdin().Log().Run() + if err != nil { + return fmt.Errorf("failed to run: %w", err) + } + fh, err := os.Create(".tf.init") + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + fh.Close() + + return nil +} diff --git a/bt/terraform/output.go b/bt/terraform/output.go new file mode 100644 index 0000000..8504d2a --- /dev/null +++ b/bt/terraform/output.go @@ -0,0 +1,28 @@ +package terraform + +import ( + "context" + + "github.com/DavidGamba/dgtools/bt/config" + "github.com/DavidGamba/go-getoptions" +) + +func outputCMD(ctx context.Context, parent *getoptions.GetOpt) *getoptions.GetOpt { + cfg := config.ConfigFromContext(ctx) + + opt := parent.NewCommand("output", "") + opt.SetCommandFn(outputRun) + + wss, err := validWorkspaces(cfg) + if err != nil { + Logger.Printf("WARNING: failed to list workspaces: %s\n", err) + } + opt.String("ws", "", opt.ValidValues(wss...), opt.Description("Workspace to use")) + + return opt +} + +func outputRun(ctx context.Context, opt *getoptions.GetOpt, args []string) error { + cmd := []string{"terraform", "output"} + return wsCMDRun(cmd...)(ctx, opt, args) +} diff --git a/bt/terraform/plan.go b/bt/terraform/plan.go new file mode 100644 index 0000000..ebe5c3c --- /dev/null +++ b/bt/terraform/plan.go @@ -0,0 +1,125 @@ +package terraform + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + + "github.com/DavidGamba/dgtools/bt/config" + "github.com/DavidGamba/dgtools/fsmodtime" + "github.com/DavidGamba/dgtools/run" + "github.com/DavidGamba/go-getoptions" + "github.com/mattn/go-isatty" +) + +func planCMD(ctx context.Context, parent *getoptions.GetOpt) *getoptions.GetOpt { + cfg := config.ConfigFromContext(ctx) + + opt := parent.NewCommand("plan", "") + opt.StringSlice("var-file", 1, 1) + opt.Bool("destroy", false) + opt.Bool("detailed-exitcode", false) + opt.Bool("ignore-cache", false, opt.Description("ignore the cache and re-run the plan"), opt.Alias("ic")) + opt.StringSlice("target", 1, 99) + opt.SetCommandFn(planRun) + + wss, err := validWorkspaces(cfg) + if err != nil { + Logger.Printf("WARNING: failed to list workspaces: %s\n", err) + } + opt.String("ws", "", opt.ValidValues(wss...), opt.Description("Workspace to use")) + + return opt +} + +func planRun(ctx context.Context, opt *getoptions.GetOpt, args []string) error { + destroy := opt.Value("destroy").(bool) + detailedExitcode := opt.Value("detailed-exitcode").(bool) + ignoreCache := opt.Value("ignore-cache").(bool) + varFiles := opt.Value("var-file").([]string) + targets := opt.Value("target").([]string) + ws := opt.Value("ws").(string) + ws, err := updateWSIfSelected(ws) + if err != nil { + return err + } + + cfg := config.ConfigFromContext(ctx) + Logger.Printf("cfg: %s\n", cfg) + + ws, err = getWorkspace(cfg, ws, varFiles) + if err != nil { + return err + } + + defaultVarFiles, err := getDefaultVarFiles(cfg) + if err != nil { + return err + } + + varFiles, err = AddVarFileIfWorkspaceSelected(cfg, ws, varFiles) + if err != nil { + return err + } + + planFile := "" + if ws == "" { + planFile = ".tf.plan" + } else { + planFile = fmt.Sprintf(".tf.plan-%s", ws) + } + + files, modified, err := fsmodtime.Target(os.DirFS("."), + []string{planFile}, + append(append([]string{".tf.init"}, defaultVarFiles...), varFiles...)) + if err != nil { + Logger.Printf("failed to check changes for: '%s'\n", ".tf.init") + } + if !ignoreCache && !modified { + Logger.Printf("no changes: skipping plan\n") + return nil + } + Logger.Printf("modified: %v\n", files) + + cmd := []string{"terraform", "plan", "-out", planFile} + for _, v := range defaultVarFiles { + cmd = append(cmd, "-var-file", v) + } + for _, v := range varFiles { + cmd = append(cmd, "-var-file", v) + } + if destroy { + cmd = append(cmd, "-destroy") + } + if detailedExitcode { + cmd = append(cmd, "-detailed-exitcode") + } + for _, t := range targets { + cmd = append(cmd, "-target", t) + } + if !isatty.IsTerminal(os.Stdout.Fd()) { + cmd = append(cmd, "-no-color") + } + cmd = append(cmd, args...) + + ri := run.CMD(cmd...).Ctx(ctx).Stdin().Log() + if ws != "" { + wsEnv := fmt.Sprintf("TF_WORKSPACE=%s", ws) + Logger.Printf("export %s\n", wsEnv) + ri.Env(wsEnv) + } + err = ri.Run() + if err != nil { + // exit code 2 with detailed-exitcode means changes found + var eerr *exec.ExitError + if detailedExitcode && errors.As(err, &eerr) && eerr.ExitCode() == 2 { + Logger.Printf("plan has changes\n") + return eerr + } + os.Remove(planFile) + return fmt.Errorf("failed to run: %w", err) + } + return nil +} diff --git a/bt/terraform/refresh.go b/bt/terraform/refresh.go new file mode 100644 index 0000000..a4ba0b3 --- /dev/null +++ b/bt/terraform/refresh.go @@ -0,0 +1,29 @@ +package terraform + +import ( + "context" + + "github.com/DavidGamba/dgtools/bt/config" + "github.com/DavidGamba/go-getoptions" +) + +func refreshCMD(ctx context.Context, parent *getoptions.GetOpt) *getoptions.GetOpt { + cfg := config.ConfigFromContext(ctx) + + opt := parent.NewCommand("refresh", "") + opt.StringSlice("var-file", 1, 1) + opt.SetCommandFn(refreshRun) + + wss, err := validWorkspaces(cfg) + if err != nil { + Logger.Printf("WARNING: failed to list workspaces: %s\n", err) + } + opt.String("ws", "", opt.ValidValues(wss...), opt.Description("Workspace to use")) + + return opt +} + +func refreshRun(ctx context.Context, opt *getoptions.GetOpt, args []string) error { + i := invalidatePlan{} + return varFileCMDRun(i, "terraform", "refresh")(ctx, opt, args) +} diff --git a/bt/terraform/show.go b/bt/terraform/show.go new file mode 100644 index 0000000..e20d61b --- /dev/null +++ b/bt/terraform/show.go @@ -0,0 +1,28 @@ +package terraform + +import ( + "context" + + "github.com/DavidGamba/dgtools/bt/config" + "github.com/DavidGamba/go-getoptions" +) + +func showCMD(ctx context.Context, parent *getoptions.GetOpt) *getoptions.GetOpt { + cfg := config.ConfigFromContext(ctx) + + opt := parent.NewCommand("show", "") + opt.SetCommandFn(showRun) + + wss, err := validWorkspaces(cfg) + if err != nil { + Logger.Printf("WARNING: failed to list workspaces: %s\n", err) + } + opt.String("ws", "", opt.ValidValues(wss...), opt.Description("Workspace to use")) + + return opt +} + +func showRun(ctx context.Context, opt *getoptions.GetOpt, args []string) error { + cmd := []string{"terraform", "show"} + return wsCMDRun(cmd...)(ctx, opt, args) +} diff --git a/bt/terraform/show_plan.go b/bt/terraform/show_plan.go new file mode 100644 index 0000000..97b46e1 --- /dev/null +++ b/bt/terraform/show_plan.go @@ -0,0 +1,69 @@ +package terraform + +import ( + "context" + "fmt" + "os" + + "github.com/DavidGamba/dgtools/bt/config" + "github.com/DavidGamba/dgtools/run" + "github.com/DavidGamba/go-getoptions" + "github.com/mattn/go-isatty" +) + +func showPlanCMD(ctx context.Context, parent *getoptions.GetOpt) *getoptions.GetOpt { + cfg := config.ConfigFromContext(ctx) + + opt := parent.NewCommand("show-plan", "") + opt.SetCommandFn(showPlanRun) + + wss, err := validWorkspaces(cfg) + if err != nil { + Logger.Printf("WARNING: failed to list workspaces: %s\n", err) + } + opt.String("ws", "", opt.ValidValues(wss...), opt.Description("Workspace to use")) + + return opt +} + +func showPlanRun(ctx context.Context, opt *getoptions.GetOpt, args []string) error { + ws := opt.Value("ws").(string) + ws, err := updateWSIfSelected(ws) + if err != nil { + return err + } + + cfg := config.ConfigFromContext(ctx) + Logger.Printf("cfg: %s\n", cfg) + + if cfg.Terraform.Workspaces.Enabled { + if !workspaceSelected() { + if ws == "" { + return fmt.Errorf("running in workspace mode but no workspace selected or --ws given") + } + } + } + + cmd := []string{"terraform", "show"} + cmd = append(cmd, args...) + // possitional arg goes at the end + if ws == "" { + cmd = append(cmd, ".tf.plan") + } else { + cmd = append(cmd, fmt.Sprintf(".tf.plan-%s", ws)) + } + if !isatty.IsTerminal(os.Stdout.Fd()) { + cmd = append(cmd, "-no-color") + } + ri := run.CMD(cmd...).Ctx(ctx).Stdin().Log() + if ws != "" { + wsEnv := fmt.Sprintf("TF_WORKSPACE=%s", ws) + Logger.Printf("export %s\n", wsEnv) + ri.Env(wsEnv) + } + err = ri.Run() + if err != nil { + return fmt.Errorf("failed to run: %w", err) + } + return nil +} diff --git a/bt/terraform/state.go b/bt/terraform/state.go new file mode 100644 index 0000000..7e8c911 --- /dev/null +++ b/bt/terraform/state.go @@ -0,0 +1,60 @@ +package terraform + +import ( + "context" + "fmt" + "os" + "slices" + + "github.com/DavidGamba/dgtools/bt/config" + "github.com/DavidGamba/go-getoptions" +) + +func statePushCMD(ctx context.Context, parent *getoptions.GetOpt) *getoptions.GetOpt { + cfg := config.ConfigFromContext(ctx) + + opt := parent.NewCommand("state-push", "") + opt.SetCommandFn(statePushRun) + opt.HelpSynopsisArg("", "State file to push") + + wss, err := validWorkspaces(cfg) + if err != nil { + Logger.Printf("WARNING: failed to list workspaces: %s\n", err) + } + opt.String("ws", "", opt.ValidValues(wss...), opt.Description("Workspace to use")) + + return opt +} + +func statePushRun(ctx context.Context, opt *getoptions.GetOpt, args []string) error { + if len(args) < 1 { + fmt.Fprintf(os.Stderr, "ERROR: missing \n") + fmt.Fprintf(os.Stderr, "%s", opt.Help(getoptions.HelpSynopsis)) + return getoptions.ErrorHelpCalled + } + stateFile := args[0] + args = slices.Delete(args, 0, 1) + + cmd := []string{"terraform", "state", "push", stateFile} + return wsCMDRun(cmd...)(ctx, opt, args) +} + +func statePullCMD(ctx context.Context, parent *getoptions.GetOpt) *getoptions.GetOpt { + cfg := config.ConfigFromContext(ctx) + + opt := parent.NewCommand("state-pull", "") + opt.SetCommandFn(statePullRun) + + wss, err := validWorkspaces(cfg) + if err != nil { + Logger.Printf("WARNING: failed to list workspaces: %s\n", err) + } + opt.String("ws", "", opt.ValidValues(wss...), opt.Description("Workspace to use")) + + return opt +} + +func statePullRun(ctx context.Context, opt *getoptions.GetOpt, args []string) error { + cmd := []string{"terraform", "state", "pull"} + return wsCMDRun(cmd...)(ctx, opt, args) +} diff --git a/bt/terraform/taint.go b/bt/terraform/taint.go new file mode 100644 index 0000000..2ca17ab --- /dev/null +++ b/bt/terraform/taint.go @@ -0,0 +1,69 @@ +package terraform + +import ( + "context" + "fmt" + "os" + "slices" + + "github.com/DavidGamba/dgtools/bt/config" + "github.com/DavidGamba/go-getoptions" +) + +func taintCMD(ctx context.Context, parent *getoptions.GetOpt) *getoptions.GetOpt { + cfg := config.ConfigFromContext(ctx) + + opt := parent.NewCommand("taint", "") + opt.SetCommandFn(taintRun) + opt.HelpSynopsisArg("
", "Address") + + wss, err := validWorkspaces(cfg) + if err != nil { + Logger.Printf("WARNING: failed to list workspaces: %s\n", err) + } + opt.String("ws", "", opt.ValidValues(wss...), opt.Description("Workspace to use")) + + return opt +} + +func taintRun(ctx context.Context, opt *getoptions.GetOpt, args []string) error { + if len(args) < 1 { + fmt.Fprintf(os.Stderr, "ERROR: missing
\n") + fmt.Fprintf(os.Stderr, "%s", opt.Help(getoptions.HelpSynopsis)) + return getoptions.ErrorHelpCalled + } + address := args[0] + args = slices.Delete(args, 0, 1) + + cmd := []string{"terraform", "taint", address} + return wsCMDRun(cmd...)(ctx, opt, args) +} + +func untaintCMD(ctx context.Context, parent *getoptions.GetOpt) *getoptions.GetOpt { + cfg := config.ConfigFromContext(ctx) + + opt := parent.NewCommand("untaint", "") + opt.SetCommandFn(untaintRun) + opt.HelpSynopsisArg("
", "Address") + + wss, err := validWorkspaces(cfg) + if err != nil { + Logger.Printf("WARNING: failed to list workspaces: %s\n", err) + } + opt.String("ws", "", opt.ValidValues(wss...), opt.Description("Workspace to use")) + + return opt +} + +func untaintRun(ctx context.Context, opt *getoptions.GetOpt, args []string) error { + if len(args) < 1 { + fmt.Fprintf(os.Stderr, "ERROR: missing
\n") + fmt.Fprintf(os.Stderr, "%s", opt.Help(getoptions.HelpSynopsis)) + return getoptions.ErrorHelpCalled + } + address := args[0] + args = slices.Delete(args, 0, 1) + + cmd := []string{"terraform", "untaint", address} + return wsCMDRun(cmd...)(ctx, opt, args) +} diff --git a/bt/terraform/terraform.go b/bt/terraform/terraform.go new file mode 100644 index 0000000..1273081 --- /dev/null +++ b/bt/terraform/terraform.go @@ -0,0 +1,157 @@ +package terraform + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/DavidGamba/dgtools/bt/config" + "github.com/DavidGamba/dgtools/fsmodtime" + "github.com/DavidGamba/go-getoptions" +) + +var Logger = log.New(os.Stderr, "", log.LstdFlags) + +func NewCommand(ctx context.Context, parent *getoptions.GetOpt) *getoptions.GetOpt { + opt := parent.NewCommand("terraform", "terraform related tasks") + + // backend-config + initCMD(ctx, opt) + + // var-file + planCMD(ctx, opt) + importCMD(ctx, opt) + refreshCMD(ctx, opt) + + // workspace selection + applyCMD(ctx, opt) + forceUnlockCMD(ctx, opt) + statePushCMD(ctx, opt) + statePullCMD(ctx, opt) + showPlanCMD(ctx, opt) + outputCMD(ctx, opt) + showCMD(ctx, opt) + taintCMD(ctx, opt) + untaintCMD(ctx, opt) + + buildCMD(ctx, opt) + + return opt +} + +// Retrieves workspaces assuming a convention where the .tfvars[.json] file matches the name of the workspace +// It only lists files, it doesn't query Terraform for a 'proper' list of workspaces. +func getWorkspaces(cfg *config.Config) ([]string, error) { + wss := []string{} + glob := fmt.Sprintf("%s/*.tfvars*", cfg.Terraform.Workspaces.Dir) + ff, _, err := fsmodtime.Glob(os.DirFS("."), true, []string{glob}) + if err != nil { + return wss, fmt.Errorf("failed to glob ws files: %w", err) + } + for _, ws := range ff { + ws = filepath.Base(ws) + ws = strings.TrimSuffix(ws, ".json") + ws = strings.TrimSuffix(ws, ".tfvars") + wss = append(wss, ws) + } + return wss, nil +} + +func validWorkspaces(cfg *config.Config) ([]string, error) { + wss := []string{} + if cfg.Terraform.Workspaces.Enabled { + if _, err := os.Stat(".terraform/environment"); os.IsNotExist(err) { + wss, err = getWorkspaces(cfg) + if err != nil { + return wss, err + } + } else { + e, err := os.ReadFile(".terraform/environment") + if err != nil { + return wss, err + } + wss = append(wss, strings.TrimSpace(string(e))) + } + } + return wss, nil +} + +func workspaceSelected() bool { + if _, err := os.Stat(".terraform/environment"); os.IsNotExist(err) { + return false + } + return true +} + +func updateWSIfSelected(ws string) (string, error) { + if workspaceSelected() { + e, err := os.ReadFile(".terraform/environment") + if err != nil { + return ws, fmt.Errorf("failed to read current workspace: %w", err) + } + wse := strings.TrimSpace(string(e)) + if ws != "" && wse != ws { + return wse, fmt.Errorf("given workspace doesn't match selected workspace: %s", wse) + } + ws = wse + } + return ws, nil +} + +// If there is no workspace selected, check the given var files and use the first one as the workspace then return the ws env var +func getWorkspace(cfg *config.Config, ws string, varFiles []string) (string, error) { + if cfg.Terraform.Workspaces.Enabled { + if !workspaceSelected() { + if ws != "" { + return ws, nil + } + if len(varFiles) < 1 { + return "", fmt.Errorf("running in workspace mode but no workspace selected or -var-file given") + } + wsFilename := filepath.Base(varFiles[0]) + r := regexp.MustCompile(`\..*$`) + ws = r.ReplaceAllString(wsFilename, "") + } + } + return ws, nil +} + +// If a workspace is selected automatically insert a var file matching the workspace. +// If the var file is already present then don't add it again. +func AddVarFileIfWorkspaceSelected(cfg *config.Config, ws string, varFiles []string) ([]string, error) { + if ws != "" { + glob := fmt.Sprintf("%s/%s.tfvars*", cfg.Terraform.Workspaces.Dir, ws) + Logger.Printf("ws: %s, glob: %s\n", ws, glob) + ff, _, err := fsmodtime.Glob(os.DirFS("."), true, []string{glob}) + if err != nil { + return varFiles, fmt.Errorf("failed to glob ws files: %w", err) + } + for _, f := range ff { + Logger.Printf("file: %s\n", f) + if !slices.Contains(varFiles, f) { + varFiles = append(varFiles, f) + } + } + } + return varFiles, nil +} + +func getDefaultVarFiles(cfg *config.Config) ([]string, error) { + varFiles := []string{} + for _, vars := range cfg.Terraform.Plan.VarFile { + v := strings.ReplaceAll(vars, "~", "$HOME") + vv, err := fsmodtime.ExpandEnv([]string{v}) + if err != nil { + return varFiles, fmt.Errorf("failed to expand: %w", err) + } + if _, err := os.Stat(vv[0]); err == nil { + varFiles = append(varFiles, vv[0]) + } + } + return varFiles, nil +} diff --git a/bt/terraform/varfile_cmd.go b/bt/terraform/varfile_cmd.go new file mode 100644 index 0000000..55930d9 --- /dev/null +++ b/bt/terraform/varfile_cmd.go @@ -0,0 +1,104 @@ +package terraform + +import ( + "context" + "fmt" + "os" + + "github.com/DavidGamba/dgtools/bt/config" + "github.com/DavidGamba/dgtools/run" + "github.com/DavidGamba/go-getoptions" + "github.com/mattn/go-isatty" +) + +type VarFileCMDer interface { + // Function that adds elements to the command based on the workspace + cmdFunction(ws string) []string + + // Function that runs if the command errored + errorFunction(ws string) + + // Function that runs if the command succeeded + successFunction(ws string) +} + +type invalidatePlan struct{} + +func (fn invalidatePlan) cmdFunction(ws string) []string { + return []string{} +} + +func (fn invalidatePlan) errorFunction(ws string) { + planFile := "" + if ws == "" { + planFile = ".tf.plan" + } else { + planFile = fmt.Sprintf(".tf.plan-%s", ws) + } + os.Remove(planFile) +} + +func (fn invalidatePlan) successFunction(ws string) { + planFile := "" + if ws == "" { + planFile = ".tf.plan" + } else { + planFile = fmt.Sprintf(".tf.plan-%s", ws) + } + os.Remove(planFile) +} + +func varFileCMDRun(fn VarFileCMDer, cmd ...string) getoptions.CommandFn { + return func(ctx context.Context, opt *getoptions.GetOpt, args []string) error { + varFiles := opt.Value("var-file").([]string) + ws := opt.Value("ws").(string) + ws, err := updateWSIfSelected(ws) + if err != nil { + return err + } + + cfg := config.ConfigFromContext(ctx) + Logger.Printf("cfg: %s\n", cfg) + + ws, err = getWorkspace(cfg, ws, varFiles) + if err != nil { + return err + } + + defaultVarFiles, err := getDefaultVarFiles(cfg) + if err != nil { + return err + } + + varFiles, err = AddVarFileIfWorkspaceSelected(cfg, ws, varFiles) + if err != nil { + return err + } + + for _, v := range defaultVarFiles { + cmd = append(cmd, "-var-file", v) + } + for _, v := range varFiles { + cmd = append(cmd, "-var-file", v) + } + if !isatty.IsTerminal(os.Stdout.Fd()) { + cmd = append(cmd, "-no-color") + } + cmd = append(cmd, fn.cmdFunction(ws)...) + cmd = append(cmd, args...) + + ri := run.CMD(cmd...).Ctx(ctx).Stdin().Log() + if ws != "" { + wsEnv := fmt.Sprintf("TF_WORKSPACE=%s", ws) + Logger.Printf("export %s\n", wsEnv) + ri.Env(wsEnv) + } + err = ri.Run() + if err != nil { + fn.errorFunction(ws) + return fmt.Errorf("failed to run: %w", err) + } + fn.successFunction(ws) + return nil + } +} diff --git a/bt/terraform/ws_cmd.go b/bt/terraform/ws_cmd.go new file mode 100644 index 0000000..5b775ad --- /dev/null +++ b/bt/terraform/ws_cmd.go @@ -0,0 +1,49 @@ +package terraform + +import ( + "context" + "fmt" + "os" + + "github.com/DavidGamba/dgtools/bt/config" + "github.com/DavidGamba/dgtools/run" + "github.com/DavidGamba/go-getoptions" + "github.com/mattn/go-isatty" +) + +func wsCMDRun(cmd ...string) getoptions.CommandFn { + return func(ctx context.Context, opt *getoptions.GetOpt, args []string) error { + ws := opt.Value("ws").(string) + ws, err := updateWSIfSelected(ws) + if err != nil { + return err + } + + cfg := config.ConfigFromContext(ctx) + Logger.Printf("cfg: %s\n", cfg) + + if cfg.Terraform.Workspaces.Enabled { + if !workspaceSelected() { + if ws == "" { + return fmt.Errorf("running in workspace mode but no workspace selected or --ws given") + } + } + } + + if !isatty.IsTerminal(os.Stdout.Fd()) { + cmd = append(cmd, "-no-color") + } + cmd = append(cmd, args...) + ri := run.CMD(cmd...).Ctx(ctx).Stdin().Log() + if ws != "" { + wsEnv := fmt.Sprintf("TF_WORKSPACE=%s", ws) + Logger.Printf("export %s\n", wsEnv) + ri.Env(wsEnv) + } + err = ri.Run() + if err != nil { + return fmt.Errorf("failed to run: %w", err) + } + return nil + } +} diff --git a/bt/tests/.bt.cue b/bt/tests/.bt.cue new file mode 100644 index 0000000..19cfc2a --- /dev/null +++ b/bt/tests/.bt.cue @@ -0,0 +1,12 @@ +terraform: { + init: { + backend_config: ["backend.tfvars"] + } + plan: { + var_file: ["vars.tfvars"] + } + workspaces: { + enabled: true + dir: "envs" + } +} diff --git a/bt/tests/backend.tfvars b/bt/tests/backend.tfvars new file mode 100644 index 0000000..e69de29