diff --git a/.gitignore b/.gitignore index 65f3544..4cee077 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ *.dll *.so *.dylib +*.DS_Store # Test binary, built with `go test -c` *.test @@ -43,5 +44,8 @@ tutorials/**/*.* tutorials/**/*.tp.tf tutorials/**/terraplate.tf +# Ignore any default plans that exist in a testData directory +**/testData/**/tfplan + # goreleaser build directory dist/ diff --git a/builder/build.go b/builder/build.go index 06bdd47..6df8e23 100644 --- a/builder/build.go +++ b/builder/build.go @@ -84,19 +84,21 @@ func buildTerraplate(terrafile *parser.Terrafile, config *parser.TerraConfig) er // changes each time. // Iterate over the sorted keys and then extract the value for that key provMap := terrafile.TerraformBlock.RequiredProviders() - for _, name := range sortedMapKeys(provMap) { - value := provMap[name] - ctyType, typeErr := gocty.ImpliedType(value) - if typeErr != nil { - return fmt.Errorf("implying required provider to cty type for provider %s: %w", name, typeErr) - } - ctyValue, ctyErr := gocty.ToCtyValue(value, ctyType) - if ctyErr != nil { - return fmt.Errorf("converting required provider to cty value for provider %s: %w", name, ctyErr) + if len(provMap) > 0 { + for _, name := range sortedMapKeys(provMap) { + value := provMap[name] + ctyType, typeErr := gocty.ImpliedType(value) + if typeErr != nil { + return fmt.Errorf("implying required provider to cty type for provider %s: %w", name, typeErr) + } + ctyValue, ctyErr := gocty.ToCtyValue(value, ctyType) + if ctyErr != nil { + return fmt.Errorf("converting required provider to cty value for provider %s: %w", name, ctyErr) + } + provBlock.Body().SetAttributeValue(name, ctyValue) } - provBlock.Body().SetAttributeValue(name, ctyValue) + tfBlock.Body().AppendBlock(provBlock) } - tfBlock.Body().AppendBlock(provBlock) // If body is not empty, write the terraform block if isBodyEmpty(tfBlock.Body()) { tfFile.Body().AppendBlock(tfBlock) diff --git a/cmd/apply.go b/cmd/apply.go index 9cb7521..6fc7203 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -35,12 +35,12 @@ var applyCmd = &cobra.Command{ if err != nil { return fmt.Errorf("parsing terraplate: %w", err) } - result := runner.Run(config, runner.RunApply(), runner.Jobs(applyJobs), runner.ExtraArgs(args)) + runner := runner.Run(config, runner.RunApply(), runner.Jobs(applyJobs), runner.ExtraArgs(args)) // Print log - fmt.Println(result.Log()) + fmt.Println(runner.Log()) - return result.Errors() + return runner.Errors() }, } diff --git a/cmd/build.go b/cmd/build.go index f219b51..0081c09 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -47,12 +47,12 @@ templates and configurations detected.`, fmt.Print(buildSuccessMessage) if doValidate { - result := runner.Run(config, runner.RunInit(), runner.RunValidate()) + runner := runner.Run(config, runner.RunInit(), runner.RunValidate()) // Print log - fmt.Println(result.Log()) + fmt.Println(runner.Log()) - if result.HasError() { - return result.Errors() + if runner.HasError() { + return runner.Errors() } } diff --git a/cmd/drift.go b/cmd/drift.go index 90555d0..f6f12a1 100644 --- a/cmd/drift.go +++ b/cmd/drift.go @@ -66,14 +66,14 @@ var driftCmd = &cobra.Command{ fmt.Print(buildSuccessMessage) // Plan fmt.Print(terraformStartMessage) - runOpts := []func(r *runner.TerraRun){ + runOpts := []func(r *runner.TerraRunOpts){ runner.RunInit(), runner.RunPlan(), runner.RunShowPlan(), runner.Jobs(planJobs), } runOpts = append(runOpts, runner.ExtraArgs(args)) - result := runner.Run(config, runOpts...) + runner := runner.Run(config, runOpts...) if notifyService != nil { repo, repoErr := notify.LookupRepo( @@ -83,7 +83,7 @@ var driftCmd = &cobra.Command{ return fmt.Errorf("looking up repository details: %w", repoErr) } sendErr := notifyService.Send(¬ify.Data{ - Result: result, + Runner: runner, Repo: repo, ResultsURL: notifyResultsUrl, }) @@ -92,7 +92,7 @@ var driftCmd = &cobra.Command{ } } - fmt.Print(result.PlanSummary()) + fmt.Print(runner.PlanSummary()) return nil }, } diff --git a/cmd/init.go b/cmd/init.go index 12e6d0d..b69bd29 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -35,11 +35,11 @@ var initCmd = &cobra.Command{ if err != nil { return fmt.Errorf("parsing terraplate: %w", err) } - result := runner.Run(config, runner.RunInit(), runner.Jobs(initJobs), runner.ExtraArgs(args)) + runner := runner.Run(config, runner.RunInit(), runner.Jobs(initJobs), runner.ExtraArgs(args)) // Print log - fmt.Println(result.Log()) + fmt.Println(runner.Log()) - return result.Errors() + return runner.Errors() }, } diff --git a/cmd/plan.go b/cmd/plan.go index 266127c..1b41154 100644 --- a/cmd/plan.go +++ b/cmd/plan.go @@ -48,7 +48,7 @@ var planCmd = &cobra.Command{ fmt.Print(buildSuccessMessage) } fmt.Print(terraformStartMessage) - runOpts := []func(r *runner.TerraRun){ + runOpts := []func(r *runner.TerraRunOpts){ runner.RunPlan(), runner.RunShowPlan(), runner.Jobs(planJobs), @@ -57,14 +57,14 @@ var planCmd = &cobra.Command{ runOpts = append(runOpts, runner.RunInit()) } runOpts = append(runOpts, runner.ExtraArgs(args)) - result := runner.Run(config, runOpts...) + runner := runner.Run(config, runOpts...) // Print log - fmt.Println(result.Log()) + fmt.Println(runner.Log()) - fmt.Println(result.PlanSummary()) + fmt.Println(runner.PlanSummary()) - return result.Errors() + return runner.Errors() }, } diff --git a/go.mod b/go.mod index f53766a..a3a35ae 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/spf13/cobra v1.3.0 github.com/stretchr/testify v1.7.0 github.com/zclconf/go-cty v1.10.0 - gotest.tools v2.2.0+incompatible + golang.org/x/text v0.3.7 ) require ( @@ -47,7 +47,6 @@ require ( github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sergi/go-diff v1.1.0 // indirect @@ -57,8 +56,8 @@ require ( github.com/xanzy/ssh-agent v0.3.0 // indirect golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // indirect - golang.org/x/sys v0.0.0-20211205182925-97ca703d548d // indirect - golang.org/x/text v0.3.7 // indirect + golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect diff --git a/go.sum b/go.sum index f2c1b31..72512da 100644 --- a/go.sum +++ b/go.sum @@ -654,10 +654,12 @@ golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211205182925-97ca703d548d h1:FjkYO/PPp4Wi0EAUOVLxePm7qVW4r4ctbWpURyuOD0E= golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -895,8 +897,6 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= -gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/main_test.go b/main_test.go index ce6d7ff..f36edd1 100644 --- a/main_test.go +++ b/main_test.go @@ -47,13 +47,13 @@ func TestMain(t *testing.T) { require.NoError(t, buildErr) if !tc.skipTerraform { - result := runner.Run(config, + runner := runner.Run(config, runner.RunValidate(), runner.RunInit(), runner.RunPlan(), runner.RunApply()) - require.NoError(t, result.Errors()) + require.NoError(t, runner.Errors()) } }) } diff --git a/notify/data.go b/notify/data.go index 5eb45b0..2eda6ba 100644 --- a/notify/data.go +++ b/notify/data.go @@ -16,16 +16,16 @@ var ( ) type Data struct { - Result *runner.Result + Runner *runner.Runner Repo *Repo ResultsURL string } func (d Data) StatusColor() string { switch { - case d.Result.HasError(): + case d.Runner.HasError(): return statusErrorColor - case d.Result.HasDrift(): + case d.Runner.HasDrift(): return statusDrftColor } return statusSyncColor diff --git a/notify/notify.go b/notify/notify.go index 85c10c6..fd1cbc7 100644 --- a/notify/notify.go +++ b/notify/notify.go @@ -88,14 +88,14 @@ func (f NotifyFilter) IsValid() bool { return false } -// ShouldNotify takes a result and returns a bool whether the notification should +// ShouldNotify takes a runner and returns a bool whether the notification should // be sent, or not -func (f NotifyFilter) ShouldNotify(result *runner.Result) bool { +func (f NotifyFilter) ShouldNotify(runner *runner.Runner) bool { switch f { case NotifyFilterAll: return true case NotifyFilterDrift: - if result.HasError() || result.HasDrift() { + if runner.HasError() || runner.HasDrift() { return true } } diff --git a/notify/slack.go b/notify/slack.go index fd660b2..4c1a9e4 100644 --- a/notify/slack.go +++ b/notify/slack.go @@ -89,7 +89,7 @@ type slackMsgJSON struct { } func (s *slackService) Send(data *Data) error { - if !s.NotifyFilter.ShouldNotify(data.Result) { + if !s.NotifyFilter.ShouldNotify(data.Runner) { fmt.Print(notifyNoDriftMessage) return nil } diff --git a/parser/terraconfig.go b/parser/terraconfig.go index 20d0013..48e27be 100644 --- a/parser/terraconfig.go +++ b/parser/terraconfig.go @@ -52,7 +52,7 @@ func (c *TerraConfig) MergeTerrafiles() error { for _, rootTf := range rootTfs { // Set defaults for root terrafile - if err := mergo.Merge(rootTf, defaultTerrafile); err != nil { + if err := mergo.Merge(rootTf, DefaultTerrafile); err != nil { return fmt.Errorf("setting defaults for root terrafile %s: %w", rootTf.Path, err) } diff --git a/parser/terrafile.go b/parser/terrafile.go index 036621b..84403a0 100644 --- a/parser/terrafile.go +++ b/parser/terrafile.go @@ -11,9 +11,9 @@ import ( ctyjson "github.com/zclconf/go-cty/cty/json" ) -// defaultTerrafile sets default values for a Terrafile that are used when +// DefaultTerrafile sets default values for a Terrafile that are used when // parsing a new Terrafile -var defaultTerrafile = Terrafile{ +var DefaultTerrafile = Terrafile{ // BuildBlock: &BuildBlock{}, ExecBlock: &ExecBlock{ PlanBlock: &ExecPlanBlock{ diff --git a/runner/cmds.go b/runner/cmds.go new file mode 100644 index 0000000..a70e4f2 --- /dev/null +++ b/runner/cmds.go @@ -0,0 +1,156 @@ +package runner + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/signal" + "syscall" + "time" + + "github.com/verifa/terraplate/parser" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +func initCmd(run TerraRunOpts, tf *parser.Terrafile) *TaskResult { + var args []string + args = append(args, tfCleanExtraArgs(run.extraArgs)...) + return runCmd(tf, terraInit, args) +} + +func validateCmd(run TerraRunOpts, tf *parser.Terrafile) *TaskResult { + var args []string + args = append(args, tfCleanExtraArgs(run.extraArgs)...) + return runCmd(tf, terraValidate, args) +} + +func planCmd(run TerraRunOpts, tf *parser.Terrafile) *TaskResult { + plan := tf.ExecBlock.PlanBlock + + var args []string + args = append(args, + fmt.Sprintf("-lock=%v", plan.Lock), + fmt.Sprintf("-input=%v", plan.Input), + ) + if !plan.SkipOut { + args = append(args, + "-out="+plan.Out, + ) + } + args = append(args, tfCleanExtraArgs(tf.ExecBlock.ExtraArgs)...) + args = append(args, tfCleanExtraArgs(run.extraArgs)...) + return runCmd(tf, terraPlan, args) +} + +func showPlanCmd(run TerraRunOpts, tf *parser.Terrafile) *TaskResult { + plan := tf.ExecBlock.PlanBlock + if plan.SkipOut { + return &TaskResult{ + TerraCmd: terraShowPlan, + Skipped: true, + } + } + var args []string + args = append(args, "-json", plan.Out) + return runCmd(tf, terraShowPlan, args) +} + +func applyCmd(run TerraRunOpts, tf *parser.Terrafile) *TaskResult { + plan := tf.ExecBlock.PlanBlock + + var args []string + args = append(args, + fmt.Sprintf("-lock=%v", plan.Lock), + fmt.Sprintf("-input=%v", plan.Input), + ) + args = append(args, tfCleanExtraArgs(tf.ExecBlock.ExtraArgs)...) + args = append(args, tfCleanExtraArgs(run.extraArgs)...) + + if !plan.SkipOut { + args = append(args, plan.Out) + } + + return runCmd(tf, terraApply, args) +} + +func runCmd(tf *parser.Terrafile, tfCmd terraCmd, args []string) *TaskResult { + result := TaskResult{ + TerraCmd: tfCmd, + } + cmdArgs := append(tfArgs(tf), string(tfCmd)) + cmdArgs = append(cmdArgs, args...) + result.ExecCmd = exec.Command(terraExe, cmdArgs...) + + // Create channel and start progress printer + done := make(chan bool) + go printProgress(tf.RelativeDir(), tfCmd, done) + defer func() { done <- true }() + + var runErr error + result.Output, runErr = result.ExecCmd.CombinedOutput() + if runErr != nil { + result.Error = fmt.Errorf("%s: running %s command", tf.RelativeDir(), tfCmd) + } + + return &result +} + +func tfArgs(tf *parser.Terrafile) []string { + var args []string + args = append(args, "-chdir="+tf.Dir) + return args +} + +// tfCleanExtraArgs returns the provided slice with any empty spaces removed. +// Empty spaces create weird errors that are hard to debug +func tfCleanExtraArgs(args []string) []string { + var cleanArgs = make([]string, 0) + for _, arg := range args { + if arg != "" { + cleanArgs = append(cleanArgs, arg) + } + } + return cleanArgs +} + +// listenTerminateSignals returns a context that will be cancelled if an interrupt +// or termination signal is received. The context can be used to prevent further +// runs from being scheduled +func listenTerminateSignals() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + go func() { + for { + <-signals + fmt.Println("") + fmt.Println("Terraplate: Interrupt received.") + fmt.Println("Terraplate: Sending interrupt to all Terraform processes and cancelling any queued runs.") + // Cancel the context, to stop any more runs from being executed + cancel() + } + }() + return ctx +} + +func printProgress(path string, cmd terraCmd, done <-chan bool) { + var ( + interval = time.Second * 10 + ticker = time.NewTicker(interval) + elapsed time.Duration + ) + defer ticker.Stop() + // Print initial line + fmt.Printf("%s: %s...\n", path, cases.Title(language.English).String(cmd.Action())) + for { + select { + case <-ticker.C: + elapsed += interval + fmt.Printf("%s: Still %s... [%s elapsed]\n", path, cmd.Action(), elapsed) + case <-done: + return + } + } +} diff --git a/runner/drift.go b/runner/drift.go index 6d3c732..b4aff53 100644 --- a/runner/drift.go +++ b/runner/drift.go @@ -6,6 +6,27 @@ import ( tfjson "github.com/hashicorp/terraform-json" ) +func driftFromPlan(plan *tfjson.Plan) *Drift { + var drift Drift + for _, change := range plan.ResourceChanges { + for _, action := range change.Change.Actions { + switch action { + case tfjson.ActionCreate: + drift.AddResources = append(drift.AddResources, change) + case tfjson.ActionDelete: + drift.DestroyResources = append(drift.DestroyResources, change) + case tfjson.ActionUpdate: + drift.ChangeResources = append(drift.ChangeResources, change) + default: + // We don't care about other actions for the summary + } + + } + } + + return &drift +} + type Drift struct { AddResources []*tfjson.ResourceChange ChangeResources []*tfjson.ResourceChange diff --git a/runner/result.go b/runner/result.go index c804963..75c10db 100644 --- a/runner/result.go +++ b/runner/result.go @@ -1,296 +1 @@ package runner - -import ( - "bufio" - "bytes" - "fmt" - "os/exec" - "strings" - - "github.com/fatih/color" - "github.com/hashicorp/go-multierror" - tfjson "github.com/hashicorp/terraform-json" - "github.com/verifa/terraplate/parser" -) - -var ( - boldColor = color.New(color.Bold) - errorColor = color.New(color.FgRed, color.Bold) - runCancelled = color.New(color.FgRed, color.Bold) - planNotAvailable = color.New(color.FgMagenta, color.Bold) - planNoChangesColor = color.New(color.FgGreen, color.Bold) - planCreateColor = color.New(color.FgGreen, color.Bold) - planDestroyColor = color.New(color.FgRed, color.Bold) - planUpdateColor = color.New(color.FgYellow, color.Bold) -) - -var ( - textSeparator = boldColor.Sprint("\n─────────────────────────────────────────────────────────────────────────────\n\n") -) - -type Result struct { - Runs []*RunResult -} - -// Log returns a string of the runs and tasks to print to the console -func (r *Result) Log() string { - var ( - summary strings.Builder - hasRelevantRuns bool - ) - summary.WriteString(textSeparator) - for _, run := range r.Runs { - // Skip runs that have nothing relevant to show - if !run.HasRelevantTasks() { - continue - } - hasRelevantRuns = true - - summary.WriteString(boldColor.Sprintf("Run for %s\n\n", run.Terrafile.RelativeDir())) - - for _, task := range run.Tasks { - if task.HasRelevance() { - summary.WriteString(task.Log()) - } - } - } - // If there were no runs to output, return an empty string to avoid printing - // separators and empty space - if !hasRelevantRuns { - return "" - } - summary.WriteString(textSeparator) - return summary.String() -} - -// PlanSummary returns a string summary to show after a plan -func (r *Result) PlanSummary() string { - var summary strings.Builder - summary.WriteString(boldColor.Sprint("\nTerraplate Plan Summary\n\n")) - for _, run := range r.Runs { - summary.WriteString(fmt.Sprintf("%s: %s\n", run.Terrafile.RelativeDir(), run.PlanSummary())) - } - return summary.String() -} - -func (r *Result) RunsWithDrift() []*RunResult { - var runs []*RunResult - for _, run := range r.Runs { - if run.Drift().HasDrift() { - runs = append(runs, run) - } - } - return runs -} - -func (r *Result) RunsWithError() []*RunResult { - var runs []*RunResult - for _, run := range r.Runs { - if run.HasError() { - runs = append(runs, run) - } - } - return runs -} - -// HasDrift returns true if any drift was detected in any of the runs -func (r *Result) HasDrift() bool { - for _, run := range r.Runs { - if drift := run.Drift(); drift != nil { - // If at least one of the runs has drifted, then our result has drift - if drift.HasDrift() { - return true - } - } - } - return false -} - -func (r *Result) HasError() bool { - for _, run := range r.Runs { - if run.HasError() { - return true - } - } - return false -} - -// Errors returns a multierror with any errors found in any tasks within the runs -func (r *Result) Errors() error { - var err error - for _, run := range r.Runs { - if run.HasError() { - err = multierror.Append(err, run.Errors()...) - } - } - return err -} - -type RunResult struct { - // Terrafile is the terrafile for which this run was executed - Terrafile *parser.Terrafile - - Tasks []*TaskResult - Cancelled bool - Skipped bool - - Plan *tfjson.Plan - PlanText []byte -} - -// PlanSummary returns a string summary to show after a plan -func (r *RunResult) PlanSummary() string { - // If the run had errors, we want to show that - if r.HasError() { - return errorColor.Sprint("Error occurred") - } - if r.Cancelled { - return runCancelled.Sprint("Cancelled") - } - if r.Skipped { - return "Skipped" - } - if !r.HasPlan() { - return planNotAvailable.Sprint("Plan not available") - } - return r.Drift().Diff() -} - -func (r *RunResult) Drift() *Drift { - if !r.HasPlan() { - // Return an empty drift which means no drift (though user should check - // if plan was available as well) - return &Drift{} - } - return driftFromPlan(r.Plan) -} - -func (r *RunResult) HasError() bool { - for _, task := range r.Tasks { - if task.HasError() { - return true - } - } - return false -} - -func (r *RunResult) Errors() []error { - var errors []error - for _, task := range r.Tasks { - if task.HasError() { - errors = append(errors, task.Error) - } - } - return errors -} - -func (r *RunResult) HasRelevantTasks() bool { - for _, task := range r.Tasks { - if task.HasRelevance() { - return true - } - } - return false -} - -func (r *RunResult) HasPlan() bool { - return r.Plan != nil -} - -// ProcessPlanText takes a TaskResult from a terraform show (without -json option) -// which makes for a compact human-readable output which we can show instead of -// the raw output from terraform plan -func (r *RunResult) ProcessPlan(task *TaskResult) error { - // Make sure we received a `terraform show` task result - if task.TerraCmd != terraShowPlan { - return fmt.Errorf("terraform show command required for processing plan: received %s", task.TerraCmd) - } - // Cannot process a plan if the `terraform show` command error'd - if task.HasError() { - return nil - } - var tfPlan tfjson.Plan - if err := tfPlan.UnmarshalJSON(task.Output); err != nil { - return fmt.Errorf("unmarshalling terraform show plan output: %w", err) - } - - r.Plan = &tfPlan - - return nil -} - -type TaskResult struct { - ExecCmd *exec.Cmd - TerraCmd terraCmd - - Output []byte - Error error - Skipped bool -} - -func (t *TaskResult) HasError() bool { - return t.Error != nil -} - -// HasRelevance is an attempt at better UX. -// We don't simply want to output everything. Things like successful inits and -// terraform show output are not interesting for the user, so skip them by -// default and therefore keep the output less -func (t *TaskResult) HasRelevance() bool { - // Errors are always relevant - if t.HasError() { - return true - } - // Skipped tasks are not relevant - if t.Skipped { - return false - } - switch t.TerraCmd { - case terraPlan: - // Plan outputs are interesting - return true - case terraApply: - // Apply outputs are interesting - return true - default: - // Skip other command outputs - return false - } -} - -func (t *TaskResult) Log() string { - var summary strings.Builder - - summary.WriteString(fmt.Sprintf("%s output: %s\n\n", strings.Title(string(t.TerraCmd)), t.ExecCmd.String())) - if t.HasError() { - summary.WriteString(fmt.Sprintf("Error: %s\n\n", t.Error.Error())) - } - scanner := bufio.NewScanner(bytes.NewBuffer(t.Output)) - for scanner.Scan() { - summary.WriteString(fmt.Sprintf(" %s\n", scanner.Text())) - } - summary.WriteString("\n\n") - - return summary.String() -} - -func driftFromPlan(plan *tfjson.Plan) *Drift { - var drift Drift - for _, change := range plan.ResourceChanges { - for _, action := range change.Change.Actions { - switch action { - case tfjson.ActionCreate: - drift.AddResources = append(drift.AddResources, change) - case tfjson.ActionDelete: - drift.DestroyResources = append(drift.DestroyResources, change) - case tfjson.ActionUpdate: - drift.ChangeResources = append(drift.ChangeResources, change) - default: - // We don't care about other actions for the summary - } - - } - } - - return &drift -} diff --git a/runner/run.go b/runner/run.go new file mode 100644 index 0000000..b1ba953 --- /dev/null +++ b/runner/run.go @@ -0,0 +1,181 @@ +package runner + +import ( + "errors" + "fmt" + "sync" + + tfjson "github.com/hashicorp/terraform-json" + "github.com/verifa/terraplate/parser" +) + +var ( + ErrRunInProgress = errors.New("run is already in progress") +) + +type TerraRun struct { + // Terrafile is the terrafile for which this run was executed + Terrafile *parser.Terrafile + + Tasks []*TaskResult + Cancelled bool + Skipped bool + + Plan *tfjson.Plan + PlanText []byte + + mu sync.RWMutex + isRunning bool +} + +// Run performs the run for this TerraRun i.e. invoking Terraform +func (r *TerraRun) Run(opts TerraRunOpts) error { + if startErr := r.startRun(); startErr != nil { + return startErr + } + + tf := r.Terrafile + // Check if root module should be skipped or not + if tf.ExecBlock.Skip { + fmt.Printf("%s: Skipping...\n", tf.RelativeDir()) + r.Skipped = true + return nil + } + + if opts.init { + taskResult := initCmd(opts, tf) + r.Tasks = append(r.Tasks, taskResult) + if taskResult.HasError() { + return nil + } + } + if opts.validate { + taskResult := validateCmd(opts, tf) + r.Tasks = append(r.Tasks, taskResult) + if taskResult.HasError() { + return nil + } + } + if opts.plan { + taskResult := planCmd(opts, tf) + r.Tasks = append(r.Tasks, taskResult) + if taskResult.HasError() { + return nil + } + } + if opts.showPlan { + taskResult := showPlanCmd(opts, tf) + r.ProcessPlan(taskResult) + r.Tasks = append(r.Tasks, taskResult) + if taskResult.HasError() { + return nil + } + } + if opts.apply { + taskResult := applyCmd(opts, tf) + r.Tasks = append(r.Tasks, taskResult) + if taskResult.HasError() { + return nil + } + } + + r.endRun() + return nil +} + +func (r *TerraRun) startRun() error { + r.mu.Lock() + defer r.mu.Unlock() + if r.isRunning { + return ErrRunInProgress + } + r.isRunning = true + return nil +} + +func (r *TerraRun) endRun() { + r.mu.Lock() + defer r.mu.Unlock() + r.isRunning = false +} + +// PlanSummary returns a string summary to show after a plan +func (r *TerraRun) PlanSummary() string { + // If the run had errors, we want to show that + if r.HasError() { + return errorColor.Sprint("Error occurred") + } + if r.Cancelled { + return runCancelled.Sprint("Cancelled") + } + if r.Skipped { + return "Skipped" + } + if !r.HasPlan() { + return planNotAvailable.Sprint("Plan not available") + } + return r.Drift().Diff() +} + +func (r *TerraRun) Drift() *Drift { + if !r.HasPlan() { + // Return an empty drift which means no drift (though user should check + // if plan was available as well) + return &Drift{} + } + return driftFromPlan(r.Plan) +} + +func (r *TerraRun) HasError() bool { + for _, task := range r.Tasks { + if task.HasError() { + return true + } + } + return false +} + +func (r *TerraRun) Errors() []error { + var errors []error + for _, task := range r.Tasks { + if task.HasError() { + errors = append(errors, task.Error) + } + } + return errors +} + +func (r *TerraRun) HasRelevantTasks() bool { + for _, task := range r.Tasks { + if task.HasRelevance() { + return true + } + } + return false +} + +func (r *TerraRun) HasPlan() bool { + return r.Plan != nil +} + +// ProcessPlanText takes a TaskResult from a terraform show (without -json option) +// which makes for a compact human-readable output which we can show instead of +// the raw output from terraform plan +func (r *TerraRun) ProcessPlan(task *TaskResult) error { + // Make sure we received a `terraform show` task result + if task.TerraCmd != terraShowPlan { + return fmt.Errorf("terraform show command required for processing plan: received %s", task.TerraCmd) + } + // Cannot process a plan if the `terraform show` command error'd + if task.HasError() { + return nil + } + var tfPlan tfjson.Plan + if err := tfPlan.UnmarshalJSON(task.Output); err != nil { + return fmt.Errorf("unmarshalling terraform show plan output: %w", err) + } + + r.Plan = &tfPlan + + return nil +} diff --git a/runner/run_test.go b/runner/run_test.go new file mode 100644 index 0000000..9a789e9 --- /dev/null +++ b/runner/run_test.go @@ -0,0 +1,50 @@ +package runner + +import ( + "sync" + "testing" + + "github.com/hashicorp/go-multierror" + "github.com/stretchr/testify/assert" + "github.com/verifa/terraplate/parser" +) + +func TestMultipleRunError(t *testing.T) { + // Setup TerraRun + tf := parser.DefaultTerrafile + tf.Dir = "testData" + r := TerraRun{ + Terrafile: &tf, + } + // Run the TerraRun twice, and one of those runs should result in an error + var ( + wg sync.WaitGroup + err error + ) + { + wg.Add(1) + go func() { + defer wg.Done() + err = multierror.Append(err, r.Run(TerraRunOpts{ + validate: true, + init: true, + plan: true, + jobs: 1, + })) + }() + } + { + wg.Add(1) + go func() { + defer wg.Done() + err = multierror.Append(err, r.Run(TerraRunOpts{ + validate: true, + init: true, + plan: true, + jobs: 1, + })) + }() + } + wg.Wait() + assert.ErrorIs(t, err, ErrRunInProgress) +} diff --git a/runner/runner.go b/runner/runner.go index 795cebf..ab7005b 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -4,13 +4,9 @@ import ( "context" "errors" "fmt" - "os" - "os/exec" - "os/signal" "strings" - "syscall" - "time" + "github.com/hashicorp/go-multierror" "github.com/remeh/sizedwaitgroup" "github.com/verifa/terraplate/parser" ) @@ -44,97 +40,97 @@ const ( DefaultJobs = 4 ) -func Run(config *parser.TerraConfig, opts ...func(r *TerraRun)) *Result { - // Initialise TerraRun with defaults - run := TerraRun{ +func New(config *parser.TerraConfig, opts ...func(r *TerraRunOpts)) *Runner { + // Initialise TerraRunOpts with defaults + runOpts := TerraRunOpts{ jobs: DefaultJobs, } for _, opt := range opts { - opt(&run) + opt(&runOpts) } - // Listen to terminate calls (i.e. SIGINT) instead of exiting immediately - ctx := listenTerminateSignals() + runner := Runner{ + ctx: listenTerminateSignals(), + swg: sizedwaitgroup.New(runOpts.jobs), + opts: runOpts, + config: config, + } + // Initialize result var ( - rootMods = config.RootModules() - runResults = make([]*RunResult, len(rootMods)) + rootMods = config.RootModules() + runs = make([]*TerraRun, len(rootMods)) ) - // Start terraform runs in different root modules based on number of concurrent - // jobs that are allowed - swg := sizedwaitgroup.New(run.jobs) for index, tf := range config.RootModules() { - - addErr := swg.AddWithContext(ctx) - // Check if the process has been cancelled. - if errors.Is(addErr, context.Canceled) { - // Set an empty RunResult - runResults[index] = &RunResult{ - Terrafile: tf, - Cancelled: true, - } - continue + runs[index] = &TerraRun{ + Terrafile: tf, } - tf := tf - index := index + } + runner.Runs = runs + return &runner +} - go func() { - defer swg.Done() - result := runCmds(&run, tf) - runResults[index] = result - }() +type Runner struct { + opts TerraRunOpts + ctx context.Context + swg sizedwaitgroup.SizedWaitGroup - } - swg.Wait() + config *parser.TerraConfig - return &Result{ - Runs: runResults, - } + Runs []*TerraRun +} + +func Run(config *parser.TerraConfig, opts ...func(r *TerraRunOpts)) *Runner { + + runner := New(config, opts...) + runner.RunAll() + return runner } -func Jobs(jobs int) func(r *TerraRun) { - return func(r *TerraRun) { +func Jobs(jobs int) func(r *TerraRunOpts) { + return func(r *TerraRunOpts) { r.jobs = jobs } } -func RunValidate() func(r *TerraRun) { - return func(r *TerraRun) { +func RunValidate() func(r *TerraRunOpts) { + return func(r *TerraRunOpts) { r.validate = true } } -func RunInit() func(r *TerraRun) { - return func(r *TerraRun) { +func RunInit() func(r *TerraRunOpts) { + return func(r *TerraRunOpts) { r.init = true } } -func RunPlan() func(r *TerraRun) { - return func(r *TerraRun) { +func RunPlan() func(r *TerraRunOpts) { + return func(r *TerraRunOpts) { r.plan = true } } -func RunShowPlan() func(r *TerraRun) { - return func(r *TerraRun) { +func RunShowPlan() func(r *TerraRunOpts) { + return func(r *TerraRunOpts) { r.showPlan = true } } -func RunApply() func(r *TerraRun) { - return func(r *TerraRun) { +func RunApply() func(r *TerraRunOpts) { + return func(r *TerraRunOpts) { r.apply = true } } -func ExtraArgs(extraArgs []string) func(r *TerraRun) { - return func(r *TerraRun) { +func ExtraArgs(extraArgs []string) func(r *TerraRunOpts) { + return func(r *TerraRunOpts) { r.extraArgs = extraArgs } } -type TerraRun struct { +// TerraRunOpts handles running Terraform over the root modules +type TerraRunOpts struct { validate bool init bool plan bool @@ -147,193 +143,115 @@ type TerraRun struct { extraArgs []string } -func runCmds(run *TerraRun, tf *parser.Terrafile) *RunResult { - result := RunResult{ - Terrafile: tf, - } - // Check if root module should be skipped or not - if tf.ExecBlock.Skip { - fmt.Printf("%s: Skipping...\n", tf.RelativeDir()) - result.Skipped = true - return &result - } - - if run.init { - taskResult := initCmd(run, tf) - result.Tasks = append(result.Tasks, taskResult) - if taskResult.HasError() { - return &result - } - } - if run.validate { - taskResult := validateCmd(run, tf) - result.Tasks = append(result.Tasks, taskResult) - if taskResult.HasError() { - return &result - } - } - if run.plan { - taskResult := planCmd(run, tf) - result.Tasks = append(result.Tasks, taskResult) - if taskResult.HasError() { - return &result - } - } - if run.showPlan { - taskResult := showPlanCmd(run, tf) - result.ProcessPlan(taskResult) - result.Tasks = append(result.Tasks, taskResult) - if taskResult.HasError() { - return &result - } - } - if run.apply { - taskResult := applyCmd(run, tf) - result.Tasks = append(result.Tasks, taskResult) - if taskResult.HasError() { - return &result +func (r *Runner) RunAll() { + for _, run := range r.Runs { + addErr := r.swg.AddWithContext(r.ctx) + // Check if the process has been cancelled. + if errors.Is(addErr, context.Canceled) { + run.Cancelled = true + continue } - } - return &result -} - -func initCmd(run *TerraRun, tf *parser.Terrafile) *TaskResult { - var args []string - args = append(args, tfCleanExtraArgs(run.extraArgs)...) - return runCmd(tf, terraInit, args) -} -func validateCmd(run *TerraRun, tf *parser.Terrafile) *TaskResult { - var args []string - args = append(args, tfCleanExtraArgs(run.extraArgs)...) - return runCmd(tf, terraValidate, args) + // Set local run for goroutine + run := run + go func() { + defer r.swg.Done() + run.Run(r.opts) + }() + } + r.swg.Wait() } -func planCmd(run *TerraRun, tf *parser.Terrafile) *TaskResult { - plan := tf.ExecBlock.PlanBlock - - var args []string - args = append(args, - fmt.Sprintf("-lock=%v", plan.Lock), - fmt.Sprintf("-input=%v", plan.Input), +// Log returns a string of the runs and tasks to print to the console +func (r *Runner) Log() string { + var ( + summary strings.Builder + hasRelevantRuns bool ) - if !plan.SkipOut { - args = append(args, - "-out="+plan.Out, - ) - } - args = append(args, tfCleanExtraArgs(tf.ExecBlock.ExtraArgs)...) - args = append(args, tfCleanExtraArgs(run.extraArgs)...) - return runCmd(tf, terraPlan, args) -} + summary.WriteString(textSeparator) + for _, run := range r.Runs { + // Skip runs that have nothing relevant to show + if !run.HasRelevantTasks() { + continue + } + hasRelevantRuns = true + + summary.WriteString(boldColor.Sprintf("Run for %s\n\n", run.Terrafile.RelativeDir())) -func showPlanCmd(run *TerraRun, tf *parser.Terrafile) *TaskResult { - plan := tf.ExecBlock.PlanBlock - if plan.SkipOut { - return &TaskResult{ - TerraCmd: terraShowPlan, - Skipped: true, + for _, task := range run.Tasks { + if task.HasRelevance() { + summary.WriteString(task.Log()) + } } } - var args []string - args = append(args, "-json", plan.Out) - return runCmd(tf, terraShowPlan, args) + // If there were no runs to output, return an empty string to avoid printing + // separators and empty space + if !hasRelevantRuns { + return "" + } + summary.WriteString(textSeparator) + return summary.String() } -func applyCmd(run *TerraRun, tf *parser.Terrafile) *TaskResult { - plan := tf.ExecBlock.PlanBlock - - var args []string - args = append(args, - fmt.Sprintf("-lock=%v", plan.Lock), - fmt.Sprintf("-input=%v", plan.Input), - ) - args = append(args, tfCleanExtraArgs(tf.ExecBlock.ExtraArgs)...) - args = append(args, tfCleanExtraArgs(run.extraArgs)...) - - if !plan.SkipOut { - args = append(args, plan.Out) +// PlanSummary returns a string summary to show after a plan +func (r *Runner) PlanSummary() string { + var summary strings.Builder + summary.WriteString(boldColor.Sprint("\nTerraplate Plan Summary\n\n")) + for _, run := range r.Runs { + summary.WriteString(fmt.Sprintf("%s: %s\n", run.Terrafile.RelativeDir(), run.PlanSummary())) } - - return runCmd(tf, terraApply, args) + return summary.String() } -func runCmd(tf *parser.Terrafile, tfCmd terraCmd, args []string) *TaskResult { - result := TaskResult{ - TerraCmd: tfCmd, - } - cmdArgs := append(tfArgs(tf), string(tfCmd)) - cmdArgs = append(cmdArgs, args...) - result.ExecCmd = exec.Command(terraExe, cmdArgs...) - - // Create channel and start progress printer - done := make(chan bool) - go printProgress(tf.RelativeDir(), tfCmd, done) - defer func() { done <- true }() - - var runErr error - result.Output, runErr = result.ExecCmd.CombinedOutput() - if runErr != nil { - result.Error = fmt.Errorf("%s: running %s command", tf.RelativeDir(), tfCmd) +func (r *Runner) RunsWithDrift() []*TerraRun { + var runs []*TerraRun + for _, run := range r.Runs { + if run.Drift().HasDrift() { + runs = append(runs, run) + } } - - return &result + return runs } -func tfArgs(tf *parser.Terrafile) []string { - var args []string - args = append(args, "-chdir="+tf.Dir) - return args +func (r *Runner) RunsWithError() []*TerraRun { + var runs []*TerraRun + for _, run := range r.Runs { + if run.HasError() { + runs = append(runs, run) + } + } + return runs } -// tfCleanExtraArgs returns the provided slice with any empty spaces removed. -// Empty spaces create weird errors that are hard to debug -func tfCleanExtraArgs(args []string) []string { - var cleanArgs = make([]string, 0) - for _, arg := range args { - if arg != "" { - cleanArgs = append(cleanArgs, arg) +// HasDrift returns true if any drift was detected in any of the runs +func (r *Runner) HasDrift() bool { + for _, run := range r.Runs { + if drift := run.Drift(); drift != nil { + // If at least one of the runs has drifted, then our result has drift + if drift.HasDrift() { + return true + } } } - return cleanArgs + return false } -// listenTerminateSignals returns a context that will be cancelled if an interrupt -// or termination signal is received. The context can be used to prevent further -// runs from being scheduled -func listenTerminateSignals() context.Context { - ctx, cancel := context.WithCancel(context.Background()) - signals := make(chan os.Signal, 1) - signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) - go func() { - for { - <-signals - fmt.Println("") - fmt.Println("Terraplate: Interrupt received.") - fmt.Println("Terraplate: Sending interrupt to all Terraform processes and cancelling any queued runs.") - // Cancel the context, to stop any more runs from being executed - cancel() +func (r *Runner) HasError() bool { + for _, run := range r.Runs { + if run.HasError() { + return true } - }() - return ctx + } + return false } -func printProgress(path string, cmd terraCmd, done <-chan bool) { - var ( - interval = time.Second * 10 - ticker = time.NewTicker(interval) - elapsed time.Duration - ) - defer ticker.Stop() - // Print initial line - fmt.Printf("%s: %s...\n", path, strings.Title(cmd.Action())) - for { - select { - case <-ticker.C: - elapsed += interval - fmt.Printf("%s: Still %s... [%s elapsed]\n", path, cmd.Action(), elapsed) - case <-done: - return +// Errors returns a multierror with any errors found in any tasks within the runs +func (r *Runner) Errors() error { + var err error + for _, run := range r.Runs { + if run.HasError() { + err = multierror.Append(err, run.Errors()...) } } + return err } diff --git a/runner/styles.go b/runner/styles.go new file mode 100644 index 0000000..7c7a336 --- /dev/null +++ b/runner/styles.go @@ -0,0 +1,18 @@ +package runner + +import "github.com/fatih/color" + +var ( + boldColor = color.New(color.Bold) + errorColor = color.New(color.FgRed, color.Bold) + runCancelled = color.New(color.FgRed, color.Bold) + planNotAvailable = color.New(color.FgMagenta, color.Bold) + planNoChangesColor = color.New(color.FgGreen, color.Bold) + planCreateColor = color.New(color.FgGreen, color.Bold) + planDestroyColor = color.New(color.FgRed, color.Bold) + planUpdateColor = color.New(color.FgYellow, color.Bold) +) + +var ( + textSeparator = boldColor.Sprint("\n─────────────────────────────────────────────────────────────────────────────\n\n") +) diff --git a/runner/task.go b/runner/task.go new file mode 100644 index 0000000..3103cf0 --- /dev/null +++ b/runner/task.go @@ -0,0 +1,64 @@ +package runner + +import ( + "bufio" + "bytes" + "fmt" + "os/exec" + "strings" +) + +type TaskResult struct { + ExecCmd *exec.Cmd + TerraCmd terraCmd + + Output []byte + Error error + Skipped bool +} + +func (t *TaskResult) HasError() bool { + return t.Error != nil +} + +// HasRelevance is an attempt at better UX. +// We don't simply want to output everything. Things like successful inits and +// terraform show output are not interesting for the user, so skip them by +// default and therefore keep the output less +func (t *TaskResult) HasRelevance() bool { + // Errors are always relevant + if t.HasError() { + return true + } + // Skipped tasks are not relevant + if t.Skipped { + return false + } + switch t.TerraCmd { + case terraPlan: + // Plan outputs are interesting + return true + case terraApply: + // Apply outputs are interesting + return true + default: + // Skip other command outputs + return false + } +} + +func (t *TaskResult) Log() string { + var summary strings.Builder + + summary.WriteString(fmt.Sprintf("%s output: %s\n\n", strings.Title(string(t.TerraCmd)), t.ExecCmd.String())) + if t.HasError() { + summary.WriteString(fmt.Sprintf("Error: %s\n\n", t.Error.Error())) + } + scanner := bufio.NewScanner(bytes.NewBuffer(t.Output)) + for scanner.Scan() { + summary.WriteString(fmt.Sprintf(" %s\n", scanner.Text())) + } + summary.WriteString("\n\n") + + return summary.String() +} diff --git a/runner/testData/empty.tf b/runner/testData/empty.tf new file mode 100644 index 0000000..e69de29 diff --git a/tutorials/multiple-root-modules/terraplate.hcl b/tutorials/multiple-root-modules/terraplate.hcl index e69de29..8b13789 100644 --- a/tutorials/multiple-root-modules/terraplate.hcl +++ b/tutorials/multiple-root-modules/terraplate.hcl @@ -0,0 +1 @@ +