diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cee7bf6..adcec46 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,7 +37,7 @@ jobs: restore-keys: | ${{ runner.os }}-go- - name: Test - run: go test -failfast ./... + run: go test -v -failfast ./... # Build with GoReleaser (but don't release!). # This verifies that the GoReleaser config is valid and can build - name: Run GoReleaser diff --git a/builder/build.go b/builder/build.go index 6df8e23..503dd65 100644 --- a/builder/build.go +++ b/builder/build.go @@ -2,47 +2,44 @@ package builder import ( "fmt" + "io" "os" "path/filepath" "reflect" "sort" - "github.com/hashicorp/go-multierror" + "github.com/fatih/color" "github.com/hashicorp/hcl/v2/hclwrite" "github.com/verifa/terraplate/parser" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/gocty" ) -// Build takes a TerraConfig as input and builds all the templates and terraform -// files -func Build(config *parser.TerraConfig) error { - var buildErrs error - for _, terrafile := range config.RootModules() { +var errorColor = color.New(color.FgRed, color.Bold) - if err := buildTerraplate(terrafile, config); err != nil { - buildErrs = multierror.Append(buildErrs, fmt.Errorf("building Terraplate Terraform file: %w", err)) - continue - } - - if err := buildTemplates(terrafile); err != nil { - buildErrs = multierror.Append(buildErrs, fmt.Errorf("building templates: %w", err)) - continue - } - - fmt.Printf("%s: Built %d templates\n", terrafile.RelativeDir(), len(terrafile.Templates)) +// BuildTerrafile takes an input Terrafile and builds it, writing any output +// to the provided io.Writer +func BuildTerrafile(tf *parser.Terrafile, out io.Writer) error { + if err := buildTerraplate(tf, out); err != nil { + buildErr := fmt.Errorf("building Terraplate Terraform file: %w", err) + fmt.Fprintf(out, "\n%s: %v", errorColor.Sprint("Error"), buildErr) + return buildErr } - if buildErrs != nil { - return fmt.Errorf("building root modules: %s", buildErrs) + if err := buildTemplates(tf, out); err != nil { + buildErr := fmt.Errorf("building templates: %w", err) + fmt.Fprintf(out, "\n%s: %v", errorColor.Sprint("Error"), buildErr) + return buildErr } - return nil } // buildTemplates builds the templates associated with the given terrafile -func buildTemplates(tf *parser.Terrafile) error { +func buildTemplates(tf *parser.Terrafile, out io.Writer) error { for _, tmpl := range tf.Templates { + target := filepath.Join(tf.Dir, tmpl.Target) + fmt.Fprintf(out, "Building template %s to %s\n", tmpl.Name, target) + data, dataErr := tf.BuildData() if dataErr != nil { return fmt.Errorf("getting build data for %s: %w", tf.Path, dataErr) @@ -56,7 +53,7 @@ func buildTemplates(tf *parser.Terrafile) error { continue } } - target := filepath.Join(tf.Dir, tmpl.Target) + content := defaultTemplateHeader(tf, tmpl) + tmpl.Contents if err := parser.TemplateWrite(data, tmpl.Name, content, target); err != nil { return fmt.Errorf("creating template %s in terrafile %s: %w", tmpl.Name, tf.RelativePath(), err) @@ -67,8 +64,11 @@ func buildTemplates(tf *parser.Terrafile) error { // buildTerraplate builds the terraplate terraform file which contains the // variables (with defaults) and terraform block -func buildTerraplate(terrafile *parser.Terrafile, config *parser.TerraConfig) error { +func buildTerraplate(terrafile *parser.Terrafile, out io.Writer) error { + path := filepath.Join(terrafile.Dir, "terraplate.tf") + fmt.Fprintf(out, "Building terraplate.tf file to %s\n", path) + // Create the Terraform file tfFile := hclwrite.NewEmptyFile() diff --git a/cmd/build.go b/cmd/build.go index 0081c09..60e489c 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -19,7 +19,6 @@ import ( "fmt" "github.com/spf13/cobra" - "github.com/verifa/terraplate/builder" "github.com/verifa/terraplate/parser" "github.com/verifa/terraplate/runner" ) @@ -40,23 +39,19 @@ templates and configurations detected.`, return fmt.Errorf("parsing terraplate: %w", err) } - if err := builder.Build(config); err != nil { - return fmt.Errorf("building terraplate: %w", err) + runOpts := []func(r *runner.TerraRunOpts){ + runner.RunBuild(), } - - fmt.Print(buildSuccessMessage) - if doValidate { - runner := runner.Run(config, runner.RunInit(), runner.RunValidate()) - // Print log - fmt.Println(runner.Log()) - - if runner.HasError() { - return runner.Errors() - } + runOpts = append(runOpts, runner.RunValidate()) } + runOpts = append(runOpts, runner.ExtraArgs(args)) + runner := runner.Run(config, runOpts...) + + fmt.Println(runner.Log()) + fmt.Println(runner.Summary()) - return nil + return runner.Errors() }, } diff --git a/cmd/dev.go b/cmd/dev.go new file mode 100644 index 0000000..c07cc72 --- /dev/null +++ b/cmd/dev.go @@ -0,0 +1,72 @@ +/* +Copyright © 2021 Verifa + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package cmd + +import ( + "fmt" + "io" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" + "github.com/verifa/terraplate/parser" + "github.com/verifa/terraplate/runner" + "github.com/verifa/terraplate/tui" +) + +// devCmd represents the plan command +var devCmd = &cobra.Command{ + Use: "dev", + Short: "Enters dev mode which launches a Terminal UI for Terraplate", + Long: `Enters dev mode which launches a Terminal UI for building and running Terraplate root modules.`, + RunE: func(cmd *cobra.Command, args []string) error { + // Parse + config, err := parser.Parse(&config.ParserConfig) + if err != nil { + return fmt.Errorf("parsing terraplate: %w", err) + } + + // Dev mode + fmt.Print(devStartMessage) + runOpts := []func(r *runner.TerraRunOpts){ + runner.RunBuild(), + runner.RunPlan(), + runner.RunShowPlan(), + runner.Jobs(planJobs), + // Discard any output from the runner itself. + // This does not discard the Terraform output. + runner.Output(io.Discard), + } + runOpts = append(runOpts, runner.ExtraArgs(args)) + runner := runner.New(config, runOpts...) + + p := tea.NewProgram( + tui.New(runner), + tea.WithAltScreen(), + tea.WithMouseCellMotion(), + ) + if err := p.Start(); err != nil { + fmt.Printf("Alas, there's been an error: %v", err) + os.Exit(1) + } + return nil + }, +} + +func init() { + RootCmd.AddCommand(devCmd) + devCmd.Flags().IntVarP(&planJobs, "jobs", "j", runner.DefaultJobs, "Number of concurrent terraform jobs to run at one time") +} diff --git a/cmd/drift.go b/cmd/drift.go index f6f12a1..f36a4b8 100644 --- a/cmd/drift.go +++ b/cmd/drift.go @@ -19,7 +19,6 @@ import ( "fmt" "github.com/spf13/cobra" - "github.com/verifa/terraplate/builder" "github.com/verifa/terraplate/notify" "github.com/verifa/terraplate/parser" "github.com/verifa/terraplate/runner" @@ -58,15 +57,10 @@ var driftCmd = &cobra.Command{ if err != nil { return fmt.Errorf("parsing terraplate: %w", err) } - // Build - fmt.Print(buildStartMessage) - if err := builder.Build(config); err != nil { - return err - } - fmt.Print(buildSuccessMessage) // Plan fmt.Print(terraformStartMessage) runOpts := []func(r *runner.TerraRunOpts){ + runner.RunBuild(), runner.RunInit(), runner.RunPlan(), runner.RunShowPlan(), @@ -92,7 +86,7 @@ var driftCmd = &cobra.Command{ } } - fmt.Print(runner.PlanSummary()) + fmt.Print(runner.Summary()) return nil }, } diff --git a/cmd/init.go b/cmd/init.go index b69bd29..a250c0d 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -23,7 +23,11 @@ import ( "github.com/verifa/terraplate/runner" ) -var initJobs int +var ( + initJobs int + initSkipBuild bool + initUpgrade bool +) // initCmd represents the init command var initCmd = &cobra.Command{ @@ -35,9 +39,23 @@ var initCmd = &cobra.Command{ if err != nil { return fmt.Errorf("parsing terraplate: %w", err) } - runner := runner.Run(config, runner.RunInit(), runner.Jobs(initJobs), runner.ExtraArgs(args)) - // Print log + runOpts := []func(r *runner.TerraRunOpts){ + runner.RunInit(), + runner.Jobs(initJobs), + runner.ExtraArgs(args), + } + + if !initSkipBuild { + runOpts = append(runOpts, runner.RunBuild()) + } + if initUpgrade { + runOpts = append(runOpts, runner.RunInitUpgrade()) + } + + runOpts = append(runOpts, runner.ExtraArgs(args)) + runner := runner.Run(config, runOpts...) fmt.Println(runner.Log()) + fmt.Println(runner.Summary()) return runner.Errors() }, @@ -46,5 +64,7 @@ var initCmd = &cobra.Command{ func init() { RootCmd.AddCommand(initCmd) + initCmd.Flags().BoolVar(&initSkipBuild, "skip-build", false, "Skip build process (default: false)") + initCmd.Flags().BoolVarP(&initUpgrade, "upgrade", "u", false, "Perform upgrade when initializing") initCmd.Flags().IntVarP(&initJobs, "jobs", "j", runner.DefaultJobs, "Number of concurrent terraform jobs to run at one time") } diff --git a/cmd/messages.go b/cmd/messages.go index 706ffb9..2bb746b 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -1,16 +1,12 @@ package cmd import ( - "fmt" - "github.com/fatih/color" ) var ( - successColor = color.New(color.FgGreen, color.Bold) errorColor = color.New(color.FgRed, color.Bold) boldText = color.New(color.Bold) - buildStartMessage = boldText.Sprint("\nBuilding root modules...\n\n") - buildSuccessMessage = fmt.Sprintf("\n%s All root modules built\n\n", successColor.Sprint("Success!")) terraformStartMessage = boldText.Sprint("\nTerraforming root modules...\n\n") + devStartMessage = boldText.Sprint("\nStarting dev mode...\n\n") ) diff --git a/cmd/plan.go b/cmd/plan.go index 1b41154..03d9180 100644 --- a/cmd/plan.go +++ b/cmd/plan.go @@ -17,17 +17,21 @@ package cmd import ( "fmt" + "io" + "os" + tea "github.com/charmbracelet/bubbletea" "github.com/spf13/cobra" - "github.com/verifa/terraplate/builder" "github.com/verifa/terraplate/parser" "github.com/verifa/terraplate/runner" + "github.com/verifa/terraplate/tui" ) var ( - runBuild bool - runInit bool - planJobs int + planSkipBuild bool + runInit bool + planJobs int + planDevMode bool ) // planCmd represents the plan command @@ -40,38 +44,62 @@ var planCmd = &cobra.Command{ if err != nil { return fmt.Errorf("parsing terraplate: %w", err) } - if runBuild { - fmt.Print(buildStartMessage) - if err := builder.Build(config); err != nil { - return err - } - fmt.Print(buildSuccessMessage) - } fmt.Print(terraformStartMessage) runOpts := []func(r *runner.TerraRunOpts){ runner.RunPlan(), runner.RunShowPlan(), runner.Jobs(planJobs), } + if !planSkipBuild { + runOpts = append(runOpts, runner.RunBuild()) + } if runInit { runOpts = append(runOpts, runner.RunInit()) } runOpts = append(runOpts, runner.ExtraArgs(args)) - runner := runner.Run(config, runOpts...) + r := runner.Run(config, runOpts...) + + if planDevMode { + // Start dev mode + fmt.Print(devStartMessage) + runOpts := []func(r *runner.TerraRunOpts){ + runner.RunBuild(), + runner.RunPlan(), + runner.RunShowPlan(), + runner.Jobs(planJobs), + // Discard any output from the runner itself. + // This does not discard the Terraform output. + runner.Output(io.Discard), + } + // Override options in runner + r.Opts = runner.NewOpts(runOpts...) + p := tea.NewProgram( + tui.New(r), + tea.WithAltScreen(), + tea.WithMouseCellMotion(), + ) + if err := p.Start(); err != nil { + fmt.Printf("Alas, there's been an error: %v", err) + os.Exit(1) + } + + return nil + } // Print log - fmt.Println(runner.Log()) + fmt.Println(r.Log()) - fmt.Println(runner.PlanSummary()) + fmt.Println(r.Summary()) - return runner.Errors() + return r.Errors() }, } func init() { RootCmd.AddCommand(planCmd) - planCmd.Flags().BoolVar(&runBuild, "build", false, "Run build process also") + planCmd.Flags().BoolVar(&planSkipBuild, "skip-build", false, "Skip build process (default: false)") planCmd.Flags().BoolVar(&runInit, "init", false, "Run terraform init also") + planCmd.Flags().BoolVar(&planDevMode, "dev", false, "Start dev mode after plan finishes") planCmd.Flags().IntVarP(&planJobs, "jobs", "j", runner.DefaultJobs, "Number of concurrent terraform jobs to run at one time") } diff --git a/docs/assets/images/tp-dev-mode.png b/docs/assets/images/tp-dev-mode.png new file mode 100644 index 0000000..5fc4b07 Binary files /dev/null and b/docs/assets/images/tp-dev-mode.png differ diff --git a/docs/commands/terraplate.md b/docs/commands/terraplate.md index 7945b05..b9fe264 100644 --- a/docs/commands/terraplate.md +++ b/docs/commands/terraplate.md @@ -25,6 +25,7 @@ Terraform configurations like providers and backend. * [terraplate apply](terraplate_apply.md) - Runs terraform apply on all subdirectories * [terraplate build](terraplate_build.md) - Build Terraform files based your Terrafiles +* [terraplate dev](terraplate_dev.md) - Enters dev mode which launches a Terminal UI for Terraplate * [terraplate drift](terraplate_drift.md) - Detect drift in your infrastructure (experimental feature) * [terraplate init](terraplate_init.md) - Runs terraform init on all subdirectories * [terraplate parse](terraplate_parse.md) - Parse the terraplate files and print a summary diff --git a/docs/commands/terraplate_dev.md b/docs/commands/terraplate_dev.md new file mode 100644 index 0000000..76d5cc8 --- /dev/null +++ b/docs/commands/terraplate_dev.md @@ -0,0 +1,33 @@ +--- +# # AUTOMATICALLY GENERATED BY COBRA (DO NOT EDIT) +title: "terraplate dev" +--- +## terraplate dev + +Enters dev mode which launches a Terminal UI for Terraplate + +### Synopsis + +Enters dev mode which launches a Terminal UI for building and running Terraplate root modules. + +``` +terraplate dev [flags] +``` + +### Options + +``` + -h, --help help for dev + -j, --jobs int Number of concurrent terraform jobs to run at one time (default 4) +``` + +### Options inherited from parent commands + +``` + -C, --chdir string Switch to a different working directory before executing the given subcommand. (default ".") +``` + +### SEE ALSO + +* [terraplate](terraplate.md) - DRY Terraform using Go Templates + diff --git a/docs/commands/terraplate_init.md b/docs/commands/terraplate_init.md index 27de9e0..743e244 100644 --- a/docs/commands/terraplate_init.md +++ b/docs/commands/terraplate_init.md @@ -17,8 +17,10 @@ terraplate init [flags] ### Options ``` - -h, --help help for init - -j, --jobs int Number of concurrent terraform jobs to run at one time (default 4) + -h, --help help for init + -j, --jobs int Number of concurrent terraform jobs to run at one time (default 4) + --skip-build Skip build process (default: false) + -u, --upgrade Perform upgrade when initializing ``` ### Options inherited from parent commands diff --git a/docs/commands/terraplate_plan.md b/docs/commands/terraplate_plan.md index b0dbd5d..94b6fc6 100644 --- a/docs/commands/terraplate_plan.md +++ b/docs/commands/terraplate_plan.md @@ -17,10 +17,11 @@ terraplate plan [flags] ### Options ``` - --build Run build process also - -h, --help help for plan - --init Run terraform init also - -j, --jobs int Number of concurrent terraform jobs to run at one time (default 4) + --dev Start dev mode after plan finishes + -h, --help help for plan + --init Run terraform init also + -j, --jobs int Number of concurrent terraform jobs to run at one time (default 4) + --skip-build Skip build process (default: false) ``` ### Options inherited from parent commands diff --git a/docs/index.md b/docs/index.md index f666402..b2fc5e2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -49,7 +49,7 @@ There are existing approaches to running Terraform (e.g. init, plan, apply) over Terraplate's templating is compatible with all the approaches above (in fact Terraplate can template your Terragrunt files! But let's not go there...). That means you can use Terraplate for **just templating and keeping things DRY** but it also comes with a thin wrapper for invoking Terraform (similar to Terragrunt). -### Drift detection (coming soon) +### Drift detection Terraplate can be run on a regular basis and detect drift in your infrastructure and create notifications. Currenly only Slack will be supported, but we welcome ideas/suggestions for more! @@ -65,6 +65,12 @@ The two main benefits Terraplate brings is: 1. Keeping your code DRY and more maintainable 2. Improve developer productivity by spending less time writing boilerplate and running Terraform across all your Root Modules +#### Dev mode + +Terraplate has a "dev" mode which launches a Terminal UI, for browsing Root modules and running commands: + +![dev-mode](../assets/images/tp-dev-mode.png) + ### Terraform users who want to make [Workspaces](https://www.terraform.io/cli/workspaces) more DRY or avoid them If you don't find workspaces completely solves the issue of DRY infra, or they are not right for you, Terraplate is worth considering. diff --git a/docs/tutorials/.pages b/docs/tutorials/.pages index 2b8be8f..3eb346a 100644 --- a/docs/tutorials/.pages +++ b/docs/tutorials/.pages @@ -1,4 +1,5 @@ arrange: - multiple-root-modules.md +- dev-mode.md - drift-slack-notifications.md diff --git a/docs/tutorials/dev-mode.md b/docs/tutorials/dev-mode.md new file mode 100644 index 0000000..59c9fb3 --- /dev/null +++ b/docs/tutorials/dev-mode.md @@ -0,0 +1,38 @@ +--- +title: "Dev mode (TUI)" +description: "Dev mode launches Terraplate's Terminal UI" +--- + +This tutorial gives a *very* quick overview of Terraplate's `dev` mode. + +## Dev mode + +Terraplate's `dev` mode launches a Terminal UI for working with multiple root modules and executing Terraform via Terraplate. + +![dev-mode](../assets/images/tp-dev-mode.png) + +## Entering dev mode + +You can either run `terraplate dev` or `terraplate plan --dev` to start dev mode. + +With the `dev` subcommand the TUI starts up immediately. +With the `plan --dev` subcommand, the plan finishes before starting the TUI. + +## Command mode + +Once in dev mode, you can press `:` to enter command mode, and you can do the following: + +- `b` for build +- `i` for init +- `u` for init -upgrade +- `p` for plan +- `a` for apply +- `A` for all (run over all visible root modules) + +Finally press enter to run the commands over the root module(s). + +For example the following keypresses will build, init (with upgrade) and plan all visible root modules: `:bupA`. + +## Help + +Check the help at the bottom of the TUI. You can press `?` to get the extended help. diff --git a/go.mod b/go.mod index a3a35ae..4922868 100644 --- a/go.mod +++ b/go.mod @@ -4,13 +4,16 @@ go 1.17 require ( github.com/Masterminds/sprig/v3 v3.2.2 + github.com/charmbracelet/bubbles v0.13.0 + github.com/charmbracelet/bubbletea v0.21.0 + github.com/charmbracelet/lipgloss v0.5.0 github.com/fatih/color v1.13.0 github.com/go-git/go-git/v5 v5.4.2 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/hcl/v2 v2.11.1 github.com/hashicorp/terraform-json v0.14.0 github.com/imdario/mergo v0.3.12 - github.com/remeh/sizedwaitgroup v1.0.0 + github.com/muesli/termenv v0.12.0 github.com/slack-go/slack v0.10.3 github.com/spf13/cobra v1.3.0 github.com/stretchr/testify v1.7.0 @@ -26,6 +29,8 @@ require ( github.com/acomagu/bufpipe v1.0.3 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/containerd/console v1.0.3 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emirpasic/gods v1.12.0 // indirect @@ -40,15 +45,21 @@ require ( github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect - github.com/kylelemons/godebug v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect 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/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.0 // indirect + github.com/muesli/reflow v0.3.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sahilm/fuzzy v0.1.0 // indirect github.com/sergi/go-diff v1.1.0 // indirect github.com/shopspring/decimal v1.2.0 // indirect github.com/spf13/cast v1.4.1 // indirect diff --git a/go.sum b/go.sum index 72512da..8daae5d 100644 --- a/go.sum +++ b/go.sum @@ -85,6 +85,8 @@ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= @@ -94,6 +96,13 @@ github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.13.0 h1:zP/ROH3wJEBqZWKIsD50ZKKlx3ydLInq3LdD/Nrlb8w= +github.com/charmbracelet/bubbles v0.13.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc= +github.com/charmbracelet/bubbletea v0.21.0 h1:f3y+kanzgev5PA916qxmDybSHU3N804uOnKnhRPXTcI= +github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= +github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -110,6 +119,8 @@ github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU= @@ -321,6 +332,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= @@ -338,6 +351,10 @@ github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOA github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= @@ -363,6 +380,17 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.0 h1:SOpr+CfyVNce341kKqvbhhzQhBPyJRXQaCtn03Pae1Q= +github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc= +github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= @@ -389,14 +417,17 @@ github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8b github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= -github.com/remeh/sizedwaitgroup v1.0.0 h1:VNGGFwNo/R5+MJBf6yrsr110p0m4/OX4S3DCy7Kyl5E= -github.com/remeh/sizedwaitgroup v1.0.0/go.mod h1:3j2R4OIe/SeS6YDhICBy22RWjJC5eNCJ1V+9+NVNYlo= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= +github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sebdah/goldie v1.0.0/go.mod h1:jXP4hmWywNEwZzhMuv2ccnqTSFpuq8iyQhtQdkkZBH4= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= @@ -631,6 +662,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -655,6 +687,7 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc 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/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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= diff --git a/main_test.go b/main_test.go index f36edd1..6bb07da 100644 --- a/main_test.go +++ b/main_test.go @@ -4,7 +4,6 @@ import ( "testing" "github.com/stretchr/testify/require" - "github.com/verifa/terraplate/builder" "github.com/verifa/terraplate/parser" "github.com/verifa/terraplate/runner" ) @@ -43,11 +42,10 @@ func TestMain(t *testing.T) { Chdir: tc.dir, }) require.NoError(t, err) - buildErr := builder.Build(config) - require.NoError(t, buildErr) if !tc.skipTerraform { runner := runner.Run(config, + runner.RunBuild(), runner.RunValidate(), runner.RunInit(), runner.RunPlan(), diff --git a/parser/parser.go b/parser/parser.go index d8f2d9b..80fce1b 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -43,8 +43,15 @@ func Parse(config *Config) (*TerraConfig, error) { return nil, errors.New("no terraplate files found") } + // Get the absolute path to set as working directory + absPath, absErr := filepath.Abs(config.Chdir) + if absErr != nil { + return nil, fmt.Errorf("getting absolute path for working directory %s: %w", config.Chdir, absErr) + } + tfc := TerraConfig{ - Terrafiles: terrafiles, + Terrafiles: terrafiles, + WorkingDirectory: absPath, } // Terrafiles inherit values from ancestors. Let's resolve the root modules diff --git a/parser/terraconfig.go b/parser/terraconfig.go index 48e27be..d236d6e 100644 --- a/parser/terraconfig.go +++ b/parser/terraconfig.go @@ -7,7 +7,8 @@ import ( ) type TerraConfig struct { - Terrafiles []*Terrafile + Terrafiles []*Terrafile + WorkingDirectory string } // RootModules returns the Terrafiles that are considered root modules diff --git a/runner/cmds.go b/runner/cmds.go index a70e4f2..97402f4 100644 --- a/runner/cmds.go +++ b/runner/cmds.go @@ -1,32 +1,72 @@ package runner import ( - "context" "fmt" - "os" + "io" + "log" "os/exec" - "os/signal" - "syscall" "time" + "github.com/verifa/terraplate/builder" "github.com/verifa/terraplate/parser" "golang.org/x/text/cases" "golang.org/x/text/language" ) -func initCmd(run TerraRunOpts, tf *parser.Terrafile) *TaskResult { +type terraCmd string + +func (t terraCmd) Action() string { + switch t { + case terraBuild: + return "building" + case terraValidate: + return "validating" + case terraInit: + return "initializing" + case terraPlan: + return "planning" + case terraApply: + return "applying" + case terraShowPlan: + return "summarizing" + } + return "Unknown action" +} + +const ( + terraExe = "terraform" + + terraBuild terraCmd = "build" + terraValidate terraCmd = "validate" + terraInit terraCmd = "init" + terraPlan terraCmd = "plan" + terraApply terraCmd = "apply" + terraShowPlan terraCmd = "show" +) + +func buildCmd(opts TerraRunOpts, tf *parser.Terrafile) *TaskResult { + var task TaskResult + task.TerraCmd = terraBuild + task.Error = builder.BuildTerrafile(tf, &task.Output) + return &task +} + +func validateCmd(opts TerraRunOpts, tf *parser.Terrafile) *TaskResult { var args []string - args = append(args, tfCleanExtraArgs(run.extraArgs)...) - return runCmd(tf, terraInit, args) + args = append(args, tfCleanExtraArgs(opts.extraArgs)...) + return runCmd(opts.out, tf, terraValidate, args) } -func validateCmd(run TerraRunOpts, tf *parser.Terrafile) *TaskResult { +func initCmd(opts TerraRunOpts, tf *parser.Terrafile) *TaskResult { var args []string - args = append(args, tfCleanExtraArgs(run.extraArgs)...) - return runCmd(tf, terraValidate, args) + if opts.initUpgrade { + args = append(args, "-upgrade") + } + args = append(args, tfCleanExtraArgs(opts.extraArgs)...) + return runCmd(opts.out, tf, terraInit, args) } -func planCmd(run TerraRunOpts, tf *parser.Terrafile) *TaskResult { +func planCmd(opts TerraRunOpts, tf *parser.Terrafile) *TaskResult { plan := tf.ExecBlock.PlanBlock var args []string @@ -40,11 +80,11 @@ func planCmd(run TerraRunOpts, tf *parser.Terrafile) *TaskResult { ) } args = append(args, tfCleanExtraArgs(tf.ExecBlock.ExtraArgs)...) - args = append(args, tfCleanExtraArgs(run.extraArgs)...) - return runCmd(tf, terraPlan, args) + args = append(args, tfCleanExtraArgs(opts.extraArgs)...) + return runCmd(opts.out, tf, terraPlan, args) } -func showPlanCmd(run TerraRunOpts, tf *parser.Terrafile) *TaskResult { +func showPlanCmd(opts TerraRunOpts, tf *parser.Terrafile) *TaskResult { plan := tf.ExecBlock.PlanBlock if plan.SkipOut { return &TaskResult{ @@ -54,10 +94,10 @@ func showPlanCmd(run TerraRunOpts, tf *parser.Terrafile) *TaskResult { } var args []string args = append(args, "-json", plan.Out) - return runCmd(tf, terraShowPlan, args) + return runCmd(opts.out, tf, terraShowPlan, args) } -func applyCmd(run TerraRunOpts, tf *parser.Terrafile) *TaskResult { +func applyCmd(opts TerraRunOpts, tf *parser.Terrafile) *TaskResult { plan := tf.ExecBlock.PlanBlock var args []string @@ -66,35 +106,49 @@ func applyCmd(run TerraRunOpts, tf *parser.Terrafile) *TaskResult { fmt.Sprintf("-input=%v", plan.Input), ) args = append(args, tfCleanExtraArgs(tf.ExecBlock.ExtraArgs)...) - args = append(args, tfCleanExtraArgs(run.extraArgs)...) + args = append(args, tfCleanExtraArgs(opts.extraArgs)...) if !plan.SkipOut { args = append(args, plan.Out) } - return runCmd(tf, terraApply, args) + return runCmd(opts.out, tf, terraApply, args) } -func runCmd(tf *parser.Terrafile, tfCmd terraCmd, args []string) *TaskResult { - result := TaskResult{ +func runCmd(out io.Writer, tf *parser.Terrafile, tfCmd terraCmd, args []string) *TaskResult { + task := TaskResult{ TerraCmd: tfCmd, } cmdArgs := append(tfArgs(tf), string(tfCmd)) cmdArgs = append(cmdArgs, args...) - result.ExecCmd = exec.Command(terraExe, cmdArgs...) + task.ExecCmd = exec.Command(terraExe, cmdArgs...) // Create channel and start progress printer done := make(chan bool) - go printProgress(tf.RelativeDir(), tfCmd, done) + go printProgress(out, tf.Dir, tfCmd, done) defer func() { done <- true }() - var runErr error - result.Output, runErr = result.ExecCmd.CombinedOutput() + pr, pw := io.Pipe() + defer pw.Close() + task.ExecCmd.Stdout = pw + task.ExecCmd.Stderr = pw + + if err := task.ExecCmd.Start(); err != nil { + task.Error = fmt.Errorf("starting command: %w", err) + return &task + } + go func() { + if _, err := io.Copy(&task.Output, pr); err != nil { + log.Fatal(err) + } + }() + + runErr := task.ExecCmd.Wait() if runErr != nil { - result.Error = fmt.Errorf("%s: running %s command", tf.RelativeDir(), tfCmd) + task.Error = fmt.Errorf("%s: running %s command", tf.Dir, tfCmd) } - return &result + return &task } func tfArgs(tf *parser.Terrafile) []string { @@ -115,27 +169,7 @@ func tfCleanExtraArgs(args []string) []string { 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) { +func printProgress(out io.Writer, path string, cmd terraCmd, done <-chan bool) { var ( interval = time.Second * 10 ticker = time.NewTicker(interval) @@ -143,12 +177,12 @@ func printProgress(path string, cmd terraCmd, done <-chan bool) { ) defer ticker.Stop() // Print initial line - fmt.Printf("%s: %s...\n", path, cases.Title(language.English).String(cmd.Action())) + fmt.Fprintf(out, "%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) + fmt.Fprintf(out, "%s: Still %s... [%s elapsed]\n", path, cmd.Action(), elapsed) case <-done: return } diff --git a/runner/opts.go b/runner/opts.go new file mode 100644 index 0000000..dc67a72 --- /dev/null +++ b/runner/opts.go @@ -0,0 +1,108 @@ +package runner + +import ( + "io" + "os" +) + +func Jobs(jobs int) func(r *TerraRunOpts) { + return func(r *TerraRunOpts) { + r.jobs = jobs + } +} + +func RunBuild() func(r *TerraRunOpts) { + return func(r *TerraRunOpts) { + r.build = true + } +} + +func RunValidate() func(r *TerraRunOpts) { + return func(r *TerraRunOpts) { + r.validate = true + } +} + +func RunInit() func(r *TerraRunOpts) { + return func(r *TerraRunOpts) { + r.init = true + } +} + +func RunInitUpgrade() func(r *TerraRunOpts) { + return func(r *TerraRunOpts) { + r.init = true + r.initUpgrade = true + } +} + +func RunPlan() func(r *TerraRunOpts) { + return func(r *TerraRunOpts) { + r.plan = true + } +} + +func RunShowPlan() func(r *TerraRunOpts) { + return func(r *TerraRunOpts) { + r.showPlan = true + } +} + +func RunApply() func(r *TerraRunOpts) { + return func(r *TerraRunOpts) { + r.apply = true + } +} + +func Output(out io.Writer) func(r *TerraRunOpts) { + return func(r *TerraRunOpts) { + r.out = out + } +} + +func ExtraArgs(extraArgs []string) func(r *TerraRunOpts) { + return func(r *TerraRunOpts) { + r.extraArgs = extraArgs + } +} + +func FromOpts(opts TerraRunOpts) func(r *TerraRunOpts) { + return func(r *TerraRunOpts) { + r.jobs = opts.jobs + r.out = opts.out + } +} + +func NewOpts(opts ...func(r *TerraRunOpts)) TerraRunOpts { + // Initialise TerraRunOpts with defaults + runOpts := TerraRunOpts{ + jobs: DefaultJobs, + } + for _, opt := range opts { + opt(&runOpts) + } + + // Set default output + if runOpts.out == nil { + runOpts.out = os.Stdout + } + return runOpts +} + +// TerraRunOpts handles running Terraform over the root modules +type TerraRunOpts struct { + out io.Writer + + build bool + validate bool + init bool + initUpgrade bool + plan bool + showPlan bool + apply bool + + // Max number of concurrent jobs allowed + jobs int + // Terraform command flags + extraArgs []string +} diff --git a/runner/result.go b/runner/result.go deleted file mode 100644 index 75c10db..0000000 --- a/runner/result.go +++ /dev/null @@ -1 +0,0 @@ -package runner diff --git a/runner/rootmodule.go b/runner/rootmodule.go new file mode 100644 index 0000000..841b34b --- /dev/null +++ b/runner/rootmodule.go @@ -0,0 +1,79 @@ +package runner + +import ( + "errors" + "sync" + + "github.com/verifa/terraplate/parser" +) + +var ( + ErrRunInProgress = errors.New("run is already in progress") + ErrRunSkipped = errors.New("cannot run skipped module") +) + +// func newRootModule(tf *parser.Terrafile, opts TerraRunOpts) *RootModule { +func newRootModule(tf *parser.Terrafile, opts TerraRunOpts) *RootModule { + return &RootModule{ + Terrafile: tf, + Opts: opts, + } +} + +type RootModule struct { + // Terrafile is the terrafile for which this run was executed + Terrafile *parser.Terrafile + Opts TerraRunOpts + + // Run stores the current run, if one has been scheduled + Run *TerraRun + mu sync.RWMutex +} + +// ScheduleRun schedules a run on the RootModule by setting the state and +// adding to the waitgroup. +// If a run is already in progress an error is returned and the state is unchanged +func (r *RootModule) ScheduleRun(runQueue chan *TerraRun) error { + return r.ScheduleRunWithOpts(runQueue, r.Opts) +} + +// ScheduleRun schedules a run on the RootModule by setting the state and +// adding to the waitgroup. +// If a run is already in progress an error is returned and the state is unchanged +func (r *RootModule) ScheduleRunWithOpts(runQueue chan *TerraRun, opts TerraRunOpts) error { + // Don't schedule runs for modules that should be skipped + if r.Skip() { + return ErrRunSkipped + } + r.mu.Lock() + defer r.mu.Unlock() + + if r.Run != nil && r.Run.IsRunning() { + return ErrRunInProgress + } + newRun := newRunForQueue(r.Terrafile, opts) + r.Run = newRun + runQueue <- newRun + return nil +} + +func (r *RootModule) Wait() { + if r.Run != nil { + r.Run.Wait() + } +} + +func (r *RootModule) Skip() bool { + return r.Terrafile.ExecBlock.Skip +} + +func (r *RootModule) IsRunning() bool { + if r.Run == nil { + return false + } + return r.Run.IsRunning() +} + +func (r *RootModule) HasRun() bool { + return r.Run != nil +} diff --git a/runner/run.go b/runner/run.go index b1ba953..acdf979 100644 --- a/runner/run.go +++ b/runner/run.go @@ -1,124 +1,158 @@ package runner import ( - "errors" "fmt" + "strings" "sync" tfjson "github.com/hashicorp/terraform-json" "github.com/verifa/terraplate/parser" ) -var ( - ErrRunInProgress = errors.New("run is already in progress") +type state int + +const ( + finishedState state = iota + queueState + runState ) +func newRunForQueue(tf *parser.Terrafile, opts TerraRunOpts) *TerraRun { + + var r TerraRun + r.Terrafile = tf + r.Opts = opts + r.state = queueState + // Increment waitgroup so that we can wait on this run, even whilst it is + // queueing, until it is finished + r.wg.Add(1) + return &r +} + type TerraRun struct { // Terrafile is the terrafile for which this run was executed Terrafile *parser.Terrafile + Opts TerraRunOpts Tasks []*TaskResult Cancelled bool - Skipped bool Plan *tfjson.Plan PlanText []byte - mu sync.RWMutex - isRunning bool + wg sync.WaitGroup + state state } -// 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 - } +// Run performs a blocking run for this TerraRun i.e. invoking Terraform +func (r *TerraRun) Run() { + r.Start() + r.Wait() +} + +// Start performs a non-blocking run for this TerraRun i.e. invoking Terraform +func (r *TerraRun) Start() { + r.startRun() + defer r.endRun() 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) + if r.Opts.build { + taskResult := buildCmd(r.Opts, tf) r.Tasks = append(r.Tasks, taskResult) if taskResult.HasError() { - return nil + return } } - if opts.validate { - taskResult := validateCmd(opts, tf) + if r.Opts.init { + taskResult := initCmd(r.Opts, tf) r.Tasks = append(r.Tasks, taskResult) if taskResult.HasError() { - return nil + return } } - if opts.plan { - taskResult := planCmd(opts, tf) + if r.Opts.validate { + taskResult := validateCmd(r.Opts, tf) r.Tasks = append(r.Tasks, taskResult) if taskResult.HasError() { - return nil + return } } - if opts.showPlan { - taskResult := showPlanCmd(opts, tf) + if r.Opts.plan { + taskResult := planCmd(r.Opts, tf) + r.Tasks = append(r.Tasks, taskResult) + if taskResult.HasError() { + return + } + } + if r.Opts.showPlan { + taskResult := showPlanCmd(r.Opts, tf) r.ProcessPlan(taskResult) r.Tasks = append(r.Tasks, taskResult) if taskResult.HasError() { - return nil + return } } - if opts.apply { - taskResult := applyCmd(opts, tf) + if r.Opts.apply { + taskResult := applyCmd(r.Opts, tf) r.Tasks = append(r.Tasks, taskResult) if taskResult.HasError() { - return nil + return } } +} - r.endRun() - return nil +// Wait blocks and waits for the run to be finished +func (r *TerraRun) Wait() { + r.wg.Wait() } -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) startRun() { + r.state = runState } func (r *TerraRun) endRun() { - r.mu.Lock() - defer r.mu.Unlock() - r.isRunning = false + defer r.wg.Done() + r.state = finishedState } -// 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") +func (r *TerraRun) Log(fullLog bool) string { + var log strings.Builder + log.WriteString(boldColor.Sprintf("Run for %s\n\n", r.Terrafile.Dir)) + + for _, task := range r.Tasks { + if fullLog || task.IsRelevant() { + log.WriteString(task.Log()) + } } - if r.Cancelled { + return log.String() +} + +// Summary returns a string summary to show after a plan +func (r *TerraRun) Summary() string { + switch { + case r.HasError(): + return errorColor.Sprint("Error occurred") + case r.Cancelled: return runCancelled.Sprint("Cancelled") + case r.IsApplied(): + return boldColor.Sprint("Applied") + case r.IsPlanned(): + if !r.HasPlan() { + return planNotAvailable.Sprint("Plan not available") + } + return r.Drift().Diff() + case r.IsInitd(): + return boldColor.Sprint("Initialized") + case r.IsBuilt(): + return boldColor.Sprint("Built") + default: + return "Unknown status" } - 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() { + if r == nil || !r.HasPlan() { // Return an empty drift which means no drift (though user should check // if plan was available as well) return &Drift{} @@ -126,7 +160,77 @@ func (r *TerraRun) Drift() *Drift { return driftFromPlan(r.Plan) } +func (r *TerraRun) IsApplied() bool { + if r == nil { + return false + } + if r.state != finishedState { + return false + } + for _, task := range r.Tasks { + if task.TerraCmd == terraApply { + return true + } + } + return false +} + +func (r *TerraRun) IsPlanned() bool { + if r == nil { + return false + } + if r.state != finishedState { + return false + } + for _, task := range r.Tasks { + if task.TerraCmd == terraPlan { + return true + } + } + return false +} + +func (r *TerraRun) IsInitd() bool { + if r == nil { + return false + } + if r.state != finishedState { + return false + } + for _, task := range r.Tasks { + if task.TerraCmd == terraInit { + return true + } + } + return false +} + +func (r *TerraRun) IsBuilt() bool { + if r == nil { + return false + } + if r.state != finishedState { + return false + } + for _, task := range r.Tasks { + if task.TerraCmd == terraBuild { + return true + } + } + return false +} + +func (r *TerraRun) IsRunning() bool { + if r == nil { + return false + } + return r.state == queueState || r.state == runState +} + func (r *TerraRun) HasError() bool { + if r == nil { + return false + } for _, task := range r.Tasks { if task.HasError() { return true @@ -147,7 +251,7 @@ func (r *TerraRun) Errors() []error { func (r *TerraRun) HasRelevantTasks() bool { for _, task := range r.Tasks { - if task.HasRelevance() { + if task.IsRelevant() { return true } } @@ -155,6 +259,9 @@ func (r *TerraRun) HasRelevantTasks() bool { } func (r *TerraRun) HasPlan() bool { + if r == nil { + return false + } return r.Plan != nil } @@ -171,7 +278,7 @@ func (r *TerraRun) ProcessPlan(task *TaskResult) error { return nil } var tfPlan tfjson.Plan - if err := tfPlan.UnmarshalJSON(task.Output); err != nil { + if err := tfPlan.UnmarshalJSON(task.Output.Bytes()); err != nil { return fmt.Errorf("unmarshalling terraform show plan output: %w", err) } diff --git a/runner/run_test.go b/runner/run_test.go deleted file mode 100644 index 9a789e9..0000000 --- a/runner/run_test.go +++ /dev/null @@ -1,50 +0,0 @@ -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 ab7005b..ea46d3c 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -2,164 +2,111 @@ package runner import ( "context" - "errors" "fmt" + "os" + "os/signal" "strings" + "sync" + "syscall" "github.com/hashicorp/go-multierror" - "github.com/remeh/sizedwaitgroup" "github.com/verifa/terraplate/parser" ) -type terraCmd string - -func (t terraCmd) Action() string { - switch t { - case terraValidate: - return "validating" - case terraInit: - return "initializing" - case terraPlan: - return "planning" - case terraApply: - return "applying" - case terraShowPlan: - return "summarizing" - } - return "Unknown action" -} - const ( - terraExe = "terraform" - terraValidate terraCmd = "validate" - terraInit terraCmd = "init" - terraPlan terraCmd = "plan" - terraApply terraCmd = "apply" - terraShowPlan terraCmd = "show" - DefaultJobs = 4 + maxRunQueue = 200 ) +func Run(config *parser.TerraConfig, opts ...func(r *TerraRunOpts)) *Runner { + runner := New(config, opts...) + runner.RunAll() + return runner +} + func New(config *parser.TerraConfig, opts ...func(r *TerraRunOpts)) *Runner { - // Initialise TerraRunOpts with defaults - runOpts := TerraRunOpts{ - jobs: DefaultJobs, - } - for _, opt := range opts { - opt(&runOpts) - } + runOpts := NewOpts(opts...) + + // Create the runQueue which is used by the workers to schedule runs + runQueue := make(chan *TerraRun, maxRunQueue) runner := Runner{ - ctx: listenTerminateSignals(), - swg: sizedwaitgroup.New(runOpts.jobs), - opts: runOpts, - config: config, + ctx: listenTerminateSignals(runQueue), + runQueue: runQueue, + config: config, + Opts: runOpts, + } + // Initialize the workers in separate go routines + for workerID := 0; workerID < runOpts.jobs; workerID++ { + go runner.startWorker(workerID) } // Initialize result var ( - rootMods = config.RootModules() - runs = make([]*TerraRun, len(rootMods)) + // Get only root module Terrafiles + tfs = config.RootModules() + modules = make([]*RootModule, len(tfs)) ) - for index, tf := range config.RootModules() { - runs[index] = &TerraRun{ - Terrafile: tf, - } + for index, tf := range tfs { + modules[index] = newRootModule(tf, runOpts) } - runner.Runs = runs + runner.Modules = modules return &runner } type Runner struct { - opts TerraRunOpts - ctx context.Context - swg sizedwaitgroup.SizedWaitGroup + ctx context.Context + // runQueue is a channel for managing the run queue + runQueue chan *TerraRun + wg sync.WaitGroup config *parser.TerraConfig - Runs []*TerraRun -} - -func Run(config *parser.TerraConfig, opts ...func(r *TerraRunOpts)) *Runner { - - runner := New(config, opts...) - runner.RunAll() - return runner + Opts TerraRunOpts + Modules []*RootModule } -func Jobs(jobs int) func(r *TerraRunOpts) { - return func(r *TerraRunOpts) { - r.jobs = jobs - } +func (r *Runner) WorkingDirectory() string { + return r.config.WorkingDirectory } -func RunValidate() func(r *TerraRunOpts) { - return func(r *TerraRunOpts) { - r.validate = true - } -} - -func RunInit() func(r *TerraRunOpts) { - return func(r *TerraRunOpts) { - r.init = true - } +func (r *Runner) RunAll() { + r.Run(r.Modules) } -func RunPlan() func(r *TerraRunOpts) { - return func(r *TerraRunOpts) { - r.plan = true - } +func (r *Runner) Run(modules []*RootModule) { + r.Start(modules) + r.Wait() } -func RunShowPlan() func(r *TerraRunOpts) { - return func(r *TerraRunOpts) { - r.showPlan = true - } +func (r *Runner) Start(modules []*RootModule) { + r.StartWithOpts(modules, r.Opts) } -func RunApply() func(r *TerraRunOpts) { - return func(r *TerraRunOpts) { - r.apply = true +func (r *Runner) StartWithOpts(modules []*RootModule, opts TerraRunOpts) { + for _, mod := range modules { + // Check that the run is not in progress + if runErr := mod.ScheduleRunWithOpts(r.runQueue, opts); runErr != nil { + continue + } + // If run was scheduled, add to waitgroup + r.wg.Add(1) } } -func ExtraArgs(extraArgs []string) func(r *TerraRunOpts) { - return func(r *TerraRunOpts) { - r.extraArgs = extraArgs - } +func (r *Runner) Wait() { + r.wg.Wait() } -// TerraRunOpts handles running Terraform over the root modules -type TerraRunOpts struct { - validate bool - init bool - plan bool - showPlan bool - apply bool - - // Max number of concurrent jobs allowed - jobs int - // Terraform command flags - extraArgs []string -} - -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 +// Runs returns the list of latest runs for the root modules +func (r *Runner) Runs() []*TerraRun { + var runs = make([]*TerraRun, 0) + for _, mod := range r.Modules { + if mod.Run != nil { + runs = append(runs, mod.Run) } - - // Set local run for goroutine - run := run - go func() { - defer r.swg.Done() - run.Run(r.opts) - }() } - r.swg.Wait() + return runs } // Log returns a string of the runs and tasks to print to the console @@ -169,20 +116,14 @@ func (r *Runner) Log() string { hasRelevantRuns bool ) summary.WriteString(textSeparator) - for _, run := range r.Runs { + 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()) - } - } + summary.WriteString(run.Log(false)) } // If there were no runs to output, return an empty string to avoid printing // separators and empty space @@ -193,19 +134,19 @@ func (r *Runner) Log() string { return summary.String() } -// PlanSummary returns a string summary to show after a plan -func (r *Runner) PlanSummary() string { +// Summary returns a string summary to show after a plan +func (r *Runner) Summary() 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())) + summary.WriteString(boldColor.Sprint("\nTerraplate Summary\n\n")) + for _, run := range r.Runs() { + summary.WriteString(fmt.Sprintf("%s: %s\n", run.Terrafile.Dir, run.Summary())) } return summary.String() } func (r *Runner) RunsWithDrift() []*TerraRun { var runs []*TerraRun - for _, run := range r.Runs { + for _, run := range r.Runs() { if run.Drift().HasDrift() { runs = append(runs, run) } @@ -215,7 +156,7 @@ func (r *Runner) RunsWithDrift() []*TerraRun { func (r *Runner) RunsWithError() []*TerraRun { var runs []*TerraRun - for _, run := range r.Runs { + for _, run := range r.Runs() { if run.HasError() { runs = append(runs, run) } @@ -225,7 +166,7 @@ func (r *Runner) RunsWithError() []*TerraRun { // HasDrift returns true if any drift was detected in any of the runs func (r *Runner) HasDrift() bool { - for _, run := range r.Runs { + 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() { @@ -237,7 +178,7 @@ func (r *Runner) HasDrift() bool { } func (r *Runner) HasError() bool { - for _, run := range r.Runs { + for _, run := range r.Runs() { if run.HasError() { return true } @@ -248,10 +189,32 @@ func (r *Runner) HasError() bool { // 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 { + for _, run := range r.Runs() { if run.HasError() { err = multierror.Append(err, run.Errors()...) } } return err } + +// 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(runQueue chan *TerraRun) 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.") + fmt.Println("") + // Cancel the context and stop any more runs from being executed + cancel() + close(runQueue) + } + }() + return ctx +} diff --git a/runner/task.go b/runner/task.go index 3103cf0..7131971 100644 --- a/runner/task.go +++ b/runner/task.go @@ -6,13 +6,16 @@ import ( "fmt" "os/exec" "strings" + + "golang.org/x/text/cases" + "golang.org/x/text/language" ) type TaskResult struct { ExecCmd *exec.Cmd TerraCmd terraCmd - Output []byte + Output bytes.Buffer Error error Skipped bool } @@ -21,11 +24,11 @@ func (t *TaskResult) HasError() bool { return t.Error != nil } -// HasRelevance is an attempt at better UX. +// IsRelevant 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 { +func (t *TaskResult) IsRelevant() bool { // Errors are always relevant if t.HasError() { return true @@ -48,13 +51,28 @@ func (t *TaskResult) HasRelevance() bool { } func (t *TaskResult) Log() string { - var summary strings.Builder + var ( + summary strings.Builder + tmp bytes.Buffer + caser = cases.Title(language.English) + ) + + // Make a copy of the output bytes as the scanner below will Read the io + // and therefore "empty" it, and we don't want to empty the output bytes + if _, err := tmp.Write(t.Output.Bytes()); err != nil { + return "Error: writing task output to temporary buffer" + } - summary.WriteString(fmt.Sprintf("%s output: %s\n\n", strings.Title(string(t.TerraCmd)), t.ExecCmd.String())) + switch t.TerraCmd { + case terraBuild: + summary.WriteString("Build output:\n\n") + default: + summary.WriteString(fmt.Sprintf("%s output: %s\n\n", caser.String(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)) + scanner := bufio.NewScanner(&tmp) for scanner.Scan() { summary.WriteString(fmt.Sprintf(" %s\n", scanner.Text())) } diff --git a/runner/test.sh b/runner/test.sh new file mode 100644 index 0000000..a6fc18f --- /dev/null +++ b/runner/test.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +for i in {1..3}; do + echo "step " $i + sleep 1s + echo >&2 "stderr!!" +done diff --git a/runner/worker.go b/runner/worker.go new file mode 100644 index 0000000..5dbbf01 --- /dev/null +++ b/runner/worker.go @@ -0,0 +1,18 @@ +package runner + +import ( + "context" + "errors" +) + +func (r *Runner) startWorker(workerID int) { + for run := range r.runQueue { + // Check if context has been cancelled + if errors.Is(r.ctx.Err(), context.Canceled) { + run.Cancelled = true + } else { + run.Run() + } + r.wg.Done() + } +} diff --git a/tui/entryui/commands.go b/tui/entryui/commands.go new file mode 100644 index 0000000..19294a4 --- /dev/null +++ b/tui/entryui/commands.go @@ -0,0 +1,47 @@ +package entryui + +import ( + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/verifa/terraplate/runner" +) + +type BackMsg struct{} + +func backToListMsg() tea.Cmd { + return func() tea.Msg { + return BackMsg{} + } +} + +type runFinished struct{} + +type runInProgress struct{} + +func RunInProgressCmd(module *runner.RootModule) tea.Cmd { + + var cmds = make([]tea.Cmd, 0) + if module.IsRunning() { + cmds = append(cmds, tickRunInProgress(module)) + } + + cmds = append(cmds, func() tea.Msg { + module.Wait() + return runFinished{} + }) + + return tea.Batch(cmds...) +} + +func tickRunInProgress(module *runner.RootModule) tea.Cmd { + return func() tea.Msg { + time.Sleep(500 * time.Millisecond) + return runInProgress{} + } +} + +func runModuleCmd(r *runner.Runner, module *runner.RootModule) tea.Cmd { + r.Start([]*runner.RootModule{module}) + return RunInProgressCmd(module) +} diff --git a/tui/entryui/keys.go b/tui/entryui/keys.go new file mode 100644 index 0000000..76ba44d --- /dev/null +++ b/tui/entryui/keys.go @@ -0,0 +1,43 @@ +package entryui + +import "github.com/charmbracelet/bubbles/key" + +var keys = keymap{ + prevSection: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("/h", "previous section"), + ), + nextSection: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("/l", "next section"), + ), + back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back to list"), + ), + run: key.NewBinding( + key.WithKeys(" "), + key.WithHelp("␣", "run selected"), + ), +} + +type keymap struct { + back key.Binding + nextSection key.Binding + prevSection key.Binding + run key.Binding +} + +func (k keymap) ShortHelp() []key.Binding { + return []key.Binding{ + k.back, k.nextSection, k.prevSection, k.run, + } +} + +func (k keymap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + { + k.back, k.nextSection, k.prevSection, k.run, + }, + } +} diff --git a/tui/entryui/model.go b/tui/entryui/model.go new file mode 100644 index 0000000..9300861 --- /dev/null +++ b/tui/entryui/model.go @@ -0,0 +1,197 @@ +package entryui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/verifa/terraplate/runner" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +var _ tea.Model = (*Model)(nil) + +func New(runner *runner.Runner, module *runner.RootModule, windowSize tea.WindowSizeMsg) tea.Model { + + m := Model{ + runner: runner, + activeTask: 0, + help: help.New(), + module: module, + windowSize: windowSize, + } + + m.viewport = viewport.New(0, 0) + m.syncViewportSize() + m.viewport.MouseWheelEnabled = true + m.viewport.SetContent(m.viewportContent()) + + return m +} + +type Model struct { + runner *runner.Runner + activeTask int + viewport viewport.Model + help help.Model + module *runner.RootModule + windowSize tea.WindowSizeMsg +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.windowSize = msg + m.syncViewportSize() + case runInProgress: + if m.module.IsRunning() { + cmd = tickRunInProgress(m.module) + m.viewport.SetContent(m.viewportContent()) + m.viewport.GotoBottom() + m.syncViewportSize() + } + case runFinished: + m.viewport.SetContent(m.viewportContent()) + m.viewport.GotoTop() + m.syncViewportSize() + case tea.KeyMsg: + switch { + case msg.String() == "q": + cmd = backToListMsg() + case key.Matches(msg, keys.back): + cmd = backToListMsg() + case key.Matches(msg, keys.nextSection): + numTasks := m.numTasks() + if numTasks > 0 { + // We will use the index of this, so it has to be the length minus 1 + numTasks-- + } + m.activeTask = min(m.activeTask+1, numTasks) + m.viewport.SetContent(m.viewportContent()) + m.viewport.GotoTop() + case key.Matches(msg, keys.prevSection): + m.activeTask = max(m.activeTask-1, 0) + m.viewport.SetContent(m.viewportContent()) + m.viewport.GotoTop() + case key.Matches(msg, keys.run): + cmd = runModuleCmd(m.runner, m.module) + m.viewport.SetContent(m.viewportContent()) + m.viewport.GotoTop() + default: + m.viewport, cmd = m.viewport.Update(msg) + } + + case tea.MouseMsg: + m.viewport, cmd = m.viewport.Update(msg) + } + return m, cmd +} + +func (m Model) View() string { + return lipgloss.JoinVertical( + lipgloss.Left, + m.renderHeader(), + m.viewport.View(), + m.renderFooter(), + ) +} + +func (m Model) viewportContent() string { + style := lipgloss.NewStyle().Width(m.viewport.Width) + run := m.module.Run + switch { + case run == nil: + return style.Render("Not run.") + case run.IsRunning(): + return style.Render(run.Log(true)) + case m.numTasks() == 0: + return style.Render("No tasks to show") + } + return style.Render(run.Tasks[m.activeTask].Output.String()) +} + +func (m Model) renderHeader() string { + var ( + run = m.module.Run + doc strings.Builder + tabs []string + caser = cases.Title(language.English) + ) + // Don't render the tabs unless the run is finished + if !m.module.IsRunning() { + tabs = make([]string, m.numTasks()) + for i := 0; i < m.numTasks(); i++ { + task := run.Tasks[i] + if i == m.activeTask { + tabs[i] = activeTab.Render(caser.String(string(task.TerraCmd))) + continue + } + tabs[i] = tab.Render(caser.String(string(task.TerraCmd))) + + } + } + row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...) + gap := tabGap.Render(strings.Repeat(" ", max(0, m.windowSize.Width-lipgloss.Width(row)))) + doc.WriteString(lipgloss.JoinHorizontal(lipgloss.Bottom, row, gap)) + return doc.String() + "\n" +} + +func (m Model) renderFooter() string { + info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) + line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(info))) + status := lipgloss.JoinHorizontal( + lipgloss.Center, + line, + info, + ) + + return lipgloss.JoinVertical( + lipgloss.Left, + status, + m.renderStatus(), + m.help.View(keys), + ) +} + +func (m Model) renderStatus() string { + return "Module: " + m.module.Terrafile.Dir +} + +func (m *Model) syncViewportSize() { + headerHeight := lipgloss.Height(m.renderHeader()) + footerHeight := lipgloss.Height(m.renderFooter()) + + m.viewport.Width = m.windowSize.Width + m.viewport.Height = max(0, m.windowSize.Height-headerHeight-footerHeight) +} + +func (m Model) numTasks() int { + if m.module.Run == nil { + return 0 + } + return len(m.module.Run.Tasks) +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func min(a, b int) int { + if a > b { + return b + } + return a +} diff --git a/tui/entryui/styles.go b/tui/entryui/styles.go new file mode 100644 index 0000000..e83a13e --- /dev/null +++ b/tui/entryui/styles.go @@ -0,0 +1,52 @@ +package entryui + +import "github.com/charmbracelet/lipgloss" + +var ( + highlight = lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"} + + tabBorder = lipgloss.Border{ + Top: "─", + Bottom: "─", + Left: "│", + Right: "│", + TopLeft: "╭", + TopRight: "╮", + BottomLeft: "┴", + BottomRight: "┴", + } + activeTabBorder = lipgloss.Border{ + Top: "─", + Bottom: " ", + Left: "│", + Right: "│", + TopLeft: "╭", + TopRight: "╮", + BottomLeft: "┘", + BottomRight: "└", + } + + tab = lipgloss.NewStyle(). + Border(tabBorder, true). + BorderForeground(highlight). + Padding(0, 1) + + activeTab = tab.Copy().Border(activeTabBorder, true) + + tabGap = tab.Copy(). + BorderTop(false). + BorderLeft(false). + BorderRight(false) + + viewTitleStyle = func() lipgloss.Style { + b := lipgloss.RoundedBorder() + b.Right = "├" + return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) + }() + + infoStyle = func() lipgloss.Style { + b := lipgloss.RoundedBorder() + b.Left = "┤" + return viewTitleStyle.Copy().BorderStyle(b) + }() +) diff --git a/tui/model.go b/tui/model.go new file mode 100644 index 0000000..dc8675b --- /dev/null +++ b/tui/model.go @@ -0,0 +1,73 @@ +package tui + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/verifa/terraplate/runner" + "github.com/verifa/terraplate/tui/entryui" + "github.com/verifa/terraplate/tui/modulesui" +) + +var _ tea.Model = (*MainModel)(nil) + +type MainModel struct { + runner *runner.Runner + + modules tea.Model + entry tea.Model + + state state + windowSize tea.WindowSizeMsg +} + +func New(runner *runner.Runner) MainModel { + return MainModel{ + runner: runner, + state: modulesView, + modules: modulesui.New(runner), + } +} + +func (m MainModel) Init() tea.Cmd { + return nil +} + +func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.windowSize = msg // pass this along to the entry view so it uses the full window size when it's initialized + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + } + case entryui.BackMsg: + m.state = modulesView + case modulesui.SelectMsg: + m.state = entryView + m.entry = entryui.New(m.runner, msg.Module, m.windowSize) + if msg.Module.IsRunning() { + cmds = append(cmds, entryui.RunInProgressCmd(msg.Module)) + } + } + + switch m.state { + case modulesView: + m.modules, cmd = m.modules.Update(msg) + case entryView: + m.entry, cmd = m.entry.Update(msg) + } + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) +} + +func (m MainModel) View() string { + switch m.state { + case modulesView: + return m.modules.View() + case entryView: + return m.entry.View() + } + return "Error: no view selected" +} diff --git a/tui/modulesui/columns.go b/tui/modulesui/columns.go new file mode 100644 index 0000000..e47c229 --- /dev/null +++ b/tui/modulesui/columns.go @@ -0,0 +1,45 @@ +package modulesui + +import "github.com/charmbracelet/lipgloss" + +type tableColumnsMap struct { + status column + rootModule column + summary column +} + +func (c tableColumnsMap) columns() []column { + return []column{ + c.status, c.rootModule, c.summary, + } +} + +var ( + tableColumns = tableColumnsMap{ + status: column{ + header: "Status", + width: 10, + grow: false, + }, + rootModule: column{ + header: "Root Module", + width: 60, + grow: true, + }, + summary: column{ + header: "Summary", + width: 60, + grow: true, + }, + } +) + +type column struct { + header string + width int + grow bool +} + +func (c column) render(style lipgloss.Style, text string) string { + return style.Width(c.width).Render(text) +} diff --git a/tui/modulesui/commands.go b/tui/modulesui/commands.go new file mode 100644 index 0000000..75f58ca --- /dev/null +++ b/tui/modulesui/commands.go @@ -0,0 +1,108 @@ +package modulesui + +import ( + "errors" + "fmt" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/verifa/terraplate/runner" +) + +type SelectMsg struct { + Module *runner.RootModule +} + +func (m Model) selectModuleCmd() tea.Cmd { + return func() tea.Msg { + return SelectMsg{Module: m.activeModule()} + } +} + +type updateModuleMsg struct { + Module *runner.RootModule +} + +func (m Model) runActiveModuleCmd() tea.Cmd { + modules := []*runner.RootModule{ + m.activeModule(), + } + return m.runModulesWithOptsCmd(modules, m.runner.Opts) +} + +func (m Model) runModulesWithOptsCmd(modules []*runner.RootModule, opts runner.TerraRunOpts) tea.Cmd { + m.runner.StartWithOpts(modules, opts) + + var cmds = make([]tea.Cmd, len(modules)) + for _, mod := range modules { + mod := mod + cmds = append(cmds, func() tea.Msg { + mod.Wait() + return updateModuleMsg{Module: mod} + }) + } + + return tea.Batch(cmds...) +} + +func (m Model) inputCmd() (tea.Cmd, error) { + opts, runAll, cmdErr := m.parseInputCmd(m.input.Value()) + if cmdErr != nil { + return nil, cmdErr + } + var modules []*runner.RootModule + if runAll { + modules = m.visibleModules() + } else { + modules = append(modules, m.activeModule()) + } + return m.runModulesWithOptsCmd(modules, opts), nil +} + +func (m Model) parseInputCmd(cmd string) (runner.TerraRunOpts, bool, error) { + var ( + opts []func(*runner.TerraRunOpts) + runAll bool + ) + if len(cmd) == 0 { + return runner.TerraRunOpts{}, runAll, errors.New("command is empty") + } + // Inherit from existing run, e.g. number of jobs and output + opts = append(opts, runner.FromOpts(m.runner.Opts)) + for _, c := range cmd { + switch c { + case 'b': + opts = append(opts, runner.RunBuild()) + case 'i': + opts = append(opts, runner.RunBuild(), runner.RunInit()) + case 'u': + opts = append(opts, runner.RunBuild(), runner.RunInitUpgrade()) + case 'p': + // If we are planning, also run the terraform show command to get + // the JSON output of the plan + opts = append(opts, runner.RunBuild(), runner.RunPlan(), runner.RunShowPlan()) + case 'a': + opts = append(opts, runner.RunApply()) + case 'A': + runAll = true + default: + return runner.TerraRunOpts{}, runAll, fmt.Errorf("unknown command: %c", c) + } + } + runOpts := runner.NewOpts(opts...) + return runOpts, runAll, nil +} + +type statusTimeoutMsg struct { + // uuid uuid.UUID + err error +} + +func statusTimeoutCmd(err error) tea.Cmd { + // uuid := uuid.New() + timer := time.NewTimer(2 * time.Second) + return func() tea.Msg { + <-timer.C + return statusTimeoutMsg{err: err} + } +} diff --git a/tui/modulesui/item.go b/tui/modulesui/item.go new file mode 100644 index 0000000..3f2d6c1 --- /dev/null +++ b/tui/modulesui/item.go @@ -0,0 +1,62 @@ +package modulesui + +import ( + "fmt" + "strings" + + "github.com/verifa/terraplate/runner" +) + +type listItem struct { + module *runner.RootModule +} + +func (i listItem) Title() string { + return i.module.Terrafile.Dir +} + +func (i listItem) Description() string { + return i.renderSummary() +} + +func (i listItem) FilterValue() string { + return i.module.Terrafile.Dir +} + +func (i listItem) renderSummary() string { + var ( + summary strings.Builder + module = i.module + run = module.Run + drift *runner.Drift + ) + if run != nil { + drift = run.Drift() + } + switch { + case module.Skip(): + summary.WriteString("Skip.") + case run == nil: + summary.WriteString("Not run.") + case run.IsRunning(): + summary.WriteString("Running...") + case run.HasError(): + summary.WriteString("Error occurred.") + case run.IsApplied(): + summary.WriteString("Applied.") + case !run.HasPlan(): + summary.WriteString("No plan available.") + case !drift.HasDrift(): + summary.WriteString("No changes.") + default: + // There is a plan, and there is drift, so show a summary + fmt.Fprintf( + &summary, + "+ %d ~ %d - %d", + len(drift.AddResources), + len(drift.ChangeResources), + len(drift.DestroyResources), + ) + } + return summary.String() +} diff --git a/tui/modulesui/keys.go b/tui/modulesui/keys.go new file mode 100644 index 0000000..a5b8ade --- /dev/null +++ b/tui/modulesui/keys.go @@ -0,0 +1,122 @@ +package modulesui + +import "github.com/charmbracelet/bubbles/key" + +var keys = keymap{ + enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ), + colon: key.NewBinding( + key.WithKeys(":"), + key.WithHelp(":", "command mode"), + ), + esc: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back to list"), + ), + run: key.NewBinding( + key.WithKeys(" "), + key.WithHelp("␣", "run selected"), + ), + // runAll: key.NewBinding( + // key.WithKeys("ctrl+r"), + // key.WithHelp("ctrl+r", "run all visible"), + // ), + tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "select section"), + ), + filterDrift: key.NewBinding( + key.WithKeys("D"), + key.WithHelp("D", "filter drift"), + ), + filterError: key.NewBinding( + key.WithKeys("E"), + key.WithHelp("E", "filter error"), + ), + toggleSummary: key.NewBinding( + key.WithKeys("S"), + key.WithHelp("S", "toggle summary"), + ), + togglePagination: key.NewBinding( + key.WithKeys("P"), + key.WithHelp("P", "toggle pagination"), + ), + toggleHelpMenu: key.NewBinding( + key.WithKeys("H"), + key.WithHelp("H", "toggle help"), + ), +} + +type keymap struct { + enter key.Binding + esc key.Binding + run key.Binding + // runAll key.Binding + tab key.Binding + colon key.Binding + filterDrift key.Binding + filterError key.Binding + toggleSummary key.Binding + togglePagination key.Binding + toggleHelpMenu key.Binding +} + +func (k keymap) ShortHelp() []key.Binding { + return []key.Binding{ + k.enter, k.colon, k.run, k.tab, k.filterDrift, + } +} + +func (k keymap) FullHelpKeys() []key.Binding { + return []key.Binding{ + k.enter, k.colon, k.run, k.tab, k.filterDrift, k.filterError, k.toggleSummary, + k.togglePagination, k.toggleHelpMenu, + } +} + +var inputKeys = inputKeyMap{ + build: key.NewBinding( + key.WithKeys("b"), + key.WithHelp("b", "build"), + ), + init: key.NewBinding( + key.WithKeys("i"), + key.WithHelp("i", "init"), + ), + upgrade: key.NewBinding( + key.WithKeys("u"), + key.WithHelp("u", "upgrade"), + ), + plan: key.NewBinding( + key.WithKeys("p"), + key.WithHelp("p", "plan"), + ), + apply: key.NewBinding( + key.WithKeys("a"), + key.WithHelp("a", "apply"), + ), + all: key.NewBinding( + key.WithKeys("A"), + key.WithHelp("A", "all modules"), + ), +} + +type inputKeyMap struct { + build key.Binding + init key.Binding + upgrade key.Binding + plan key.Binding + apply key.Binding + all key.Binding +} + +func (k inputKeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + k.build, k.init, k.upgrade, k.plan, k.apply, k.all, + } +} +func (k inputKeyMap) FullHelp() [][]key.Binding { + return nil +} diff --git a/tui/modulesui/model.go b/tui/modulesui/model.go new file mode 100644 index 0000000..777bcae --- /dev/null +++ b/tui/modulesui/model.go @@ -0,0 +1,487 @@ +package modulesui + +import ( + "fmt" + "strings" + "text/tabwriter" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + tfjson "github.com/hashicorp/terraform-json" + "github.com/verifa/terraplate/runner" +) + +var _ tea.Model = (*Model)(nil) + +func New(runner *runner.Runner) tea.Model { + var m Model + m.state = listView + m.runner = runner + + // Setup list + l := list.New(m.filterItems(), list.NewDefaultDelegate(), 0, 0) + l.Title = "Root Modules" + l.SetShowTitle(true) + l.SetShowStatusBar(false) + l.SetShowHelp(false) + l.AdditionalShortHelpKeys = keys.ShortHelp + l.AdditionalFullHelpKeys = keys.FullHelpKeys + m.list = l + + // Setup viewport + m.viewport = viewport.New(0, 0) + m.viewport.MouseWheelEnabled = true + m.viewport.SetContent(m.viewportContent()) + + m.showSummary = true + m.showHelp = true + m.listBorder = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + m.summaryBorder = lipgloss.NewStyle().Border(lipgloss.NormalBorder()) + m.setActiveViewStyle() + + // Setup textinput for commands + input := textinput.New() + input.Prompt = ":" + m.input = input + + return m +} + +type Model struct { + state state + runner *runner.Runner + list list.Model + viewport viewport.Model + input textinput.Model + statusErr error + + filterDrift bool + filterError bool + showSummary bool + showHelp bool + + listBorder lipgloss.Style + summaryBorder lipgloss.Style + + windowSize tea.WindowSizeMsg +} + +func (m Model) Init() tea.Cmd { + return nil +} +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.windowSize = msg + m.syncViewSizes() + case updateModuleMsg: + // If the selected module is updated, update the viewport + if msg.Module == m.activeModule() { + m.viewport.SetContent(m.viewportContent()) + m.viewport.GotoTop() + } + case statusTimeoutMsg: + if msg.err == m.statusErr { + m.statusErr = nil + } + + case list.FilterMatchesMsg: + cmd = m.handleListUpdate(msg) + case tea.KeyMsg: + if m.list.FilterState() == list.Filtering { + // If we are filtering the list, send all keys to the list + cmd = m.handleListUpdate(msg) + } else if m.input.Focused() { + switch { + case key.Matches(msg, keys.enter): + var cmdErr error + cmd, cmdErr = m.inputCmd() + if cmdErr != nil { + m.statusErr = cmdErr + cmd = statusTimeoutCmd(cmdErr) + } + m.input.Blur() + m.input.Reset() + m.syncViewSizes() + m.setActiveViewStyle() + case key.Matches(msg, keys.esc): + m.input.Blur() + m.input.Reset() + m.syncViewSizes() + m.setActiveViewStyle() + default: + m.input, cmd = m.input.Update(msg) + } + } else { + switch { + case key.Matches(msg, keys.enter): + cmd = m.selectModuleCmd() + case key.Matches(msg, keys.colon): + cmd = m.input.Focus() + m.syncViewSizes() + m.setActiveViewStyle() + case key.Matches(msg, keys.esc): + switch m.state { + case listView: + cmd = m.handleListUpdate(msg) + case summaryView: + m.state = listView + } + m.setActiveViewStyle() + case key.Matches(msg, keys.run): + cmd = m.runActiveModuleCmd() + m.viewport.SetContent(m.viewportContent()) + m.viewport.GotoTop() + case key.Matches(msg, keys.tab): + if m.state == listView { + m.state = summaryView + } else { + m.state = listView + } + m.setActiveViewStyle() + return m, nil + case key.Matches(msg, keys.filterDrift): + m.filterDrift = !m.filterDrift + m.list.SetItems(m.filterItems()) + // Reset the list filter. Slightly strange behaviour if we don't + // do this. + m.list.ResetFilter() + m.viewport.SetContent(m.viewportContent()) + m.viewport.GotoTop() + return m, nil + case key.Matches(msg, keys.filterError): + m.filterError = !m.filterError + m.list.SetItems(m.filterItems()) + // Reset the list filter. Slightly strange behaviour if we don't + // do this. + m.list.ResetFilter() + m.viewport.SetContent(m.viewportContent()) + m.viewport.GotoTop() + return m, nil + case key.Matches(msg, keys.togglePagination): + m.list.SetShowPagination(!m.list.ShowPagination()) + case key.Matches(msg, keys.toggleSummary): + m.showSummary = !m.showSummary + m.state = listView + m.setActiveViewStyle() + m.syncViewSizes() + return m, nil + case key.Matches(msg, keys.toggleHelpMenu): + m.showHelp = !m.showHelp + m.syncViewSizes() + return m, nil + + default: + if key.Matches(msg, m.list.KeyMap.CloseFullHelp, m.list.KeyMap.ShowFullHelp) { + cmd = m.handleListUpdate(msg) + m.syncViewSizes() + break + } + switch m.state { + case listView: + cmd = m.handleListUpdate(msg) + case summaryView: + m.viewport, cmd = m.viewport.Update(msg) + } + } + } + } + return m, cmd +} + +func (m Model) View() string { + + views := []string{ + m.listBorder.Render(m.list.View()), + } + if m.showSummary { + views = append(views, m.summaryBorder.Render(m.viewport.View())) + } + + stacks := []string{ + lipgloss.JoinHorizontal( + lipgloss.Top, + views..., + ), + } + if m.input.Focused() { + stacks = append(stacks, m.input.View()) + } else { + stacks = append(stacks, m.renderStatus()) + } + stacks = append(stacks, m.renderFooter()) + + return lipgloss.JoinVertical( + lipgloss.Left, + stacks..., + ) +} + +func (m *Model) handleListUpdate(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + // Call the list delegate, and check if the selected item has changed. + // If the selected item has changed, update the viewport to show the + // correct summary + prevItem := m.list.SelectedItem() + prevState := m.list.FilterState() + m.list, cmd = m.list.Update(msg) + newItem := m.list.SelectedItem() + newState := m.list.FilterState() + if prevItem != newItem || prevState != newState { + m.viewport.SetContent(m.viewportContent()) + m.viewport.GotoTop() + } + return cmd +} + +func (m Model) filterItems() []list.Item { + var items = make([]list.Item, 0) + for _, mod := range m.runner.Modules { + var ( + run = mod.Run + drift = run.Drift() + ) + if m.filterDrift && !drift.HasDrift() { + continue + } + if m.filterError && !run.HasError() { + continue + } + items = append(items, listItem{ + module: mod, + }) + } + return items +} + +func (m Model) activeModule() *runner.RootModule { + item := m.list.SelectedItem() + if item == nil { + return nil + } + return item.(listItem).module +} + +func (m Model) visibleModules() []*runner.RootModule { + items := m.list.VisibleItems() + modules := make([]*runner.RootModule, len(items)) + for index, item := range items { + modules[index] = item.(listItem).module + } + return modules +} + +func (m *Model) setActiveViewStyle() { + if m.input.Focused() { + m.summaryBorder.BorderForeground(dimmedColor) + m.listBorder.BorderForeground(dimmedColor) + return + } + if m.state == listView { + m.summaryBorder.BorderForeground(dimmedColor) + m.listBorder.UnsetBorderForeground() + } else { + m.listBorder.BorderForeground(dimmedColor) + m.summaryBorder.UnsetBorderForeground() + } + +} + +func (m *Model) syncViewSizes() { + var ( + footerHeight = lipgloss.Height(m.renderFooter()) + statusHeight int + listViewWidth, listViewHeight int + summaryViewWidth, summaryViewHeight int + ) + if m.input.Focused() { + statusHeight = lipgloss.Height(m.input.View()) + } else { + statusHeight = 1 + } + listViewHeight = m.windowSize.Height - footerHeight - statusHeight + summaryViewHeight = listViewHeight + if m.showSummary { + summaryViewWidth = m.windowSize.Width / 2 + listViewWidth = m.windowSize.Width - summaryViewWidth + } else { + listViewWidth = m.windowSize.Width + summaryViewWidth = 0 + } + + m.listBorder.Width(listViewWidth - m.listBorder.GetHorizontalBorderSize()) + m.listBorder.Height(listViewHeight - m.listBorder.GetVerticalBorderSize()) + m.summaryBorder.Width(summaryViewWidth - m.summaryBorder.GetHorizontalBorderSize()) + m.summaryBorder.Height(summaryViewHeight - m.summaryBorder.GetVerticalBorderSize()) + + listWidth := listViewWidth - m.listBorder.GetHorizontalFrameSize() + listHeight := listViewHeight - m.listBorder.GetVerticalFrameSize() + m.list.SetSize(listWidth, listHeight) + + summaryWidth := summaryViewWidth - m.summaryBorder.GetHorizontalFrameSize() + summaryHeight := summaryViewHeight - m.summaryBorder.GetVerticalFrameSize() + m.viewport.Width = summaryWidth + m.viewport.Height = summaryHeight + viewportContentStyle.Width(summaryWidth) +} + +func (m Model) viewportContent() string { + var content string + if m.list.FilterState() == list.Filtering { + content = "Filtering..." + } else { + content = m.renderSummaryContent(m.list.SelectedItem()) + } + return viewportContentStyle.Render(content) +} + +func (m Model) renderStatus() string { + if m.statusErr != nil { + return "Error: " + m.statusErr.Error() + } + return "Base: " + m.runner.WorkingDirectory() +} + +func (m Model) renderFooter() string { + if m.input.Focused() { + return help.New().View(inputKeys) + } + if m.showHelp { + return m.list.Help.View(m.list) + } + return "" +} + +func (m Model) renderSummaryContent(item list.Item) string { + if item == nil { + return "No module selected." + } + + var ( + s strings.Builder + hasDrift bool + noOpResources []*tfjson.ResourceChange + addResources []*tfjson.ResourceChange + changeResources []*tfjson.ResourceChange + replaceResources []*tfjson.ResourceChange + destroyResources []*tfjson.ResourceChange + ) + + module := item.(listItem).module + run := module.Run + // Initialize tabwriter for making pretty tab-indented text + tw := tabwriter.NewWriter(&s, 0, 0, 2, ' ', tabwriter.DiscardEmptyColumns) + + if run.IsRunning() { + fmt.Fprintln(&s, inProgress("Running...")) + fmt.Fprintf(&s, "Run is in progress...") + return s.String() + } + if run.IsApplied() { + fmt.Fprintln(&s, appliedLabel("Applied")) + fmt.Fprintf(&s, "Terraform applied.") + return s.String() + } + if !run.HasPlan() { + fmt.Fprintln(&s, noPlanLabel("No plan")) + fmt.Fprintf(&s, "No plan available.") + return s.String() + } + if run.HasError() { + fmt.Fprintln(&s, errorLabel("Error")) + fmt.Fprintf(&s, "Error running Terraform.") + return s.String() + } + for _, r := range run.Plan.ResourceChanges { + actions := r.Change.Actions + switch { + case actions.NoOp(): + noOpResources = append(noOpResources, r) + case actions.Create(): + hasDrift = true + addResources = append(addResources, r) + case actions.Update(): + hasDrift = true + changeResources = append(changeResources, r) + case actions.Replace(): + hasDrift = true + replaceResources = append(replaceResources, r) + case actions.Delete(): + hasDrift = true + destroyResources = append(destroyResources, r) + } + } + + // Header + if hasDrift { + fmt.Fprintln(&s, driftLabel("Drift detected")) + } else { + fmt.Fprintln(&s, noChangesLabel("No changes")) + } + + // Overview + fmt.Fprintf(&s, "%s\n\n", boldStyle.Render("Overview")) + fmt.Fprintf(tw, "Terraform version\t%s\n", run.Plan.TerraformVersion) + fmt.Fprintf(tw, "Total resources\t%d\n", len(run.Plan.ResourceChanges)) + tw.Flush() + + // Plan summary + fmt.Fprintf(&s, "\n%s\n", boldStyle.Render("Plan summary")) + if !hasDrift { + fmt.Fprintln(&s, "No changes.") + } + if len(addResources) != 0 { + fmt.Fprintf(&s, "\nAdd:\n") + for _, r := range addResources { + fmt.Fprintf(&s, "%s %s\n", addSymbol, r.Address) + } + } + if len(changeResources) != 0 { + fmt.Fprintf(&s, "\nChange:\n") + for _, r := range changeResources { + fmt.Fprintf(&s, "%s %s\n", changeSymbol, r.Address) + } + } + if len(replaceResources) != 0 { + fmt.Fprintf(&s, "\nReplace:\n") + for _, r := range replaceResources { + fmt.Fprintf(&s, "%s %s\n", replaceSymbol, r.Address) + } + } + if len(destroyResources) != 0 { + fmt.Fprintf(&s, "\nDestroy:\n") + for _, r := range destroyResources { + fmt.Fprintf(&s, "%s %s\n", destroySymbol, r.Address) + } + } + + // Modules + fmt.Fprintf(&s, "\n%s\n\n", boldStyle.Render("Modules")) + if run.Plan.Config.RootModule.ModuleCalls == nil { + fmt.Fprintln(&s, "No modules calls.") + } + for name, mc := range run.Plan.Config.RootModule.ModuleCalls { + fmt.Fprintf(tw, "%s\t%s\n", name, mc.Source) + tw.Flush() + } + // Providers + fmt.Fprintf(&s, "\n%s\n\n", boldStyle.Render("Providers")) + for name, config := range run.Plan.Config.ProviderConfigs { + var pName string + if config.Alias != "" { + pName = fmt.Sprintf("%s (%s)", name, config.Alias) + } else { + pName = name + } + fmt.Fprintf(tw, "%s\t%s\n", pName, config.VersionConstraint) + tw.Flush() + } + + return s.String() +} diff --git a/tui/modulesui/state.go b/tui/modulesui/state.go new file mode 100644 index 0000000..cd7f47b --- /dev/null +++ b/tui/modulesui/state.go @@ -0,0 +1,8 @@ +package modulesui + +type state int + +const ( + listView state = iota + summaryView +) diff --git a/tui/modulesui/styles.go b/tui/modulesui/styles.go new file mode 100644 index 0000000..ec95d30 --- /dev/null +++ b/tui/modulesui/styles.go @@ -0,0 +1,45 @@ +package modulesui + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +var ( + labelStyle = lipgloss.NewStyle(). + Padding(0, 1).MarginBottom(1) + appliedLabel = labelStyle.Copy(). + Background(lipgloss.Color(termenv.ANSIGreen.String())). + Render + noChangesLabel = labelStyle.Copy(). + Background(lipgloss.Color(termenv.ANSIGreen.String())). + Render + driftLabel = labelStyle.Copy(). + Background(lipgloss.Color(termenv.ANSIYellow.String())). + Render + noPlanLabel = labelStyle.Copy(). + Background(lipgloss.Color(termenv.ANSICyan.String())). + Render + inProgress = labelStyle.Copy(). + Background(lipgloss.Color(termenv.ANSIMagenta.String())). + Render + errorLabel = labelStyle.Copy(). + Background(lipgloss.Color(termenv.ANSIRed.String())). + Render + + boldStyle = lipgloss.NewStyle().Bold(true) + viewportContentStyle = lipgloss.NewStyle().Padding(0, 2) + + addSymbol = lipgloss.NewStyle(). + Foreground(lipgloss.Color(termenv.ANSIGreen.String())). + Render("+") + changeSymbol = lipgloss.NewStyle(). + Foreground(lipgloss.Color(termenv.ANSIYellow.String())). + Render("~") + destroySymbol = lipgloss.NewStyle(). + Foreground(lipgloss.Color(termenv.ANSIRed.String())). + Render("-") + replaceSymbol = destroySymbol + addSymbol + + dimmedColor = lipgloss.AdaptiveColor{Light: "#A49FA5", Dark: "#777777"} +) diff --git a/tui/state.go b/tui/state.go new file mode 100644 index 0000000..c5b1b58 --- /dev/null +++ b/tui/state.go @@ -0,0 +1,8 @@ +package tui + +type state int + +const ( + modulesView state = iota + entryView +)