diff --git a/.gitignore b/.gitignore index d548864..b4aec4c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ test.rb test.out junit.xml *.sublime-* +.vscode/ main keychain secrets.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index a1839e3..c1c8cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## Unreleased +### Added +- Added ability to search for secrets.yml going up, starting from the current working directory + [#122](https://github.com/cyberark/summon/issues/122) + ## [0.8.1] - 2020-03-02 ### Changed - Added ability to support empty variables [#124](https://github.com/cyberark/summon/issues/124) diff --git a/README.md b/README.md index 8faee8b..1c45da8 100644 --- a/README.md +++ b/README.md @@ -136,13 +136,20 @@ VARIABLE_WITH_DEFAULT: !var:default='defaultvalue' path/to/variable `summon` supports a number of flags. -* `-p, --provider` specify the path to the [provider](provider/README.md) summon should use +* `-p, --provider` specify the path to the [provider](provider/README.md) summon should use. If the provider is in the default path, `/usr/local/lib/summon/` you can just provide the name of the executable. If not, use a full path. * `-f ` specify a location to a secrets.yml file, default 'secrets.yml' in current directory. +* `--up` searches for secrets.yml going up, starting from the current working + directory. + + Stops at the first file found or when the root of the current file system is + reached. This allows to be at any directory depth in a project and simply do + `summon -u `. + * `-D 'var=value'` causes substitution of `value` to `$var`. You can use the same secrets.yml file for different environments, using `-D` to @@ -154,15 +161,15 @@ VARIABLE_WITH_DEFAULT: !var:default='defaultvalue' path/to/variable summon -D ENV=production --yaml 'SQL_PASSWORD: !var env/$ENV/db-password' deploy.sh ``` -* `-i, --ignore` A secret path for which to ignore provider errors +* `-i, --ignore` A secret path for which to ignore provider errors. This flag can be useful for when you have secrets that you don't need access to for development. For example API keys for monitoring tools. This flag can be used multiple times. -* `-I, --ignore-all` A boolean to ignore any missing secret paths +* `-I, --ignore-all` A boolean to ignore any missing secret paths. This flag can be useful when the underlying system that's going to be using the values implements defaults. For example, when using summon as a bridge to [confd](https://github.com/kelseyhightower/confd). -* `-e, --environment` Specify section (environment) to parse from secret YAML +* `-e, --environment` Specify section (environment) to parse from secret YAML. This flag specifies which specific environment/section to parse from the secrets YAML file (or string). In addition, it will also enable the usage of a `common` (or `default`) section which will be inherited by other sections/environments. In other words, if your `secrets.yaml` looks something like this: diff --git a/internal/command/action.go b/internal/command/action.go index d18014b..6a2e9db 100644 --- a/internal/command/action.go +++ b/internal/command/action.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path" + "path/filepath" "strings" "sync" "syscall" @@ -26,6 +27,7 @@ type ActionConfig struct { Ignores []string IgnoreAll bool Environment string + RecurseUp bool ShowProviderVersions bool } @@ -56,6 +58,7 @@ var Action = func(c *cli.Context) { YamlInline: c.String("yaml"), Ignores: c.StringSlice("ignore"), IgnoreAll: c.Bool("ignore-all"), + RecurseUp: c.Bool("up"), ShowProviderVersions: c.Bool("all-provider-versions"), Subs: convertSubsToMap(c.StringSlice("D")), }) @@ -87,6 +90,14 @@ func runAction(ac *ActionConfig) error { return nil } + if ac.RecurseUp { + currentDir, err := os.Getwd() + ac.Filepath, err = findInParentTree(ac.Filepath, currentDir) + if err != nil { + return err + } + } + switch ac.YamlInline { case "": secrets, err = secretsyml.ParseFromFile(ac.Filepath, ac.Environment, ac.Subs) @@ -183,6 +194,43 @@ func joinEnv(env []string) string { return strings.Join(env, "\n") + "\n" } +// findInParentTree recursively searches for secretsFile starting at leafDir and in the +// directories above leafDir until it is found or the root of the file system is reached. +// If found, returns the absolute path to the file. +func findInParentTree(secretsFile string, leafDir string) (string, error) { + if filepath.IsAbs(secretsFile) { + return "", fmt.Errorf( + "file specified (%s) is an absolute path: will not recurse up", secretsFile) + } + + for { + joinedPath := filepath.Join(leafDir, secretsFile) + + _, err := os.Stat(joinedPath) + + if err != nil { + // If the file is not present, we just move up one level and run the next loop + // iteration + if os.IsNotExist(err) { + upOne := filepath.Dir(leafDir) + if upOne == leafDir { + return "", fmt.Errorf( + "unable to locate file specified (%s): reached root of file system", secretsFile) + } + + leafDir = upOne + continue + } + + // If we have an unexpected error, we fail-fast + return "", fmt.Errorf("unable to locate file specified (%s): %s", secretsFile, err) + } + + // If there's no error, we found the file so we return it + return joinedPath, nil + } +} + // scans arguments for the magic string; if found, // creates a tempfile to which all the environment mappings are dumped // and replaces the magic string with its path. diff --git a/internal/command/action_test.go b/internal/command/action_test.go index 88ec334..f690a43 100644 --- a/internal/command/action_test.go +++ b/internal/command/action_test.go @@ -2,13 +2,16 @@ package command import ( "errors" + "fmt" "io/ioutil" "os" "os/exec" "path" "path/filepath" + "strconv" "strings" "testing" + "time" "github.com/cyberark/summon/secretsyml" . "github.com/smartystreets/goconvey/convey" @@ -240,3 +243,81 @@ testprovider-trailingnewline version 3.2.1 So(output, ShouldEqual, expected) }) } + +func TestLocateFileRecurseUp(t *testing.T) { + filename := "test.txt" + + Convey("Finds file in current working directory", t, func() { + topDir, err := ioutil.TempDir("", "summon") + So(err, ShouldBeNil) + defer os.RemoveAll(topDir) + + localFilePath := filepath.Join(topDir, filename) + _, err = os.Create(localFilePath) + So(err, ShouldBeNil) + + gotPath, err := findInParentTree(filename, topDir) + So(err, ShouldBeNil) + + So(gotPath, ShouldEqual, localFilePath) + }) + + Convey("Finds file in a higher working directory", t, func() { + topDir, err := ioutil.TempDir("", "summon") + So(err, ShouldBeNil) + defer os.RemoveAll(topDir) + + higherFilePath := filepath.Join(topDir, filename) + _, err = os.Create(higherFilePath) + So(err, ShouldBeNil) + + // Create a downwards directory hierarchy, starting from topDir + downDir := filepath.Join(topDir, "dir1", "dir2", "dir3") + err = os.MkdirAll(downDir, 0700) + So(err, ShouldBeNil) + + gotPath, err := findInParentTree(filename, downDir) + So(err, ShouldBeNil) + + So(gotPath, ShouldEqual, higherFilePath) + }) + + Convey("returns a friendly error if file not found", t, func() { + topDir, err := ioutil.TempDir("", "summon") + So(err, ShouldBeNil) + defer os.RemoveAll(topDir) + + // A unlikely to exist file name + nonExistingFileName := strconv.FormatInt(time.Now().Unix(), 10) + wantErrMsg := fmt.Sprintf( + "unable to locate file specified (%s): reached root of file system", + nonExistingFileName) + + _, err = findInParentTree(nonExistingFileName, topDir) + So(err.Error(), ShouldEqual, wantErrMsg) + }) + + Convey("returns a friendly error if file is an absolute path", t, func() { + topDir, err := ioutil.TempDir("", "summon") + So(err, ShouldBeNil) + defer os.RemoveAll(topDir) + + absFileName := "/foo/bar/baz" + wantErrMsg := "file specified (/foo/bar/baz) is an absolute path: will not recurse up" + + _, err = findInParentTree(absFileName, topDir) + So(err.Error(), ShouldEqual, wantErrMsg) + }) + + Convey("returns a friendly error in unexpected circumstances (100% coverage)", t, func() { + topDir, err := ioutil.TempDir("", "summon") + So(err, ShouldBeNil) + defer os.RemoveAll(topDir) + + fileNameWithNulByte := "pizza\x00margherita" + wantErrMsg := "unable to locate file specified (pizza\x00margherita): stat" + + _, err = findInParentTree(fileNameWithNulByte, topDir) + So(err.Error(), ShouldStartWith, wantErrMsg) + }) +} diff --git a/internal/command/flags.go b/internal/command/flags.go index 59a8600..0154f1a 100644 --- a/internal/command/flags.go +++ b/internal/command/flags.go @@ -19,6 +19,10 @@ var Flags = []cli.Flag{ Value: "secrets.yml", Usage: "Path to secrets.yml", }, + cli.BoolFlag{ + Name: "up", + Usage: "Go up in the directory hierarchy until the secrets file is found", + }, cli.StringSliceFlag{ Name: "D", Value: &cli.StringSlice{},