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) + }) + } +}