From e350fb8fc4f96277784e3e1498e70b956fca3041 Mon Sep 17 00:00:00 2001 From: Thomas De Meyer Date: Fri, 13 Sep 2024 11:53:59 +0200 Subject: [PATCH] feat: added command to locally validate terraform configuration --- .../unreleased/Added-20240913-113045.yaml | 3 + docs/src/reference/cli/mach-composer.md | 1 + .../reference/cli/mach-composer_validate.md | 40 ++++++++++ internal/cmd/root.go | 1 + internal/cmd/testdata/cases/.gitignore | 1 + .../testdata/cases/validate/invalid/main.yaml | 33 ++++++++ .../testdata/cases/validate/valid/main.yaml | 33 ++++++++ internal/cmd/validate.go | 77 +++++++++++++++++++ internal/cmd/validate_test.go | 64 +++++++++++++++ internal/runner/graph.go | 20 ++++- internal/terraform/init.go | 6 +- internal/terraform/validate.go | 12 +++ 12 files changed, 286 insertions(+), 5 deletions(-) create mode 100644 .changes/unreleased/Added-20240913-113045.yaml create mode 100644 docs/src/reference/cli/mach-composer_validate.md create mode 100644 internal/cmd/testdata/cases/validate/invalid/main.yaml create mode 100644 internal/cmd/testdata/cases/validate/valid/main.yaml create mode 100644 internal/cmd/validate.go create mode 100644 internal/cmd/validate_test.go create mode 100644 internal/terraform/validate.go diff --git a/.changes/unreleased/Added-20240913-113045.yaml b/.changes/unreleased/Added-20240913-113045.yaml new file mode 100644 index 00000000..b8e42f3a --- /dev/null +++ b/.changes/unreleased/Added-20240913-113045.yaml @@ -0,0 +1,3 @@ +kind: Added +body: Added command to validate terraform configurations +time: 2024-09-13T11:30:45.83129735+02:00 diff --git a/docs/src/reference/cli/mach-composer.md b/docs/src/reference/cli/mach-composer.md index a78963d0..3b600627 100644 --- a/docs/src/reference/cli/mach-composer.md +++ b/docs/src/reference/cli/mach-composer.md @@ -28,5 +28,6 @@ MACH composer is a framework that you use to orchestrate and extend modern digit * [mach-composer sites](mach-composer_sites.md) - List all sites. * [mach-composer terraform](mach-composer_terraform.md) - Execute terraform commands directly * [mach-composer update](mach-composer_update.md) - Update all (or a given) component. +* [mach-composer validate](mach-composer_validate.md) - Validate the generated terraform configuration. * [mach-composer version](mach-composer_version.md) - Return version information of the mach-composer cli diff --git a/docs/src/reference/cli/mach-composer_validate.md b/docs/src/reference/cli/mach-composer_validate.md new file mode 100644 index 00000000..b90d2c73 --- /dev/null +++ b/docs/src/reference/cli/mach-composer_validate.md @@ -0,0 +1,40 @@ +## mach-composer validate + +Validate the generated terraform configuration. + +### Synopsis + +This command validates the generated terraform configuration. It will check the provided configuration file for any errors, and will run `terraform validate` on the generated configuration. This will check for any syntax errors in the generated configuration without accessing the actual infrastructure. + +By default, the generated configuration is stored in the `validations` directory in the current working directory. This can be changed by providing the `--validation-path` flag. + +See [the terraform validation docs](https://www.terraform.io/docs/commands/validate.html) for more information on `terraform validate`. + +``` +mach-composer validate [flags] +``` + +### Options + +``` + -f, --file string YAML file to parse. (default "main.yml") + -h, --help help for validate + --ignore-version Skip MACH composer version check + --output-path string Outputs path to store the generated files. (default "deployments") + -s, --site string Site to parse. If not set parse all sites. + --validation-path string Directory path to store files required for configuration validation. (default "validations") + --var-file string Use a variable file to parse the configuration with. + -w, --workers int The number of workers to use (default 1) +``` + +### Options inherited from parent commands + +``` + -q, --quiet Quiet output. This is equal to setting log levels to error and higher + -v, --verbose Verbose output. This is equal to setting log levels to debug and higher +``` + +### SEE ALSO + +* [mach-composer](mach-composer.md) - MACH composer is an orchestration tool for modern MACH ecosystems + diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 1422fdb8..5d1e37a4 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -80,4 +80,5 @@ func init() { RootCmd.AddCommand(terraformCmd) RootCmd.AddCommand(versionCmd) RootCmd.AddCommand(graphCmd) + RootCmd.AddCommand(validateCmd) } diff --git a/internal/cmd/testdata/cases/.gitignore b/internal/cmd/testdata/cases/.gitignore index 8c6ecf07..3d55b9e5 100644 --- a/internal/cmd/testdata/cases/.gitignore +++ b/internal/cmd/testdata/cases/.gitignore @@ -1,2 +1,3 @@ **/deployments +**/validations **/states diff --git a/internal/cmd/testdata/cases/validate/invalid/main.yaml b/internal/cmd/testdata/cases/validate/invalid/main.yaml new file mode 100644 index 00000000..ecee6c5a --- /dev/null +++ b/internal/cmd/testdata/cases/validate/invalid/main.yaml @@ -0,0 +1,33 @@ +mach_composer: + version: 1 + deployment: + type: "site-component" + plugins: + aws: + source: mach-composer/aws + version: 0.1.0 + +global: + cloud: "aws" + environment: test + terraform_config: + remote_state: + plugin: local + path: ./states + +sites: + - identifier: test-1 + aws: + account_id: "12345" + region: eu-west-1 + components: + - name: component-1 + +components: + - name: component-1 + source: ./testdata/modules/application + version: "test" + branch: main + integrations: + - aws + diff --git a/internal/cmd/testdata/cases/validate/valid/main.yaml b/internal/cmd/testdata/cases/validate/valid/main.yaml new file mode 100644 index 00000000..b243f23e --- /dev/null +++ b/internal/cmd/testdata/cases/validate/valid/main.yaml @@ -0,0 +1,33 @@ +mach_composer: + version: 1 + plugins: + aws: + source: mach-composer/aws + version: 0.1.0 + +global: + cloud: "" + environment: test + terraform_config: + remote_state: + plugin: local + path: ./states + +sites: + - identifier: test-1 + aws: + account_id: "12345" + region: eu-west-1 + components: + - name: component-1 + variables: + parent_names: [ ] + +components: + - name: component-1 + source: ./testdata/modules/application + version: "test" + branch: main + integrations: + - aws + diff --git a/internal/cmd/validate.go b/internal/cmd/validate.go new file mode 100644 index 00000000..3d4556e3 --- /dev/null +++ b/internal/cmd/validate.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "github.com/mach-composer/mach-composer-cli/internal/batcher" + "github.com/mach-composer/mach-composer-cli/internal/cli" + "github.com/mach-composer/mach-composer-cli/internal/graph" + "github.com/mach-composer/mach-composer-cli/internal/hash" + "github.com/spf13/cobra" + "os" + "path" + "path/filepath" + + "github.com/mach-composer/mach-composer-cli/internal/generator" + "github.com/mach-composer/mach-composer-cli/internal/runner" +) + +var validateFlags struct { + validationPath string +} + +var validateCmd = &cobra.Command{ + Use: "validate", + Short: "Validate the generated terraform configuration.", + Long: "This command validates the generated terraform configuration. It will check the provided configuration file " + + "for any errors, and will run `terraform validate` on the generated configuration. This will check for any " + + "syntax errors in the generated configuration without accessing the actual infrastructure.\n\n" + + "By default, the generated configuration is stored in the `validations` directory in the current " + + "working directory. This can be changed by providing the `--validation-path` flag.\n\n" + + "See [the terraform validation docs](https://www.terraform.io/docs/commands/validate.html) for more " + + "information on `terraform validate`.", + PreRun: func(cmd *cobra.Command, args []string) { + preprocessCommonFlags(cmd) + }, + + RunE: func(cmd *cobra.Command, args []string) error { + return validateFunc(cmd, args) + }, +} + +func init() { + registerCommonFlags(validateCmd) + validateCmd.Flags().StringVarP(&validateFlags.validationPath, "validation-path", "", "validations", + "Directory path to store files required for configuration validation.") + + if path.IsAbs(validateFlags.validationPath) == false { + var err error + value, err := os.Getwd() + if err != nil { + cli.PrintExitError("failed to get current working directory") + } + validateFlags.validationPath = filepath.Join(value, validateFlags.validationPath) + } +} + +func validateFunc(cmd *cobra.Command, _ []string) error { + cfg := loadConfig(cmd, true) + defer cfg.Close() + ctx := cmd.Context() + + dg, err := graph.ToDeploymentGraph(cfg, validateFlags.validationPath) + if err != nil { + return err + } + + err = generator.Write(ctx, cfg, dg, nil) + if err != nil { + return err + } + + r := runner.NewGraphRunner( + batcher.NaiveBatchFunc(), + hash.Factory(cfg), + commonFlags.workers, + ) + + return r.TerraformValidate(ctx, dg) +} diff --git a/internal/cmd/validate_test.go b/internal/cmd/validate_test.go new file mode 100644 index 00000000..8d2d6967 --- /dev/null +++ b/internal/cmd/validate_test.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "os" + "os/exec" + "path" + "testing" +) + +type ValidateTestSuite struct { + suite.Suite + tempDir string +} + +func TestValidateTestSuite(t *testing.T) { + suite.Run(t, new(ValidateTestSuite)) +} + +func (s *ValidateTestSuite) SetupSuite() { + _, err := exec.LookPath("terraform") + if err != nil { + s.T().Fatal("terraform command not found") + } + + tmpDir, _ := os.MkdirTemp("mach-composer", "test") + _ = os.Setenv("TF_PLUGIN_CACHE_DIR", tmpDir) + _ = os.Setenv("TF_PLUGIN_CACHE_MAY_BREAK_DEPENDENCY_LOCK_FILE", "1") + + s.tempDir = tmpDir +} + +func (s *ValidateTestSuite) TearDownSuite() { + _ = os.RemoveAll(s.tempDir) +} + +func (s *ValidateTestSuite) TestValidateInvalid() { + pwd, _ := os.Getwd() + workdir := path.Join(pwd, "testdata/cases/validate/invalid") + + cmd := RootCmd + cmd.SetArgs([]string{ + "validate", + "--file", path.Join(workdir, "main.yaml"), + "--validation-path", path.Join(workdir, "validations"), + }) + err := cmd.Execute() + assert.Error(s.T(), err) +} + +func (s *ValidateTestSuite) TestValidateValid() { + pwd, _ := os.Getwd() + workdir := path.Join(pwd, "testdata/cases/validate/valid") + + cmd := RootCmd + cmd.SetArgs([]string{ + "validate", + "--file", path.Join(workdir, "main.yaml"), + "--validation-path", path.Join(workdir, "validations"), + }) + err := cmd.Execute() + assert.NoError(s.T(), err) +} diff --git a/internal/runner/graph.go b/internal/runner/graph.go index a18e6dbc..90326e4d 100644 --- a/internal/runner/graph.go +++ b/internal/runner/graph.go @@ -101,7 +101,7 @@ func (gr *GraphRunner) TerraformApply(ctx context.Context, dg *graph.Graph, opts if err := gr.run(ctx, dg, func(ctx context.Context, n graph.Node) (string, error) { if !terraformIsInitialized(n.Path()) || opts.ForceInit { log.Info().Msgf("Running terraform init for %s", n.Path()) - if out, err := terraform.Init(ctx, n.Path()); err != nil { + if out, err := terraform.Init(ctx, n.Path(), true); err != nil { return out, err } } else { @@ -126,11 +126,23 @@ func (gr *GraphRunner) TerraformApply(ctx context.Context, dg *graph.Graph, opts return nil } +func (gr *GraphRunner) TerraformValidate(ctx context.Context, dg *graph.Graph) error { + return gr.run(ctx, dg, func(ctx context.Context, n graph.Node) (string, error) { + log.Info().Msgf("Running terraform init without backend for %s", n.Path()) + if out, err := terraform.Init(ctx, n.Path(), false); err != nil { + return out, err + } + + log.Info().Msgf("Running terraform validate for %s", n.Path()) + return terraform.Validate(ctx, n.Path()) + }, true) +} + func (gr *GraphRunner) TerraformPlan(ctx context.Context, dg *graph.Graph, opts *PlanOptions) error { if err := gr.run(ctx, dg, func(ctx context.Context, n graph.Node) (string, error) { if !terraformIsInitialized(n.Path()) || opts.ForceInit { log.Info().Msgf("Running terraform init for %s", n.Path()) - if out, err := terraform.Init(ctx, n.Path()); err != nil { + if out, err := terraform.Init(ctx, n.Path(), true); err != nil { return out, err } } else { @@ -173,7 +185,7 @@ func (gr *GraphRunner) TerraformShow(ctx context.Context, dg *graph.Graph, opts if err := gr.run(ctx, dg, func(ctx context.Context, n graph.Node) (string, error) { if !terraformIsInitialized(n.Path()) || opts.ForceInit { log.Info().Msgf("Running terraform init for %s", n.Path()) - if out, err := terraform.Init(ctx, n.Path()); err != nil { + if out, err := terraform.Init(ctx, n.Path(), true); err != nil { return out, err } } else { @@ -190,7 +202,7 @@ func (gr *GraphRunner) TerraformShow(ctx context.Context, dg *graph.Graph, opts func (gr *GraphRunner) TerraformInit(ctx context.Context, dg *graph.Graph) error { if err := gr.run(ctx, dg, func(ctx context.Context, n graph.Node) (string, error) { - return terraform.Init(ctx, n.Path()) + return terraform.Init(ctx, n.Path(), true) }, true); err != nil { return err } diff --git a/internal/terraform/init.go b/internal/terraform/init.go index 1d57216d..468502ab 100644 --- a/internal/terraform/init.go +++ b/internal/terraform/init.go @@ -5,8 +5,12 @@ import ( "github.com/mach-composer/mach-composer-cli/internal/utils" ) -func Init(ctx context.Context, path string) (string, error) { +func Init(ctx context.Context, path string, withBackend bool) (string, error) { args := []string{"init"} + if !withBackend { + args = append(args, "-backend=false") + } + return utils.RunTerraform(ctx, path, false, args...) } diff --git a/internal/terraform/validate.go b/internal/terraform/validate.go new file mode 100644 index 00000000..8c72662b --- /dev/null +++ b/internal/terraform/validate.go @@ -0,0 +1,12 @@ +package terraform + +import ( + "context" + "github.com/mach-composer/mach-composer-cli/internal/utils" +) + +func Validate(ctx context.Context, path string) (string, error) { + cmd := []string{"validate"} + + return utils.RunTerraform(ctx, path, false, cmd...) +}