From a39cf79dd8095973ed5a0ab6935b7e5933440905 Mon Sep 17 00:00:00 2001 From: Daniel Simmons Date: Wed, 17 Apr 2019 16:36:43 +0200 Subject: [PATCH] Integration tests refactor (#26) This refactors the integration tests to run in-process instead of compiling and executing the binary separately. * Move integration tests to `tests/` directory Integration tests that execute Terraform are now in the top-level directory. * Use functional options pattern This is a breaking change that introduces the functional options pattern for `astro.Project` so we don't need to make breaking changes in the future. * Refactor `cmd` package The CLI application and state is now part of a struct that can be instantiated. Global variables have been removed. This improves testability as well as understanding of what's going on. Code has been cleaned up to be more readable. Since this is a breaking API change we'll bump a version. --- CHANGELOG.md | 10 + astro/astro.go | 23 +- astro/cli/astro/cmd/cmd.go | 291 ++++++++++++++++++ astro/cli/astro/cmd/cmd_test.go | 270 ---------------- astro/cli/astro/cmd/cmds.go | 135 -------- astro/cli/astro/cmd/config.go | 96 +++--- astro/cli/astro/cmd/display.go | 13 +- .../astro.yaml} | 0 .../cli/astro/cmd/fixtures/plan-detach-0.7.13 | 1 - .../cmd/fixtures/plan-success-changes-0.7.13 | 1 - .../fixtures/plan-success-nochanges-0.7.13 | 1 - astro/cli/astro/cmd/flags.go | 105 +++---- astro/cli/astro/cmd/flags_test.go | 69 +++-- astro/cli/astro/cmd/main.go | 103 ------- astro/cli/astro/cmd/options.go | 60 ++++ astro/cli/astro/main.go | 11 +- astro/config.go | 4 +- astro/options.go | 47 +++ astro/tests/base.go | 142 +++++++++ .../fixtures/apply-changes-success/astro.yaml | 0 .../apply-changes-success/terraform.tf | 0 astro/tests/fixtures/flags/merge_values.yaml | 23 ++ astro/tests/fixtures/flags/no_variables.yaml | 8 + .../fixtures/flags/simple_variables.yaml | 18 ++ .../fixtures/plan-detach-0.7.13}/Makefile | 0 .../fixtures/plan-detach-0.7.13}/astro.yaml | 0 .../local-backend/foo.tfstate | 0 .../fixtures/plan-detach-0.7.13}/terraform.tf | 0 .../fixtures/plan-detach-0.7.x}/Makefile | 0 .../fixtures/plan-detach-0.7.x}/astro.yaml | 0 .../local-backend/foo.tfstate | 0 .../fixtures/plan-detach-0.7.x}/terraform.tf | 0 astro/tests/fixtures/plan-detach/Makefile | 4 + astro/tests/fixtures/plan-detach/astro.yaml | 11 + .../plan-detach/local-backend/foo.tfstate | 37 +++ .../fixtures/plan-detach/terraform.tf | 0 .../fixtures/plan-error/astro.yaml | 0 .../fixtures/plan-error/terraform.tf | 0 .../plan-success-changes-0.7.13}/astro.yaml | 0 .../plan-success-changes-0.7.13}/terraform.tf | 0 .../plan-success-changes-0.7.x}/astro.yaml | 0 .../plan-success-changes-0.7.x/terraform.tf | 1 + .../fixtures/plan-success-changes/astro.yaml | 9 + .../plan-success-changes/terraform.tf | 0 .../plan-success-nochanges-0.7.13}/Makefile | 0 .../plan-success-nochanges-0.7.13}/astro.yaml | 0 .../local-backend/foo.tfstate | 0 .../terraform.tf | 1 + .../plan-success-nochanges-0.7.x}/Makefile | 0 .../plan-success-nochanges-0.7.x}/astro.yaml | 0 .../local-backend/foo.tfstate | 0 .../plan-success-nochanges-0.7.x/terraform.tf | 1 + .../fixtures/plan-success-nochanges/Makefile | 4 + .../plan-success-nochanges/astro.yaml | 9 + .../local-backend/foo.tfstate | 37 +++ .../plan-success-nochanges/terraform.tf | 0 astro/tests/integration_test.go | 126 ++++++++ 57 files changed, 981 insertions(+), 690 deletions(-) create mode 100644 astro/cli/astro/cmd/cmd.go delete mode 100644 astro/cli/astro/cmd/cmd_test.go delete mode 100644 astro/cli/astro/cmd/cmds.go rename astro/cli/astro/cmd/fixtures/{flags/simple_variables.yaml => config-simple/astro.yaml} (100%) delete mode 120000 astro/cli/astro/cmd/fixtures/plan-detach-0.7.13 delete mode 120000 astro/cli/astro/cmd/fixtures/plan-success-changes-0.7.13 delete mode 120000 astro/cli/astro/cmd/fixtures/plan-success-nochanges-0.7.13 delete mode 100644 astro/cli/astro/cmd/main.go create mode 100644 astro/cli/astro/cmd/options.go create mode 100644 astro/options.go create mode 100644 astro/tests/base.go rename astro/{cli/astro/cmd => tests}/fixtures/apply-changes-success/astro.yaml (100%) rename astro/{cli/astro/cmd => tests}/fixtures/apply-changes-success/terraform.tf (100%) create mode 100644 astro/tests/fixtures/flags/merge_values.yaml create mode 100644 astro/tests/fixtures/flags/no_variables.yaml create mode 100644 astro/tests/fixtures/flags/simple_variables.yaml rename astro/{cli/astro/cmd/fixtures/plan-detach-0.7.x => tests/fixtures/plan-detach-0.7.13}/Makefile (100%) rename astro/{cli/astro/cmd/fixtures/plan-detach-0.7.x => tests/fixtures/plan-detach-0.7.13}/astro.yaml (100%) rename astro/{cli/astro/cmd/fixtures/plan-detach-0.7.x => tests/fixtures/plan-detach-0.7.13}/local-backend/foo.tfstate (100%) rename astro/{cli/astro/cmd/fixtures/plan-detach-0.7.x => tests/fixtures/plan-detach-0.7.13}/terraform.tf (100%) rename astro/{cli/astro/cmd/fixtures/plan-detach => tests/fixtures/plan-detach-0.7.x}/Makefile (100%) rename astro/{cli/astro/cmd/fixtures/plan-detach => tests/fixtures/plan-detach-0.7.x}/astro.yaml (100%) rename astro/{cli/astro/cmd/fixtures/plan-detach => tests/fixtures/plan-detach-0.7.x}/local-backend/foo.tfstate (100%) rename astro/{cli/astro/cmd/fixtures/plan-success-changes-0.7.x => tests/fixtures/plan-detach-0.7.x}/terraform.tf (100%) create mode 100644 astro/tests/fixtures/plan-detach/Makefile create mode 100644 astro/tests/fixtures/plan-detach/astro.yaml create mode 100644 astro/tests/fixtures/plan-detach/local-backend/foo.tfstate rename astro/{cli/astro/cmd => tests}/fixtures/plan-detach/terraform.tf (100%) rename astro/{cli/astro/cmd => tests}/fixtures/plan-error/astro.yaml (100%) rename astro/{cli/astro/cmd => tests}/fixtures/plan-error/terraform.tf (100%) rename astro/{cli/astro/cmd/fixtures/plan-success-changes-0.7.x => tests/fixtures/plan-success-changes-0.7.13}/astro.yaml (100%) rename astro/{cli/astro/cmd/fixtures/plan-success-nochanges-0.7.x => tests/fixtures/plan-success-changes-0.7.13}/terraform.tf (100%) rename astro/{cli/astro/cmd/fixtures/plan-success-changes => tests/fixtures/plan-success-changes-0.7.x}/astro.yaml (100%) create mode 100644 astro/tests/fixtures/plan-success-changes-0.7.x/terraform.tf create mode 100644 astro/tests/fixtures/plan-success-changes/astro.yaml rename astro/{cli/astro/cmd => tests}/fixtures/plan-success-changes/terraform.tf (100%) rename astro/{cli/astro/cmd/fixtures/plan-success-nochanges-0.7.x => tests/fixtures/plan-success-nochanges-0.7.13}/Makefile (100%) rename astro/{cli/astro/cmd/fixtures/plan-success-nochanges-0.7.x => tests/fixtures/plan-success-nochanges-0.7.13}/astro.yaml (100%) rename astro/{cli/astro/cmd/fixtures/plan-success-nochanges-0.7.x => tests/fixtures/plan-success-nochanges-0.7.13}/local-backend/foo.tfstate (100%) create mode 100644 astro/tests/fixtures/plan-success-nochanges-0.7.13/terraform.tf rename astro/{cli/astro/cmd/fixtures/plan-success-nochanges => tests/fixtures/plan-success-nochanges-0.7.x}/Makefile (100%) rename astro/{cli/astro/cmd/fixtures/plan-success-nochanges => tests/fixtures/plan-success-nochanges-0.7.x}/astro.yaml (100%) rename astro/{cli/astro/cmd/fixtures/plan-success-nochanges => tests/fixtures/plan-success-nochanges-0.7.x}/local-backend/foo.tfstate (100%) create mode 100644 astro/tests/fixtures/plan-success-nochanges-0.7.x/terraform.tf create mode 100644 astro/tests/fixtures/plan-success-nochanges/Makefile create mode 100644 astro/tests/fixtures/plan-success-nochanges/astro.yaml create mode 100644 astro/tests/fixtures/plan-success-nochanges/local-backend/foo.tfstate rename astro/{cli/astro/cmd => tests}/fixtures/plan-success-nochanges/terraform.tf (100%) create mode 100644 astro/tests/integration_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c0e39e..832cc79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.5.0 (UNRELEASED, 2018) +* Adopt options pattern for `astro.NewProject` constructor (#26) +* Refactor and improve integration tests to invoke them directly using cli + rather than `os.exec` (#26) * Add Travis configuration, `make lint` and git precommit hook * Fix `--help` displaying "pflag: help requested" (#1) * Fix issue with make not recompiling when source files changed @@ -23,6 +26,13 @@ called "flags". See the "Remapping flags" section of the README for more information. +* API: The signature of `astro.NewProject` has changed to now accept a list of + functional options. This allows us to add new options in the future without + making a breaking change. + + `astro.NewProject(conf)` should be changed to: + `astro.NewProject(astro.WithConfig(conf))` + ## 0.4.1 (October 3, 2018) * Output policy changes in unified diff format (#2) diff --git a/astro/astro.go b/astro/astro.go index fe526fd..66f0743 100644 --- a/astro/astro.go +++ b/astro/astro.go @@ -45,37 +45,34 @@ type Project struct { } // NewProject returns a new instance of Project. -func NewProject(config conf.Project) (*Project, error) { +func NewProject(opts ...Option) (*Project, error) { + project := &Project{} + logger.Trace.Println("astro: initializing") - project := &Project{} + if err := project.applyOptions(opts...); err != nil { + return nil, err + } versionRepo, err := tvm.NewVersionRepoForCurrentSystem("") if err != nil { return nil, fmt.Errorf("failed to initialize tvm: %v", err) } + project.terraformVersions = versionRepo - sessionRepoPath := filepath.Join(config.SessionRepoDir, ".astro") + sessionRepoPath := filepath.Join(project.config.SessionRepoDir, ".astro") sessions, err := NewSessionRepo(project, sessionRepoPath, utils.ULIDString) if err != nil { return nil, fmt.Errorf("failed to initialize session repository: %v", err) } - - project.config = &config project.sessions = sessions - project.terraformVersions = versionRepo - - // validate config - if errs := project.config.Validate(); errs != nil { - return nil, errs - } // check dependency graph is all good if _, err := project.executions(NoExecutionParameters()).graph(); err != nil { return nil, err } - if config.Hooks.Startup == nil { + if project.config.Hooks.Startup == nil { return project, nil } @@ -83,7 +80,7 @@ func NewProject(config conf.Project) (*Project, error) { if err != nil { return nil, err } - for _, hook := range config.Hooks.Startup { + for _, hook := range project.config.Hooks.Startup { if err := runCommandkAndSetEnvironment(session.path, hook); err != nil { return nil, fmt.Errorf("error running Startup hook: %v", err) } diff --git a/astro/cli/astro/cmd/cmd.go b/astro/cli/astro/cmd/cmd.go new file mode 100644 index 0000000..44a286b --- /dev/null +++ b/astro/cli/astro/cmd/cmd.go @@ -0,0 +1,291 @@ +/* + * Copyright (c) 2018 Uber Technologies, Inc. + * + * 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 contains the source for the `astro` command line tool +// that operators use to interact with the project. +package cmd + +import ( + "errors" + "fmt" + "io" + "io/ioutil" + "log" + "os" + "strings" + + "github.com/uber/astro/astro" + "github.com/uber/astro/astro/conf" + "github.com/uber/astro/astro/logger" + + "github.com/spf13/cobra" +) + +func init() { + // silence trace info from terraform/dag by default + log.SetOutput(ioutil.Discard) +} + +// AstroCLI is the main CLI program, where flags and state are stored for the +// running program. +type AstroCLI struct { + stdin io.Reader + stdout io.Writer + stderr io.Writer + + project *astro.Project + config *conf.Project + + // these values are filled in based on runtime flags + flags struct { + detach bool + moduleNamesString string + trace bool + userCfgFile string + verbose bool + + // projectFlags are special in that the actual flags are dynamic, based + // on the astro project configuration loaded. + projectFlags []*projectFlag + } + + commands struct { + root *cobra.Command + plan *cobra.Command + apply *cobra.Command + } +} + +// NewAstroCLI creates a new AstroCLI. +func NewAstroCLI(opts ...Option) (*AstroCLI, error) { + cli := &AstroCLI{ + stdin: os.Stdin, + stdout: os.Stdout, + stderr: os.Stderr, + } + + if err := cli.applyOptions(opts...); err != nil { + return nil, err + } + + // Set up Cobra commands and structure + cli.createRootCommand() + cli.createPlanCmd() + cli.createApplyCmd() + + cli.commands.root.AddCommand( + cli.commands.plan, + cli.commands.apply, + ) + + // Set trace. Note, this will turn tracing on for all instances of astro + // running in the same process, as the logger is a singleton. This should + // only be of concern during testing. + cobra.OnInitialize(func() { + if cli.flags.trace { + logger.Trace.SetOutput(cli.stderr) + log.SetOutput(cli.stderr) + } + }) + + return cli, nil +} + +// Run is the main entry point into the CLI program. +func (cli *AstroCLI) Run(args []string) (exitCode int) { + cli.commands.root.SetArgs(args) + cli.commands.root.SetOutput(cli.stderr) + + userProvidedConfigPath, err := configPathFromArgs(args) + if err != nil { + fmt.Fprintln(cli.stderr, err.Error()) + return 1 + } + + configFilePath := firstExistingFilePath( + append([]string{userProvidedConfigPath}, configFileSearchPaths...)..., + ) + + if configFilePath != "" { + config, err := astro.NewConfigFromFile(configFilePath) + if err != nil { + fmt.Fprintln(cli.stderr, err.Error()) + return 1 + } + + cli.config = config + } + + cli.configureDynamicUserFlags() + + if err := cli.commands.root.Execute(); err != nil { + fmt.Fprintln(cli.stderr, err.Error()) + exitCode = 1 // exit with error + + // If we get an unknown flag, it could be because the user expected + // config to be loaded but it wasn't. Display a message to the user to + // let them know. + if cli.config == nil && strings.Contains(err.Error(), "unknown flag") { + fmt.Fprintln(cli.stderr, "NOTE: No astro config was loaded.") + } + } + + return exitCode +} + +// configureDynamicUserFlags dynamically adds Cobra flags based on the loaded +// configuration. +func (cli *AstroCLI) configureDynamicUserFlags() { + projectFlags := flagsFromConfig(cli.config) + addProjectFlagsToCommands(projectFlags, + cli.commands.plan, + cli.commands.apply, + ) + cli.flags.projectFlags = projectFlags +} + +func (cli *AstroCLI) createRootCommand() { + rootCmd := &cobra.Command{ + Use: "astro", + Short: "A tool for managing multiple Terraform modules.", + SilenceUsage: true, + SilenceErrors: true, + PersistentPreRunE: cli.preRun, + } + + rootCmd.PersistentFlags().BoolVarP(&cli.flags.verbose, "verbose", "v", false, "verbose output") + rootCmd.PersistentFlags().BoolVarP(&cli.flags.trace, "trace", "", false, "trace output") + rootCmd.PersistentFlags().StringVar(&cli.flags.userCfgFile, "config", "", "config file") + + cli.commands.root = rootCmd +} + +func (cli *AstroCLI) createApplyCmd() { + applyCmd := &cobra.Command{ + Use: "apply [flags] [-- [Terraform argument]...]", + DisableFlagsInUseLine: true, + Short: "Run Terraform apply on all modules", + RunE: cli.runApply, + } + + applyCmd.PersistentFlags().StringVar(&cli.flags.moduleNamesString, "modules", "", "list of modules to apply") + + cli.commands.apply = applyCmd +} + +func (cli *AstroCLI) createPlanCmd() { + planCmd := &cobra.Command{ + Use: "plan [flags] [-- [Terraform argument]...]", + DisableFlagsInUseLine: true, + Short: "Generate execution plans for modules", + RunE: cli.runPlan, + } + + planCmd.PersistentFlags().BoolVar(&cli.flags.detach, "detach", false, "disconnect remote state before planning") + planCmd.PersistentFlags().StringVar(&cli.flags.moduleNamesString, "modules", "", "list of modules to plan") + + cli.commands.plan = planCmd +} + +func (cli *AstroCLI) preRun(cmd *cobra.Command, args []string) error { + logger.Trace.Println("cli: in preRun") + + // Load astro from config + project, err := astro.NewProject(astro.WithConfig(*cli.config)) + if err != nil { + return err + } + cli.project = project + + return nil +} + +// processError interprets certain astro errors and embellishes them for +// display on the CLI. +func (cli *AstroCLI) processError(err error) error { + switch e := err.(type) { + case astro.MissingRequiredVarsError: + // reverse map variables to CLI flags + return fmt.Errorf("missing required flags: %s", strings.Join(cli.varsToFlagNames(e.MissingVars()), ", ")) + default: + return err + } +} + +func (cli *AstroCLI) runApply(cmd *cobra.Command, args []string) error { + vars := flagsToUserVariables(cli.flags.projectFlags) + + var moduleNames []string + if cli.flags.moduleNamesString != "" { + moduleNames = strings.Split(cli.flags.moduleNamesString, ",") + } + + status, results, err := cli.project.Apply( + astro.ApplyExecutionParameters{ + ExecutionParameters: astro.ExecutionParameters{ + ModuleNames: moduleNames, + UserVars: vars, + TerraformParameters: args, + }, + }, + ) + if err != nil { + return fmt.Errorf("ERROR: %v", cli.processError(err)) + } + + err = cli.printExecStatus(status, results) + if err != nil { + return fmt.Errorf("Done; there were errors; some modules may not have been applied") + } + + fmt.Fprintln(cli.stdout, "Done") + + return nil +} + +func (cli *AstroCLI) runPlan(cmd *cobra.Command, args []string) error { + logger.Trace.Printf("cli: plan args: %s\n", args) + + vars := flagsToUserVariables(cli.flags.projectFlags) + + var moduleNames []string + if cli.flags.moduleNamesString != "" { + moduleNames = strings.Split(cli.flags.moduleNamesString, ",") + } + + status, results, err := cli.project.Plan( + astro.PlanExecutionParameters{ + ExecutionParameters: astro.ExecutionParameters{ + ModuleNames: moduleNames, + UserVars: vars, + TerraformParameters: args, + }, + Detach: cli.flags.detach, + }, + ) + if err != nil { + return fmt.Errorf("ERROR: %v", cli.processError(err)) + } + + err = cli.printExecStatus(status, results) + if err != nil { + return errors.New("Done; there were errors") + } + + fmt.Fprintln(cli.stdout, "Done") + + return nil +} diff --git a/astro/cli/astro/cmd/cmd_test.go b/astro/cli/astro/cmd/cmd_test.go deleted file mode 100644 index a52b5c4..0000000 --- a/astro/cli/astro/cmd/cmd_test.go +++ /dev/null @@ -1,270 +0,0 @@ -/* - * Copyright (c) 2018 Uber Technologies, Inc. - * - * 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_test - -import ( - "bytes" - "errors" - "fmt" - "io/ioutil" - "os" - "os/exec" - "path/filepath" - "regexp" - "testing" - - "github.com/uber/astro/astro/tvm" - "github.com/uber/astro/astro/utils" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var ( - // Add Terraform versions here to run the tests against those - // versions. - terraformVersionsToTest = []string{ - "0.7.13", - "0.8.8", - "0.9.11", - "0.10.8", - "0.11.5", - } -) - -var ( - // During setup, this is set to the path of the compiled astro - // binary so tests can execute it. - astroBinary string - - // During setup, this is initialized with a Terraform version - // repository so multiple versions of Terraform can be tested. - terraformVersionRepo *tvm.VersionRepo -) - -const VERSION_LATEST = "" - -// compiles the astro binary and returns the path to it. -func compileAstro() (string, error) { - f, err := ioutil.TempFile("", "") - if err != nil { - return "", err - } - - out, err := exec.Command("go", "build", "-o", f.Name(), "..").CombinedOutput() - if err != nil { - return "", errors.New(string(out)) - } - - return f.Name(), nil -} - -func TestMain(m *testing.M) { - // compile the astro binary so we can execute it during tests - binary, err := compileAstro() - if err != nil { - fmt.Fprint(os.Stderr, err) - os.Exit(1) - } - astroBinary = binary - - // Initialize Terraform version repository - terraformVersionRepo, err = tvm.NewVersionRepoForCurrentSystem("") - if err != nil { - fmt.Fprint(os.Stderr, err) - os.Exit(1) - } - - // Download Terraform versions first so that multiple tests don't - // try to do it in parallel. - for _, version := range terraformVersionsToTest { - if _, err := terraformVersionRepo.Get(version); err != nil { - fmt.Fprint(os.Stderr, err) - os.Exit(1) - } - } - - os.Exit(m.Run()) -} - -type testResult struct { - Stdout *bytes.Buffer - Stderr *bytes.Buffer - Err error - Version string -} - -func runTest(t *testing.T, args []string, fixtureBasePath string, version string) *testResult { - fixturePath := fixtureBasePath - - if version == VERSION_LATEST { - version = terraformVersionsToTest[len(terraformVersionsToTest)-1] - } - - // Determine if this version has a version-specific fixture. - versionSpecificFixturePath := fmt.Sprintf("%s-%s", fixtureBasePath, version) - if utils.FileExists(versionSpecificFixturePath) { - fixturePath = versionSpecificFixturePath - } - - // If there is a Makefile in the fixture, run make to set up any prereqs - // for the test. - if utils.FileExists(filepath.Join(fixturePath, "Makefile")) { - make := exec.Command("make") - make.Dir = fixturePath - out, err := make.CombinedOutput() - if err != nil { - fmt.Fprint(os.Stderr, string(out)) - } - require.NoError(t, err) - } - - // Get Terraform path - terraformBinaryPath, err := terraformVersionRepo.Get(version) - require.NoError(t, err) - - // Override Terraform path - terraformBinaryDir := filepath.Dir(terraformBinaryPath) - - // TODO: this blocks us from running multiple tests in parallel. - // Need a better way to override the version externally. - oldPath := os.Getenv("PATH") - os.Setenv("PATH", fmt.Sprintf("%s:%s", terraformBinaryDir, oldPath)) - defer os.Setenv("PATH", oldPath) - - cmd := exec.Command(astroBinary, args...) - cmd.Dir = fixturePath - - stdoutBytes := &bytes.Buffer{} - stderrBytes := &bytes.Buffer{} - - cmd.Stdout = stdoutBytes - cmd.Stderr = stderrBytes - - cmdErr := cmd.Run() - - return &testResult{ - Err: cmdErr, - Stdout: stdoutBytes, - Stderr: stderrBytes, - Version: version, - } -} - -// getSessionDirs returns a list of the sessions inside a session repository. -// This excludes other directories that might have been created in there, e.g. -// the shared plugin cache directory. -func getSessionDirs(sessionBaseDir string) ([]string, error) { - sessionRegexp, err := regexp.Compile("[0-9A-Z]{26}") - if err != nil { - return nil, err - } - - dirs, err := ioutil.ReadDir(sessionBaseDir) - if err != nil { - return nil, err - } - - sessionDirs := []string{} - - for _, dir := range dirs { - if sessionRegexp.MatchString(dir.Name()) { - sessionDirs = append(sessionDirs, dir.Name()) - } - } - - return sessionDirs, nil -} - -func TestHelpWorks(t *testing.T) { - result := runTest(t, []string{"--help"}, "", VERSION_LATEST) - assert.Contains(t, "A tool for managing multiple Terraform modules", result.Stderr.String()) - assert.NoError(t, result.Err) -} - -func TestProjectApplyChangesSuccess(t *testing.T) { - for _, version := range terraformVersionsToTest { - t.Run(version, func(t *testing.T) { - err := os.RemoveAll("/tmp/terraform-tests/apply-changes-success") - require.NoError(t, err) - - err = os.MkdirAll("/tmp/terraform-tests/apply-changes-success", 0775) - require.NoError(t, err) - - result := runTest(t, []string{"apply"}, "fixtures/apply-changes-success", version) - assert.Contains(t, result.Stdout.String(), "foo: OK") - assert.Empty(t, result.Stderr.String()) - assert.NoError(t, result.Err) - }) - } -} - -func TestProjectPlanSuccessNoChanges(t *testing.T) { - for _, version := range terraformVersionsToTest { - t.Run(version, func(t *testing.T) { - result := runTest(t, []string{"plan", "--trace"}, "fixtures/plan-success-nochanges", version) - assert.Equal(t, "foo: \x1b[32mOK\x1b[0m\x1b[37m No changes\x1b[0m\x1b[37m (0s)\x1b[0m\nDone\n", result.Stdout.String()) - assert.NoError(t, result.Err) - }) - } -} - -func TestProjectPlanSuccessChanges(t *testing.T) { - for _, version := range terraformVersionsToTest { - t.Run(version, func(t *testing.T) { - result := runTest(t, []string{"plan"}, "fixtures/plan-success-changes", version) - assert.Contains(t, result.Stdout.String(), "foo: OK Changes") - assert.Regexp(t, `\+.*null_resource.foo`, result.Stdout.String()) - assert.NoError(t, result.Err) - }) - } -} - -func TestProjectPlanError(t *testing.T) { - for _, version := range terraformVersionsToTest { - t.Run(version, func(t *testing.T) { - result := runTest(t, []string{"plan"}, "fixtures/plan-error", version) - assert.Contains(t, result.Stderr.String(), "foo: ERROR") - assert.Contains(t, result.Stderr.String(), "Error parsing") - assert.Error(t, result.Err) - }) - } -} - -func TestProjectPlanDetachSuccess(t *testing.T) { - for _, version := range terraformVersionsToTest { - t.Run(version, func(t *testing.T) { - err := os.RemoveAll("/tmp/terraform-tests/plan-detach") - require.NoError(t, err) - - err = os.MkdirAll("/tmp/terraform-tests/plan-detach", 0775) - require.NoError(t, err) - - result := runTest(t, []string{"plan", "--detach"}, "fixtures/plan-detach", version) - require.Empty(t, result.Stderr.String()) - require.NoError(t, result.Err) - require.Equal(t, "foo: \x1b[32mOK\x1b[0m\x1b[37m No changes\x1b[0m\x1b[37m (0s)\x1b[0m\nDone\n", result.Stdout.String()) - - sessionDirs, err := getSessionDirs("/tmp/terraform-tests/plan-detach/.astro") - require.NoError(t, err) - require.Equal(t, 1, len(sessionDirs), "unable to find session: expect only a single session to have been written, found multiple") - - _, err = os.Stat(filepath.Join("/tmp/terraform-tests/plan-detach/.astro", sessionDirs[0], "foo/sandbox/terraform.tfstate")) - assert.NoError(t, err) - }) - } -} diff --git a/astro/cli/astro/cmd/cmds.go b/astro/cli/astro/cmd/cmds.go deleted file mode 100644 index b2fc89f..0000000 --- a/astro/cli/astro/cmd/cmds.go +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright (c) 2018 Uber Technologies, Inc. - * - * 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 ( - "errors" - "fmt" - "strings" - - "github.com/spf13/cobra" - "github.com/uber/astro/astro" -) - -var ( - detach bool - moduleNamesString string -) - -var applyCmd = &cobra.Command{ - Use: "apply [flags] [-- [Terraform argument]...]", - DisableFlagsInUseLine: true, - Short: "Run Terraform apply on all modules", - RunE: func(cmd *cobra.Command, args []string) error { - c, err := currentProject() - if err != nil { - return err - } - - vars := flagsToUserVariables() - - var moduleNames []string - if moduleNamesString != "" { - moduleNames = strings.Split(moduleNamesString, ",") - } - - status, results, err := c.Apply( - astro.ApplyExecutionParameters{ - ExecutionParameters: astro.ExecutionParameters{ - ModuleNames: moduleNames, - UserVars: vars, - TerraformParameters: args, - }, - }, - ) - if err != nil { - return fmt.Errorf("ERROR: %v", processError(err)) - } - - err = printExecStatus(status, results) - if err != nil { - return fmt.Errorf("Done; there were errors; some modules may not have been applied") - } - - fmt.Println("Done") - - return nil - }, -} - -var planCmd = &cobra.Command{ - Use: "plan [flags] [-- [Terraform argument]...]", - DisableFlagsInUseLine: true, - Short: "Generate execution plans for modules", - RunE: func(cmd *cobra.Command, args []string) error { - c, err := currentProject() - if err != nil { - return err - } - - vars := flagsToUserVariables() - - var moduleNames []string - if moduleNamesString != "" { - moduleNames = strings.Split(moduleNamesString, ",") - } - - status, results, err := c.Plan( - astro.PlanExecutionParameters{ - ExecutionParameters: astro.ExecutionParameters{ - ModuleNames: moduleNames, - UserVars: vars, - TerraformParameters: args, - }, - Detach: detach, - }, - ) - if err != nil { - return fmt.Errorf("ERROR: %v", processError(err)) - } - - err = printExecStatus(status, results) - if err != nil { - return errors.New("Done; there were errors") - } - - fmt.Println("Done") - - return nil - }, -} - -func init() { - applyCmd.PersistentFlags().StringVar(&moduleNamesString, "modules", "", "list of modules to apply") - rootCmd.AddCommand(applyCmd) - - planCmd.PersistentFlags().BoolVar(&detach, "detach", false, "disconnect remote state before planning") - planCmd.PersistentFlags().StringVar(&moduleNamesString, "modules", "", "list of modules to plan") - rootCmd.AddCommand(planCmd) -} - -// processError interprets certain astro errors and embellishes them for -// display on the CLI. -func processError(err error) error { - switch e := err.(type) { - case astro.MissingRequiredVarsError: - // reverse map variables to CLI flags - return fmt.Errorf("missing required flags: %s", strings.Join(varsToFlagNames(e.MissingVars()), ", ")) - default: - return err - } -} diff --git a/astro/cli/astro/cmd/config.go b/astro/cli/astro/cmd/config.go index 56dd4ec..e804bab 100644 --- a/astro/cli/astro/cmd/config.go +++ b/astro/cli/astro/cmd/config.go @@ -17,11 +17,9 @@ package cmd import ( - "errors" "fmt" - "github.com/uber/astro/astro" - "github.com/uber/astro/astro/conf" + "github.com/spf13/cobra" "github.com/uber/astro/astro/utils" ) @@ -34,72 +32,50 @@ var configFileSearchPaths = []string{ "terraform/astro.yml", } -var errCannotFindConfig = errors.New("unable to find config file") - -// Global cache -var ( - _conf *conf.Project - _project *astro.Project -) - -// firstExistingFilePath takes a list of paths and returns the first one -// where a file exists (or symlink to a file). -func firstExistingFilePath(paths ...string) string { - for _, f := range paths { - if utils.FileExists(f) { - return f - } +// configPathFromArgs reads the command line arguments and returns the value of +// the config option. It returns an empty string if there is no path in the +// args. +func configPathFromArgs(args []string) (configFilePath string, err error) { + // this is a special cobra command so that we can parse just the config + // flag early in the program lifecycle. + findConfig := &cobra.Command{ + SilenceUsage: true, + SilenceErrors: true, + FParseErrWhitelist: cobra.FParseErrWhitelist{ + UnknownFlags: true, + }, } - return "" -} -// configFile returns the path of the project config file. -func configFile() (string, error) { - // User provided config file path takes precedence - if userCfgFile != "" { - return userCfgFile, nil - } - - // Try to find the config file - if path := firstExistingFilePath(configFileSearchPaths...); path != "" { - return path, nil + // Strip the help options from args so that the pre-loading of the config + // doesn't fail with pflag.ErrHelp + finalArgs := []string{} + for _, arg := range args { + if arg == "-h" || arg == "--help" || arg == "-help" { + continue + } + finalArgs = append(finalArgs, arg) } - return "", errCannotFindConfig -} - -// currentConfig loads configuration or returns the previously loaded config. -func currentConfig() (*conf.Project, error) { - if _conf != nil { - return _conf, nil + // Do an early first parse of the config flag before the main command, + findConfig.PersistentFlags().StringVar(&configFilePath, "config", "", "config file") + if err := findConfig.ParseFlags(finalArgs); err != nil { + return "", err } - file, err := configFile() - if err != nil { - return nil, err + if configFilePath != "" && !utils.FileExists(configFilePath) { + return "", fmt.Errorf("%v: file does not exist", configFilePath) } - _conf, err = astro.NewConfigFromFile(file) - return _conf, err + return configFilePath, nil } -// currentProject creates a new astro project or returns the previously created -// astro project. -func currentProject() (*astro.Project, error) { - if _project != nil { - return _project, nil - } - - config, err := currentConfig() - if err != nil { - return nil, err - } - c, err := astro.NewProject(*config) - if err != nil { - return nil, fmt.Errorf("unable to load module configuration: %v", err) +// firstExistingFilePath takes a list of paths and returns the first one +// where a file exists (or symlink to a file). +func firstExistingFilePath(paths ...string) string { + for _, f := range paths { + if utils.FileExists(f) { + return f + } } - - _project = c - - return _project, nil + return "" } diff --git a/astro/cli/astro/cmd/display.go b/astro/cli/astro/cmd/display.go index 34555db..f6a3ab8 100644 --- a/astro/cli/astro/cmd/display.go +++ b/astro/cli/astro/cmd/display.go @@ -20,10 +20,8 @@ import ( "fmt" "io" "io/ioutil" - "os" "github.com/uber/astro/astro" - "github.com/uber/astro/astro/logger" "github.com/uber/astro/astro/terraform" "github.com/hashicorp/go-multierror" @@ -32,14 +30,14 @@ import ( // printExecStatus takes channels for status updates and exec results // and prints them on screen as they arrive. -func printExecStatus(status <-chan string, results <-chan *astro.Result) (errors error) { +func (cli *AstroCLI) printExecStatus(status <-chan string, results <-chan *astro.Result) (errors error) { // Print status updates to stdout as they arrive if status != nil { go func() { var out io.Writer - if verbose { - out = os.Stdout + if cli.flags.verbose { + out = cli.stdout } else { out = ioutil.Discard } @@ -52,7 +50,7 @@ func printExecStatus(status <-chan string, results <-chan *astro.Result) (errors for result := range results { var resultType, changesInfo, runtimeInfo string - var out = os.Stdout + var out = cli.stdout // If this was an error, append it to the list of errors to // return. @@ -69,7 +67,7 @@ func printExecStatus(status <-chan string, results <-chan *astro.Result) (errors resultType = aurora.Green("OK").String() } else { resultType = aurora.Red("ERROR").String() - out = os.Stderr + out = cli.stderr } // If this is a plan, show whether it has changes or not @@ -109,7 +107,6 @@ func printExecStatus(status <-chan string, results <-chan *astro.Result) (errors // If there is a stderr, print it if terraformResult != nil { - logger.Trace.Println("cli: printing stderr from terraform result:") fmt.Fprintf(out, terraformResult.Stderr()) } diff --git a/astro/cli/astro/cmd/fixtures/flags/simple_variables.yaml b/astro/cli/astro/cmd/fixtures/config-simple/astro.yaml similarity index 100% rename from astro/cli/astro/cmd/fixtures/flags/simple_variables.yaml rename to astro/cli/astro/cmd/fixtures/config-simple/astro.yaml diff --git a/astro/cli/astro/cmd/fixtures/plan-detach-0.7.13 b/astro/cli/astro/cmd/fixtures/plan-detach-0.7.13 deleted file mode 120000 index cb56bd8..0000000 --- a/astro/cli/astro/cmd/fixtures/plan-detach-0.7.13 +++ /dev/null @@ -1 +0,0 @@ -plan-detach-0.7.x \ No newline at end of file diff --git a/astro/cli/astro/cmd/fixtures/plan-success-changes-0.7.13 b/astro/cli/astro/cmd/fixtures/plan-success-changes-0.7.13 deleted file mode 120000 index 94832de..0000000 --- a/astro/cli/astro/cmd/fixtures/plan-success-changes-0.7.13 +++ /dev/null @@ -1 +0,0 @@ -plan-success-changes-0.7.x \ No newline at end of file diff --git a/astro/cli/astro/cmd/fixtures/plan-success-nochanges-0.7.13 b/astro/cli/astro/cmd/fixtures/plan-success-nochanges-0.7.13 deleted file mode 120000 index 808c43d..0000000 --- a/astro/cli/astro/cmd/fixtures/plan-success-nochanges-0.7.13 +++ /dev/null @@ -1 +0,0 @@ -plan-success-nochanges-0.7.x \ No newline at end of file diff --git a/astro/cli/astro/cmd/flags.go b/astro/cli/astro/cmd/flags.go index 45e2b30..a401c9e 100644 --- a/astro/cli/astro/cmd/flags.go +++ b/astro/cli/astro/cmd/flags.go @@ -18,7 +18,6 @@ package cmd import ( "fmt" - "os" "sort" "strings" @@ -33,11 +32,11 @@ import ( // user flags from the astro project config. const userHelpTemplate = ` User flags: -{{.ProjectFlagsHelp}}` +{{.projectFlagsHelp}}` -// ProjectFlag is a CLI flag that represents a variable from the user's astro +// projectFlag is a CLI flag that represents a variable from the user's astro // project config file. -type ProjectFlag struct { +type projectFlag struct { // Name of the user flag at the command line Name string // Value is the string var the flag value will be put into @@ -52,44 +51,50 @@ type ProjectFlag struct { } // AddToFlagSet adds the flag to the specified flag set. -func (flag *ProjectFlag) AddToFlagSet(flags *pflag.FlagSet) { +func (flag *projectFlag) AddToFlagSet(flags *pflag.FlagSet) { if len(flag.AllowedValues) > 0 { - flags.Var(&StringEnum{Flag: flag}, flag.Name, flag.Description) + flags.Var(&stringEnum{flag: flag}, flag.Name, flag.Description) } else { flags.StringVar(&flag.Value, flag.Name, "", flag.Description) } } -// StringEnum implements pflag.Value interface, to check that the passed-in +// stringEnum implements pflag.Value interface, to check that the passed-in // value is one of the strings in AllowedValues. -type StringEnum struct { - Flag *ProjectFlag +type stringEnum struct { + flag *projectFlag } // String returns the current value -func (s *StringEnum) String() string { - return s.Flag.Value +func (s *stringEnum) String() string { + return s.flag.Value } // Set checks that the passed-in value is only of the allowd values, and // returns an error if it is not -func (s *StringEnum) Set(value string) error { - for _, allowedValue := range s.Flag.AllowedValues { +func (s *stringEnum) Set(value string) error { + for _, allowedValue := range s.flag.AllowedValues { if allowedValue == value { - s.Flag.Value = value + s.flag.Value = value return nil } } - return fmt.Errorf("allowed values: %s", strings.Join(s.Flag.AllowedValues, ", ")) + return fmt.Errorf("allowed values: %s", strings.Join(s.flag.AllowedValues, ", ")) } -func (s *StringEnum) Type() string { +// Type is the type of Value. For more info, see: +// https://godoc.org/github.com/spf13/pflag#Values +func (s *stringEnum) Type() string { return "string" } // addProjectFlagsToCommands adds the user flags to the specified Cobra commands. -func addProjectFlagsToCommands(flags []*ProjectFlag, cmds ...*cobra.Command) { - ProjectFlagSet := flagsToFlagSet(flags) +func addProjectFlagsToCommands(flags []*projectFlag, cmds ...*cobra.Command) { + if len(flags) == 0 { + return + } + + projectFlagSet := flagsToFlagSet(flags) for _, cmd := range cmds { for _, flag := range flags { @@ -99,12 +104,12 @@ func addProjectFlagsToCommands(flags []*ProjectFlag, cmds ...*cobra.Command) { // Update help text for the command to include the user flags helpTmpl := cmd.HelpTemplate() helpTmpl += "\nUser flags:\n" - helpTmpl += ProjectFlagSet.FlagUsages() + helpTmpl += projectFlagSet.FlagUsages() cmd.SetHelpTemplate(helpTmpl) // Mark flag hidden so it doesn't appear in the normal help. We have to - // do this *after* calling FlagUsages above, otherwise the flags don't + // do this *after* calling flagUsages above, otherwise the flags don't // appear in the output. for _, flag := range flags { cmd.Flags().MarkHidden(flag.Name) @@ -112,44 +117,14 @@ func addProjectFlagsToCommands(flags []*ProjectFlag, cmds ...*cobra.Command) { } } -// Load the astro configuration file and read flags from the project config. -func loadProjectFlagsFromConfig() ([]*ProjectFlag, error) { - findConfig := &cobra.Command{ - SilenceUsage: true, - SilenceErrors: true, - FParseErrWhitelist: cobra.FParseErrWhitelist{ - UnknownFlags: true, - }, - } - - // Strip the help options from os.Args so that the pre-loading of the - // config doesn't fail with pflag.ErrHelp - args := []string{} - for _, arg := range os.Args { - if arg == "-h" || arg == "--help" || arg == "-help" { - continue - } - args = append(args, arg) - } - - // Do an early first parse of the config flag before the main command, - findConfig.PersistentFlags().StringVar(&userCfgFile, "config", "", "config file") - if err := findConfig.ParseFlags(args); err != nil { - return nil, err - } - - config, err := currentConfig() - if err != nil { - return nil, err +// flagsFromConfig reads the astro config and returns a list of projectFlags that +// can be used to fill in astro variable values at runtime. +func flagsFromConfig(config *conf.Project) (flags []*projectFlag) { + if config == nil { + return } - return flagsFromConfig(config), nil -} - -// flagsFromConfig reads the astro config and returns a list of ProjectFlags that -// can be used to fill in astro variable values at runtime. -func flagsFromConfig(config *conf.Project) (flags []*ProjectFlag) { - flagMap := map[string]*ProjectFlag{} + flagMap := map[string]*projectFlag{} for _, moduleConf := range config.Modules { for _, variableConf := range moduleConf.Variables { @@ -170,7 +145,7 @@ func flagsFromConfig(config *conf.Project) (flags []*ProjectFlag) { // aggregate values from all variables in the config flag.AllowedValues = uniqueStrings(append(flag.AllowedValues, variableConf.Values...)) } else { - flag := &ProjectFlag{ + flag := &projectFlag{ Name: flagName, Description: flagConf.Description, Variable: variableConf.Name, @@ -192,7 +167,7 @@ func flagsFromConfig(config *conf.Project) (flags []*ProjectFlag) { // Create an astro.UserVariables suitable for passing into ExecutionParameters // from the user flags. -func flagsToUserVariables() *astro.UserVariables { +func flagsToUserVariables(projectFlags []*projectFlag) *astro.UserVariables { values := make(map[string]string) filters := make(map[string]bool) @@ -211,9 +186,9 @@ func flagsToUserVariables() *astro.UserVariables { } } -// Converts a list of ProjectFlags to a pflag.FlagSet. -func flagsToFlagSet(flags []*ProjectFlag) *pflag.FlagSet { - flagSet := pflag.NewFlagSet("ProjectFlags", pflag.ContinueOnError) +// Converts a list of projectFlags to a pflag.flagSet. +func flagsToFlagSet(flags []*projectFlag) *pflag.FlagSet { + flagSet := pflag.NewFlagSet("projectFlags", pflag.ContinueOnError) for _, flag := range flags { flag.AddToFlagSet(flagSet) } @@ -221,17 +196,17 @@ func flagsToFlagSet(flags []*ProjectFlag) *pflag.FlagSet { } // flagName returns the flag name, given a variable name. -func flagName(variableName string) string { - if flag, ok := _conf.Flags[variableName]; ok { +func (cli *AstroCLI) flagName(variableName string) string { + if flag, ok := cli.config.Flags[variableName]; ok { return flag.Name } return variableName } // varsToFlagNames converts a list of variable names to CLI flags. -func varsToFlagNames(variableNames []string) (flagNames []string) { +func (cli *AstroCLI) varsToFlagNames(variableNames []string) (flagNames []string) { for _, v := range variableNames { - flagNames = append(flagNames, fmt.Sprintf("--%s", flagName(v))) + flagNames = append(flagNames, fmt.Sprintf("--%s", cli.flagName(v))) } return flagNames } diff --git a/astro/cli/astro/cmd/flags_test.go b/astro/cli/astro/cmd/flags_test.go index 1bec14a..29f4628 100644 --- a/astro/cli/astro/cmd/flags_test.go +++ b/astro/cli/astro/cmd/flags_test.go @@ -20,52 +20,61 @@ import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/uber/astro/astro/tests" ) +func TestHelpWorks(t *testing.T) { + result := tests.RunTest(t, []string{"--help"}, "fixtures/no-config", tests.VERSION_LATEST) + assert.Contains(t, result.Stderr.String(), "A tool for managing multiple Terraform modules") + assert.Equal(t, 0, result.ExitCode) +} func TestHelpUserFlags(t *testing.T) { - result := runTest(t, []string{ - "--config=simple_variables.yaml", + result := tests.RunTest(t, []string{ "plan", "--help", - }, "fixtures/flags", VERSION_LATEST) - assert.Contains(t, result.Stdout.String(), "User flags:") - assert.Contains(t, result.Stdout.String(), "--foo") - assert.Contains(t, result.Stdout.String(), "--baz") - assert.Contains(t, result.Stdout.String(), "Baz Description") - assert.Contains(t, result.Stdout.String(), "--qux") + }, "fixtures/config-simple", tests.VERSION_LATEST) + assert.Contains(t, result.Stderr.String(), "User flags:") + assert.Contains(t, result.Stderr.String(), "--foo") + assert.Contains(t, result.Stderr.String(), "--baz") + assert.Contains(t, result.Stderr.String(), "Baz Description") + assert.Contains(t, result.Stderr.String(), "--qux") } func TestHelpNoUserFlags(t *testing.T) { - result := runTest(t, []string{ + result := tests.RunTest(t, []string{ "--config=no_variables.yaml", "plan", "--help", - }, "fixtures/flags", VERSION_LATEST) - assert.NotContains(t, result.Stdout.String(), "User flags:") + }, "fixtures/flags", tests.VERSION_LATEST) + assert.NotContains(t, result.Stderr.String(), "User flags:") } -func TestHelpShowsConfigLoadError(t *testing.T) { - result := runTest(t, []string{ +func TestConfigLoadErrorWhenSpecified(t *testing.T) { + result := tests.RunTest(t, []string{ "--config=/nonexistent/path/to/config", "plan", "--help", - }, "fixtures/flags", VERSION_LATEST) - assert.Contains(t, result.Stderr.String(), "There was an error loading astro config") + }, "fixtures/config-simple", tests.VERSION_LATEST) + assert.Contains(t, result.Stderr.String(), "file does not exist") + assert.Equal(t, 1, result.ExitCode) } -func TestHelpDoesntAlwaysShowLoadingError(t *testing.T) { - result := runTest(t, []string{ - "--help", - }, "fixtures/flags", VERSION_LATEST) - assert.NotContains(t, result.Stderr.String(), "There was an error loading astro config") +func TestUnknownFlag(t *testing.T) { + result := tests.RunTest(t, []string{ + "plan", + "--foo", + "bar", + }, "fixtures/flags", tests.VERSION_LATEST) + assert.Contains(t, result.Stderr.String(), "No astro config was loaded") + assert.Equal(t, 1, result.ExitCode) } func TestPlanErrorOnMissingValues(t *testing.T) { - result := runTest(t, []string{ - "--config=simple_variables.yaml", + result := tests.RunTest(t, []string{ "plan", - }, "fixtures/flags", VERSION_LATEST) - assert.Error(t, result.Err) + }, "fixtures/config-simple", tests.VERSION_LATEST) + assert.Equal(t, 1, result.ExitCode) assert.Contains(t, result.Stderr.String(), "missing required flags") assert.Contains(t, result.Stderr.String(), "--foo") assert.Contains(t, result.Stderr.String(), "--baz") @@ -80,25 +89,25 @@ func TestPlanAllowedValues(t *testing.T) { } for _, env := range tt { t.Run(env, func(t *testing.T) { - result := runTest(t, []string{ + result := tests.RunTest(t, []string{ "--config=merge_values.yaml", "plan", "--environment", env, - }, "fixtures/flags", VERSION_LATEST) - assert.NoError(t, result.Err) + }, "fixtures/flags", tests.VERSION_LATEST) + assert.Equal(t, 0, result.ExitCode) }) } } func TestPlanFailOnNotAllowedValue(t *testing.T) { - result := runTest(t, []string{ + result := tests.RunTest(t, []string{ "--config=merge_values.yaml", "plan", "--environment", "foo", - }, "fixtures/flags", VERSION_LATEST) - assert.Error(t, result.Err) + }, "fixtures/flags", tests.VERSION_LATEST) + assert.Equal(t, 1, result.ExitCode) assert.Contains(t, result.Stderr.String(), "invalid argument") assert.Contains(t, result.Stderr.String(), "allowed values") } diff --git a/astro/cli/astro/cmd/main.go b/astro/cli/astro/cmd/main.go deleted file mode 100644 index 6cfe9a0..0000000 --- a/astro/cli/astro/cmd/main.go +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright (c) 2018 Uber Technologies, Inc. - * - * 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 contains the source for the `astro` command line tool -// that operators use to interact with the project. -package cmd - -import ( - "fmt" - "io/ioutil" - "log" - "os" - "strings" - - "github.com/uber/astro/astro/logger" - - "github.com/spf13/cobra" -) - -// CLI flags -var ( - trace bool - userCfgFile string - projectFlags []*ProjectFlag - verbose bool -) - -var rootCmd = &cobra.Command{ - Use: "astro", - Short: "A tool for managing multiple Terraform modules.", - SilenceUsage: true, - SilenceErrors: true, -} - -func init() { - rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") - rootCmd.PersistentFlags().BoolVarP(&trace, "trace", "", false, "trace output") - rootCmd.PersistentFlags().StringVar(&userCfgFile, "config", "", "config file") - - // silence trace info from terraform/dag - log.SetOutput(ioutil.Discard) - - // Set trace - cobra.OnInitialize(func() { - if trace { - logger.Trace.SetOutput(os.Stderr) - } - }) -} - -// Main is the main entry point into the CLI program. -func Main() (exitCode int) { - var projectFlagsLoadErr error - - // Try to parse user flags from their astro config file. Reading the astro - // config could fail with an error, e.g. if there is no config file found, - // but this is not a hard failure. Save the error for later, so we can let - // the user know about the error in certain cases. - projectFlags, projectFlagsLoadErr = loadProjectFlagsFromConfig() - if projectFlags != nil && len(projectFlags) > 0 { - addProjectFlagsToCommands(projectFlags, applyCmd, planCmd) - } - - rc := 0 - - if err := rootCmd.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - - if projectFlagsLoadErr != nil && strings.Contains(err.Error(), "unknown flag") { - printConfigLoadingError(projectFlagsLoadErr) - } - - // exit with error - rc = 1 - } - - // If there was an error when parsing the user's project config file, - // then display a message to let them know in case they're wondering - // why their CLI flags are not showing up. - if projectFlagsLoadErr != nil && projectFlagsLoadErr != errCannotFindConfig { - printConfigLoadingError(projectFlagsLoadErr) - } - - return rc -} - -func printConfigLoadingError(e error) { - fmt.Fprintf(os.Stderr, "\nNOTE: There was an error loading astro config:\n") - fmt.Fprintln(os.Stderr, e.Error()) -} diff --git a/astro/cli/astro/cmd/options.go b/astro/cli/astro/cmd/options.go new file mode 100644 index 0000000..46dd328 --- /dev/null +++ b/astro/cli/astro/cmd/options.go @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2018 Uber Technologies, Inc. + * + * 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 ( + "io" + + multierror "github.com/hashicorp/go-multierror" +) + +// Option is an option for the cli that allows for changing of options or +// dependency injection for testing. +type Option func(*AstroCLI) error + +func (cli *AstroCLI) applyOptions(opts ...Option) (errs error) { + for _, opt := range opts { + if err := opt(cli); err != nil { + errs = multierror.Append(errs, err) + } + } + return errs +} + +// WithStdout allows you to pass custom input. +func WithStdout(stdout io.Writer) Option { + return func(cli *AstroCLI) error { + cli.stdout = stdout + return nil + } +} + +// WithStderr allows you to pass a custom stderr. +func WithStderr(stderr io.Writer) Option { + return func(cli *AstroCLI) error { + cli.stderr = stderr + return nil + } +} + +// WithStdin allows you to pass a custom stdin. +func WithStdin(stdin io.Reader) Option { + return func(cli *AstroCLI) error { + cli.stdin = stdin + return nil + } +} diff --git a/astro/cli/astro/main.go b/astro/cli/astro/main.go index f7d9ff7..74bee99 100644 --- a/astro/cli/astro/main.go +++ b/astro/cli/astro/main.go @@ -17,11 +17,20 @@ package main import ( + "fmt" "os" "github.com/uber/astro/astro/cli/astro/cmd" ) func main() { - os.Exit(cmd.Main()) + cli, err := cmd.NewAstroCLI( + cmd.WithStdout(os.Stdout), + cmd.WithStderr(os.Stderr), + ) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(cli.Run(os.Args[1:])) } diff --git a/astro/config.go b/astro/config.go index 5b8be40..156b443 100644 --- a/astro/config.go +++ b/astro/config.go @@ -52,7 +52,7 @@ func NewProjectFromConfigFile(configFilePath string) (*Project, error) { if err != nil { return nil, err } - return NewProject(*config) + return NewProject(WithConfig(*config)) } // NewProjectFromYAML creates a new Project based on the specified YAML @@ -63,7 +63,7 @@ func NewProjectFromYAML(yamlBytes []byte) (*Project, error) { return nil, err } - return NewProject(*config) + return NewProject(WithConfig(*config)) } // configFromYAML takes YAML bytes and returns a Project configuration diff --git a/astro/options.go b/astro/options.go new file mode 100644 index 0000000..81d02b2 --- /dev/null +++ b/astro/options.go @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2018 Uber Technologies, Inc. + * + * 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 astro + +import ( + multierror "github.com/hashicorp/go-multierror" + + "github.com/uber/astro/astro/conf" +) + +// Option is an option for the c that allows for changing of options or +// dependency injection for testing. +type Option func(*Project) error + +func (c *Project) applyOptions(opts ...Option) (errs error) { + for _, opt := range opts { + if err := opt(c); err != nil { + errs = multierror.Append(errs, err) + } + } + return errs +} + +// WithConfig allows you to pass project config. +func WithConfig(config conf.Project) Option { + return func(c *Project) error { + if err := config.Validate(); err != nil { + return err + } + c.config = &config + return nil + } +} diff --git a/astro/tests/base.go b/astro/tests/base.go new file mode 100644 index 0000000..c644d78 --- /dev/null +++ b/astro/tests/base.go @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2018 Uber Technologies, Inc. + * + * 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 tests + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/uber/astro/astro/cli/astro/cmd" + "github.com/uber/astro/astro/tvm" + "github.com/uber/astro/astro/utils" +) + +var ( + // During setup, this is initialized with a Terraform version + // repository so multiple versions of Terraform can be tested. + terraformVersionRepo *tvm.VersionRepo +) + +var ( + // Add Terraform versions here to run the tests against those + // versions. + terraformVersionsToTest = []string{ + "0.7.13", + "0.8.8", + "0.9.11", + "0.10.8", + "0.11.5", + } +) + +const VERSION_LATEST = "" + +type TestResult struct { + Stdout *bytes.Buffer + Stderr *bytes.Buffer + ExitCode int + Version string +} + +func init() { + var err error + + // Initialize Terraform version repository + terraformVersionRepo, err = tvm.NewVersionRepoForCurrentSystem("") + if err != nil { + panic(err) + } + + // Download Terraform versions first so that multiple tests don't + // try to do it in parallel. + for _, version := range terraformVersionsToTest { + if _, err := terraformVersionRepo.Get(version); err != nil { + panic(err) + } + } +} + +func RunTest(t *testing.T, args []string, fixtureBasePath string, version string) *TestResult { + fixturePath := fixtureBasePath + + // If requested version is empty, assume the latest + if version == VERSION_LATEST { + version = terraformVersionsToTest[len(terraformVersionsToTest)-1] + } + + // Determine if this version has a version-specific fixture. + versionSpecificFixturePath := fmt.Sprintf("%s-%s", fixtureBasePath, version) + if utils.FileExists(versionSpecificFixturePath) { + fixturePath = versionSpecificFixturePath + } + + // If there is a Makefile in the fixture, run make to set up any prereqs + // for the test. + if utils.FileExists(filepath.Join(fixturePath, "Makefile")) { + make := exec.Command("make") + make.Dir = fixturePath + out, err := make.CombinedOutput() + if err != nil { + fmt.Fprint(os.Stderr, string(out)) + } + require.NoError(t, err) + } + + // Get Terraform path + terraformBinaryPath, err := terraformVersionRepo.Get(version) + require.NoError(t, err) + + // Override Terraform path + terraformBinaryDir := filepath.Dir(terraformBinaryPath) + + // TODO: this blocks us from running multiple tests in parallel. + // Need a better way to override the version externally. + oldPath := os.Getenv("PATH") + os.Setenv("PATH", fmt.Sprintf("%s:%s", terraformBinaryDir, oldPath)) + defer os.Setenv("PATH", oldPath) + + // This also blocks us from running in parallel (the need to chdir) + oldDir, err := os.Getwd() + if err != nil { + panic(err) + } + os.Chdir(fixturePath) + defer os.Chdir(oldDir) + + stdoutBytes := &bytes.Buffer{} + stderrBytes := &bytes.Buffer{} + + cli, err := cmd.NewAstroCLI( + cmd.WithStdout(stdoutBytes), + cmd.WithStderr(stderrBytes), + ) + require.NoError(t, err) + + exitCode := cli.Run(args) + + return &TestResult{ + ExitCode: exitCode, + Stdout: stdoutBytes, + Stderr: stderrBytes, + Version: version, + } +} diff --git a/astro/cli/astro/cmd/fixtures/apply-changes-success/astro.yaml b/astro/tests/fixtures/apply-changes-success/astro.yaml similarity index 100% rename from astro/cli/astro/cmd/fixtures/apply-changes-success/astro.yaml rename to astro/tests/fixtures/apply-changes-success/astro.yaml diff --git a/astro/cli/astro/cmd/fixtures/apply-changes-success/terraform.tf b/astro/tests/fixtures/apply-changes-success/terraform.tf similarity index 100% rename from astro/cli/astro/cmd/fixtures/apply-changes-success/terraform.tf rename to astro/tests/fixtures/apply-changes-success/terraform.tf diff --git a/astro/tests/fixtures/flags/merge_values.yaml b/astro/tests/fixtures/flags/merge_values.yaml new file mode 100644 index 0000000..1ade5ce --- /dev/null +++ b/astro/tests/fixtures/flags/merge_values.yaml @@ -0,0 +1,23 @@ +--- + +terraform: + path: ../../../../../fixtures/mock-terraform/success + +modules: + - name: foo_mgmt + path: . + variables: + - name: environment + values: [mgmt] + + - name: misc + path: . + variables: + - name: environment + values: [dev, staging, prod] + + - name: test_env + path: . + variables: + - name: environment + values: [dev, staging] diff --git a/astro/tests/fixtures/flags/no_variables.yaml b/astro/tests/fixtures/flags/no_variables.yaml new file mode 100644 index 0000000..6063875 --- /dev/null +++ b/astro/tests/fixtures/flags/no_variables.yaml @@ -0,0 +1,8 @@ +--- + +terraform: + path: ../../../../../fixtures/mock-terraform/success + +modules: + - name: foo + path: . diff --git a/astro/tests/fixtures/flags/simple_variables.yaml b/astro/tests/fixtures/flags/simple_variables.yaml new file mode 100644 index 0000000..96b4a83 --- /dev/null +++ b/astro/tests/fixtures/flags/simple_variables.yaml @@ -0,0 +1,18 @@ +--- + +terraform: + path: ../../../../../fixtures/mock-terraform/success + +flags: + bar: + name: baz + description: Baz Description + +modules: + - name: fooModule + path: . + variables: + - name: foo + - name: bar + - name: qux + values: [dev, staging, prod] diff --git a/astro/cli/astro/cmd/fixtures/plan-detach-0.7.x/Makefile b/astro/tests/fixtures/plan-detach-0.7.13/Makefile similarity index 100% rename from astro/cli/astro/cmd/fixtures/plan-detach-0.7.x/Makefile rename to astro/tests/fixtures/plan-detach-0.7.13/Makefile diff --git a/astro/cli/astro/cmd/fixtures/plan-detach-0.7.x/astro.yaml b/astro/tests/fixtures/plan-detach-0.7.13/astro.yaml similarity index 100% rename from astro/cli/astro/cmd/fixtures/plan-detach-0.7.x/astro.yaml rename to astro/tests/fixtures/plan-detach-0.7.13/astro.yaml diff --git a/astro/cli/astro/cmd/fixtures/plan-detach-0.7.x/local-backend/foo.tfstate b/astro/tests/fixtures/plan-detach-0.7.13/local-backend/foo.tfstate similarity index 100% rename from astro/cli/astro/cmd/fixtures/plan-detach-0.7.x/local-backend/foo.tfstate rename to astro/tests/fixtures/plan-detach-0.7.13/local-backend/foo.tfstate diff --git a/astro/cli/astro/cmd/fixtures/plan-detach-0.7.x/terraform.tf b/astro/tests/fixtures/plan-detach-0.7.13/terraform.tf similarity index 100% rename from astro/cli/astro/cmd/fixtures/plan-detach-0.7.x/terraform.tf rename to astro/tests/fixtures/plan-detach-0.7.13/terraform.tf diff --git a/astro/cli/astro/cmd/fixtures/plan-detach/Makefile b/astro/tests/fixtures/plan-detach-0.7.x/Makefile similarity index 100% rename from astro/cli/astro/cmd/fixtures/plan-detach/Makefile rename to astro/tests/fixtures/plan-detach-0.7.x/Makefile diff --git a/astro/cli/astro/cmd/fixtures/plan-detach/astro.yaml b/astro/tests/fixtures/plan-detach-0.7.x/astro.yaml similarity index 100% rename from astro/cli/astro/cmd/fixtures/plan-detach/astro.yaml rename to astro/tests/fixtures/plan-detach-0.7.x/astro.yaml diff --git a/astro/cli/astro/cmd/fixtures/plan-detach/local-backend/foo.tfstate b/astro/tests/fixtures/plan-detach-0.7.x/local-backend/foo.tfstate similarity index 100% rename from astro/cli/astro/cmd/fixtures/plan-detach/local-backend/foo.tfstate rename to astro/tests/fixtures/plan-detach-0.7.x/local-backend/foo.tfstate diff --git a/astro/cli/astro/cmd/fixtures/plan-success-changes-0.7.x/terraform.tf b/astro/tests/fixtures/plan-detach-0.7.x/terraform.tf similarity index 100% rename from astro/cli/astro/cmd/fixtures/plan-success-changes-0.7.x/terraform.tf rename to astro/tests/fixtures/plan-detach-0.7.x/terraform.tf diff --git a/astro/tests/fixtures/plan-detach/Makefile b/astro/tests/fixtures/plan-detach/Makefile new file mode 100644 index 0000000..4b69997 --- /dev/null +++ b/astro/tests/fixtures/plan-detach/Makefile @@ -0,0 +1,4 @@ +.PHONY: out +out: + mkdir -p /tmp/terraform-tests/plan-detach/local-backend + cp -r local-backend/foo.tfstate /tmp/terraform-tests/plan-detach/local-backend/ diff --git a/astro/tests/fixtures/plan-detach/astro.yaml b/astro/tests/fixtures/plan-detach/astro.yaml new file mode 100644 index 0000000..5b47178 --- /dev/null +++ b/astro/tests/fixtures/plan-detach/astro.yaml @@ -0,0 +1,11 @@ +--- + +session_repo_dir: /tmp/terraform-tests/plan-detach + +modules: + - name: foo + path: . + remote: + backend: local + backend_config: + path: /tmp/terraform-tests/plan-detach/local-backend/foo.tfstate diff --git a/astro/tests/fixtures/plan-detach/local-backend/foo.tfstate b/astro/tests/fixtures/plan-detach/local-backend/foo.tfstate new file mode 100644 index 0000000..b224142 --- /dev/null +++ b/astro/tests/fixtures/plan-detach/local-backend/foo.tfstate @@ -0,0 +1,37 @@ +{ + "version": 3, + "terraform_version": "0.0.0", + "serial": 1, + "lineage": "6950e31c-0700-47fc-9c03-da86a21a3ebd", + "remote": { + "type": "local", + "config": { + "path": "/tmp/terraform-tests/plan-detach/local-backend/foo.tfstate" + } + }, + "modules": [ + { + "path": [ + "root" + ], + "outputs": {}, + "resources": { + "null_resource.foo": { + "type": "null_resource", + "depends_on": [], + "primary": { + "id": "4265659715563114320", + "attributes": { + "id": "4265659715563114320" + }, + "meta": {}, + "tainted": false + }, + "deposed": [], + "provider": "" + } + }, + "depends_on": [] + } + ] +} diff --git a/astro/cli/astro/cmd/fixtures/plan-detach/terraform.tf b/astro/tests/fixtures/plan-detach/terraform.tf similarity index 100% rename from astro/cli/astro/cmd/fixtures/plan-detach/terraform.tf rename to astro/tests/fixtures/plan-detach/terraform.tf diff --git a/astro/cli/astro/cmd/fixtures/plan-error/astro.yaml b/astro/tests/fixtures/plan-error/astro.yaml similarity index 100% rename from astro/cli/astro/cmd/fixtures/plan-error/astro.yaml rename to astro/tests/fixtures/plan-error/astro.yaml diff --git a/astro/cli/astro/cmd/fixtures/plan-error/terraform.tf b/astro/tests/fixtures/plan-error/terraform.tf similarity index 100% rename from astro/cli/astro/cmd/fixtures/plan-error/terraform.tf rename to astro/tests/fixtures/plan-error/terraform.tf diff --git a/astro/cli/astro/cmd/fixtures/plan-success-changes-0.7.x/astro.yaml b/astro/tests/fixtures/plan-success-changes-0.7.13/astro.yaml similarity index 100% rename from astro/cli/astro/cmd/fixtures/plan-success-changes-0.7.x/astro.yaml rename to astro/tests/fixtures/plan-success-changes-0.7.13/astro.yaml diff --git a/astro/cli/astro/cmd/fixtures/plan-success-nochanges-0.7.x/terraform.tf b/astro/tests/fixtures/plan-success-changes-0.7.13/terraform.tf similarity index 100% rename from astro/cli/astro/cmd/fixtures/plan-success-nochanges-0.7.x/terraform.tf rename to astro/tests/fixtures/plan-success-changes-0.7.13/terraform.tf diff --git a/astro/cli/astro/cmd/fixtures/plan-success-changes/astro.yaml b/astro/tests/fixtures/plan-success-changes-0.7.x/astro.yaml similarity index 100% rename from astro/cli/astro/cmd/fixtures/plan-success-changes/astro.yaml rename to astro/tests/fixtures/plan-success-changes-0.7.x/astro.yaml diff --git a/astro/tests/fixtures/plan-success-changes-0.7.x/terraform.tf b/astro/tests/fixtures/plan-success-changes-0.7.x/terraform.tf new file mode 100644 index 0000000..3911a2a --- /dev/null +++ b/astro/tests/fixtures/plan-success-changes-0.7.x/terraform.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/astro/tests/fixtures/plan-success-changes/astro.yaml b/astro/tests/fixtures/plan-success-changes/astro.yaml new file mode 100644 index 0000000..1911a6d --- /dev/null +++ b/astro/tests/fixtures/plan-success-changes/astro.yaml @@ -0,0 +1,9 @@ +--- + +modules: + - name: foo + path: . + remote: + backend: local + backend_config: + path: /tmp/terraform-tests/nonexistent.tfstate diff --git a/astro/cli/astro/cmd/fixtures/plan-success-changes/terraform.tf b/astro/tests/fixtures/plan-success-changes/terraform.tf similarity index 100% rename from astro/cli/astro/cmd/fixtures/plan-success-changes/terraform.tf rename to astro/tests/fixtures/plan-success-changes/terraform.tf diff --git a/astro/cli/astro/cmd/fixtures/plan-success-nochanges-0.7.x/Makefile b/astro/tests/fixtures/plan-success-nochanges-0.7.13/Makefile similarity index 100% rename from astro/cli/astro/cmd/fixtures/plan-success-nochanges-0.7.x/Makefile rename to astro/tests/fixtures/plan-success-nochanges-0.7.13/Makefile diff --git a/astro/cli/astro/cmd/fixtures/plan-success-nochanges-0.7.x/astro.yaml b/astro/tests/fixtures/plan-success-nochanges-0.7.13/astro.yaml similarity index 100% rename from astro/cli/astro/cmd/fixtures/plan-success-nochanges-0.7.x/astro.yaml rename to astro/tests/fixtures/plan-success-nochanges-0.7.13/astro.yaml diff --git a/astro/cli/astro/cmd/fixtures/plan-success-nochanges-0.7.x/local-backend/foo.tfstate b/astro/tests/fixtures/plan-success-nochanges-0.7.13/local-backend/foo.tfstate similarity index 100% rename from astro/cli/astro/cmd/fixtures/plan-success-nochanges-0.7.x/local-backend/foo.tfstate rename to astro/tests/fixtures/plan-success-nochanges-0.7.13/local-backend/foo.tfstate diff --git a/astro/tests/fixtures/plan-success-nochanges-0.7.13/terraform.tf b/astro/tests/fixtures/plan-success-nochanges-0.7.13/terraform.tf new file mode 100644 index 0000000..3911a2a --- /dev/null +++ b/astro/tests/fixtures/plan-success-nochanges-0.7.13/terraform.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/astro/cli/astro/cmd/fixtures/plan-success-nochanges/Makefile b/astro/tests/fixtures/plan-success-nochanges-0.7.x/Makefile similarity index 100% rename from astro/cli/astro/cmd/fixtures/plan-success-nochanges/Makefile rename to astro/tests/fixtures/plan-success-nochanges-0.7.x/Makefile diff --git a/astro/cli/astro/cmd/fixtures/plan-success-nochanges/astro.yaml b/astro/tests/fixtures/plan-success-nochanges-0.7.x/astro.yaml similarity index 100% rename from astro/cli/astro/cmd/fixtures/plan-success-nochanges/astro.yaml rename to astro/tests/fixtures/plan-success-nochanges-0.7.x/astro.yaml diff --git a/astro/cli/astro/cmd/fixtures/plan-success-nochanges/local-backend/foo.tfstate b/astro/tests/fixtures/plan-success-nochanges-0.7.x/local-backend/foo.tfstate similarity index 100% rename from astro/cli/astro/cmd/fixtures/plan-success-nochanges/local-backend/foo.tfstate rename to astro/tests/fixtures/plan-success-nochanges-0.7.x/local-backend/foo.tfstate diff --git a/astro/tests/fixtures/plan-success-nochanges-0.7.x/terraform.tf b/astro/tests/fixtures/plan-success-nochanges-0.7.x/terraform.tf new file mode 100644 index 0000000..3911a2a --- /dev/null +++ b/astro/tests/fixtures/plan-success-nochanges-0.7.x/terraform.tf @@ -0,0 +1 @@ +resource "null_resource" "foo" {} diff --git a/astro/tests/fixtures/plan-success-nochanges/Makefile b/astro/tests/fixtures/plan-success-nochanges/Makefile new file mode 100644 index 0000000..6ae2e14 --- /dev/null +++ b/astro/tests/fixtures/plan-success-nochanges/Makefile @@ -0,0 +1,4 @@ +.PHONY: out +out: + mkdir -p /tmp/terraform-tests/plan-success-nochanges/local-backend + cp -r local-backend/foo.tfstate /tmp/terraform-tests/plan-success-nochanges/local-backend/ diff --git a/astro/tests/fixtures/plan-success-nochanges/astro.yaml b/astro/tests/fixtures/plan-success-nochanges/astro.yaml new file mode 100644 index 0000000..99c3f80 --- /dev/null +++ b/astro/tests/fixtures/plan-success-nochanges/astro.yaml @@ -0,0 +1,9 @@ +--- + +modules: + - name: foo + path: . + remote: + backend: local + backend_config: + path: /tmp/terraform-tests/plan-success-nochanges/local-backend/foo.tfstate diff --git a/astro/tests/fixtures/plan-success-nochanges/local-backend/foo.tfstate b/astro/tests/fixtures/plan-success-nochanges/local-backend/foo.tfstate new file mode 100644 index 0000000..f560f0d --- /dev/null +++ b/astro/tests/fixtures/plan-success-nochanges/local-backend/foo.tfstate @@ -0,0 +1,37 @@ +{ + "version": 3, + "terraform_version": "0.0.0", + "serial": 1, + "lineage": "6950e31c-0700-47fc-9c03-da86a21a3ebd", + "remote": { + "type": "local", + "config": { + "path": "/tmp/terraform-tests/plan-success-nochanges/local-backend/foo.tfstate" + } + }, + "modules": [ + { + "path": [ + "root" + ], + "outputs": {}, + "resources": { + "null_resource.foo": { + "type": "null_resource", + "depends_on": [], + "primary": { + "id": "4265659715563114320", + "attributes": { + "id": "4265659715563114320" + }, + "meta": {}, + "tainted": false + }, + "deposed": [], + "provider": "" + } + }, + "depends_on": [] + } + ] +} diff --git a/astro/cli/astro/cmd/fixtures/plan-success-nochanges/terraform.tf b/astro/tests/fixtures/plan-success-nochanges/terraform.tf similarity index 100% rename from astro/cli/astro/cmd/fixtures/plan-success-nochanges/terraform.tf rename to astro/tests/fixtures/plan-success-nochanges/terraform.tf diff --git a/astro/tests/integration_test.go b/astro/tests/integration_test.go new file mode 100644 index 0000000..af995db --- /dev/null +++ b/astro/tests/integration_test.go @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2018 Uber Technologies, Inc. + * + * 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 tests + +import ( + "io/ioutil" + "os" + "path/filepath" + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// getSessionDirs returns a list of the sessions inside a session repository. +// This excludes other directories that might have been created in there, e.g. +// the shared plugin cache directory. +func getSessionDirs(sessionBaseDir string) ([]string, error) { + sessionRegexp, err := regexp.Compile("[0-9A-Z]{26}") + if err != nil { + return nil, err + } + + dirs, err := ioutil.ReadDir(sessionBaseDir) + if err != nil { + return nil, err + } + + sessionDirs := []string{} + + for _, dir := range dirs { + if sessionRegexp.MatchString(dir.Name()) { + sessionDirs = append(sessionDirs, dir.Name()) + } + } + + return sessionDirs, nil +} + +func TestProjectApplyChangesSuccess(t *testing.T) { + for _, version := range terraformVersionsToTest { + t.Run(version, func(t *testing.T) { + err := os.RemoveAll("/tmp/terraform-tests/apply-changes-success") + require.NoError(t, err) + + err = os.MkdirAll("/tmp/terraform-tests/apply-changes-success", 0775) + require.NoError(t, err) + + result := RunTest(t, []string{"apply"}, "fixtures/apply-changes-success", version) + assert.Contains(t, result.Stdout.String(), "foo: OK") + assert.Empty(t, result.Stderr.String()) + assert.Equal(t, 0, result.ExitCode) + }) + } +} + +func TestProjectPlanSuccessNoChanges(t *testing.T) { + for _, version := range terraformVersionsToTest { + t.Run(version, func(t *testing.T) { + result := RunTest(t, []string{"plan", "--trace"}, "fixtures/plan-success-nochanges", version) + assert.Equal(t, "foo: \x1b[32mOK\x1b[0m\x1b[37m No changes\x1b[0m\x1b[37m (0s)\x1b[0m\nDone\n", result.Stdout.String()) + assert.Equal(t, 0, result.ExitCode) + }) + } +} + +func TestProjectPlanSuccessChanges(t *testing.T) { + for _, version := range terraformVersionsToTest { + t.Run(version, func(t *testing.T) { + result := RunTest(t, []string{"plan"}, "fixtures/plan-success-changes", version) + assert.Contains(t, result.Stdout.String(), "foo: OK Changes") + assert.Regexp(t, `\+.*null_resource.foo`, result.Stdout.String()) + assert.Equal(t, 0, result.ExitCode) + }) + } +} + +func TestProjectPlanError(t *testing.T) { + for _, version := range terraformVersionsToTest { + t.Run(version, func(t *testing.T) { + result := RunTest(t, []string{"plan"}, "fixtures/plan-error", version) + assert.Contains(t, result.Stderr.String(), "foo: ERROR") + assert.Contains(t, result.Stderr.String(), "Error parsing") + assert.Equal(t, 1, result.ExitCode) + }) + } +} + +func TestProjectPlanDetachSuccess(t *testing.T) { + for _, version := range terraformVersionsToTest { + t.Run(version, func(t *testing.T) { + err := os.RemoveAll("/tmp/terraform-tests/plan-detach") + require.NoError(t, err) + + err = os.MkdirAll("/tmp/terraform-tests/plan-detach", 0775) + require.NoError(t, err) + + result := RunTest(t, []string{"plan", "--detach"}, "fixtures/plan-detach", version) + require.Empty(t, result.Stderr.String()) + require.Equal(t, 0, result.ExitCode) + require.Equal(t, "foo: \x1b[32mOK\x1b[0m\x1b[37m No changes\x1b[0m\x1b[37m (0s)\x1b[0m\nDone\n", result.Stdout.String()) + + sessionDirs, err := getSessionDirs("/tmp/terraform-tests/plan-detach/.astro") + require.NoError(t, err) + require.Equal(t, 1, len(sessionDirs), "unable to find session: expect only a single session to have been written, found multiple") + + _, err = os.Stat(filepath.Join("/tmp/terraform-tests/plan-detach/.astro", sessionDirs[0], "foo/sandbox/terraform.tfstate")) + assert.NoError(t, err) + }) + } +}