diff --git a/internals/secrethub/env.go b/internals/secrethub/env.go index 845d50f3..5e653311 100644 --- a/internals/secrethub/env.go +++ b/internals/secrethub/env.go @@ -24,5 +24,5 @@ func (cmd *EnvCommand) Register(r command.Registerer) { clause := r.Command("env", "[BETA] Manage environment variables.").Hidden() clause.HelpLong("This command is hidden because it is still in beta. Future versions may break.") NewEnvReadCommand(cmd.io, cmd.newClient).Register(clause) - NewEnvListCommand(cmd.io).Register(clause) + NewEnvListCommand(cmd.io, cmd.newClient).Register(clause) } diff --git a/internals/secrethub/env_ls.go b/internals/secrethub/env_ls.go index e4835701..c1cac212 100644 --- a/internals/secrethub/env_ls.go +++ b/internals/secrethub/env_ls.go @@ -14,10 +14,10 @@ type EnvListCommand struct { } // NewEnvListCommand creates a new EnvListCommand. -func NewEnvListCommand(io ui.IO) *EnvListCommand { +func NewEnvListCommand(io ui.IO, newClient newClientFunc) *EnvListCommand { return &EnvListCommand{ io: io, - environment: newEnvironment(io), + environment: newEnvironment(io, newClient), } } diff --git a/internals/secrethub/env_read.go b/internals/secrethub/env_read.go index 028b2f69..af216e25 100644 --- a/internals/secrethub/env_read.go +++ b/internals/secrethub/env_read.go @@ -20,7 +20,7 @@ func NewEnvReadCommand(io ui.IO, newClient newClientFunc) *EnvReadCommand { return &EnvReadCommand{ io: io, newClient: newClient, - environment: newEnvironment(io), + environment: newEnvironment(io, newClient), } } diff --git a/internals/secrethub/env_source.go b/internals/secrethub/env_source.go index 607d3e6f..242d4f59 100644 --- a/internals/secrethub/env_source.go +++ b/internals/secrethub/env_source.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "errors" + "fmt" "io" "io/ioutil" "os" @@ -22,8 +23,19 @@ import ( "gopkg.in/yaml.v2" ) +type errNameCollision struct { + name string + firstPath string + secondPath string +} + +func (e errNameCollision) Error() string { + return fmt.Sprintf("secrets at path %s and %s map to the same environment variable: %s. Rename one of the secrets or source them in a different way", e.firstPath, e.secondPath, e.name) +} + type environment struct { io ui.IO + newClient newClientFunc osEnv []string readFile func(filename string) ([]byte, error) osStat func(filename string) (os.FileInfo, error) @@ -32,12 +44,14 @@ type environment struct { templateVars map[string]string templateVersion string dontPromptMissingTemplateVar bool + secretsDir string secretsEnvDir string } -func newEnvironment(io ui.IO) *environment { +func newEnvironment(io ui.IO, newClient newClientFunc) *environment { return &environment{ io: io, + newClient: newClient, osEnv: os.Environ(), readFile: ioutil.ReadFile, osStat: os.Stat, @@ -53,6 +67,7 @@ func (env *environment) register(clause *cli.CommandClause) { clause.Flag("var", "Define the value for a template variable with `VAR=VALUE`, e.g. --var env=prod").Short('v').StringMapVar(&env.templateVars) clause.Flag("template-version", "The template syntax version to be used. The options are v1, v2, latest or auto to automatically detect the version.").Default("auto").StringVar(&env.templateVersion) clause.Flag("no-prompt", "Do not prompt when a template variable is missing and return an error instead.").BoolVar(&env.dontPromptMissingTemplateVar) + clause.Flag("secrets-dir", "Recursively include all secrets from a directory. Environment variable names are derived from the path of the secret: `/` are replaced with `_` and the name is uppercased.").StringVar(&env.secretsDir) clause.Flag("env", "The name of the environment prepared by the set command (default is `default`)").Default("default").Hidden().StringVar(&env.secretsEnvDir) } @@ -75,6 +90,12 @@ func (env *environment) env() (map[string]value, error) { sources = append(sources, dirSource) } + // --secrets-dir flag + if env.secretsDir != "" { + secretsDirEnv := newSecretsDirEnv(env.newClient, env.secretsDir) + sources = append(sources, secretsDirEnv) + } + //secrethub.env file if env.envFile == "" { _, err := env.osStat(defaultEnvFile) @@ -173,6 +194,69 @@ func newSecretValue(path string) value { return &secretValue{path: path} } +// secretsDirEnv sources environment variables from the directory specified with the --secrets-dir flag. +type secretsDirEnv struct { + newClient newClientFunc + dirPath string +} + +// env returns a map of environment variables containing all secrets from the specified path. +// The variable names are the relative paths of their corresponding secrets in uppercase snake case. +// An error is returned if two secret paths map to the same variable name. +func (s *secretsDirEnv) env() (map[string]value, error) { + client, err := s.newClient() + if err != nil { + return nil, err + } + + tree, err := client.Dirs().GetTree(s.dirPath, -1, false) + if err != nil { + return nil, err + } + + paths := make(map[string]string, tree.SecretCount()) + for id := range tree.Secrets { + secretPath, err := tree.AbsSecretPath(id) + if err != nil { + return nil, err + } + path := secretPath.String() + + envVarName := s.envVarName(path) + if prevPath, found := paths[envVarName]; found { + return nil, errNameCollision{ + name: envVarName, + firstPath: prevPath, + secondPath: path, + } + } + paths[envVarName] = path + } + + result := make(map[string]value, len(paths)) + for name, path := range paths { + result[name] = newSecretValue(path) + } + return result, nil +} + +// envVarName returns the environment variable name corresponding to the secret on the specified path +// by converting the relative path to uppercase snake case. +func (s *secretsDirEnv) envVarName(path string) string { + envVarName := strings.TrimPrefix(path, s.dirPath) + envVarName = strings.TrimPrefix(envVarName, "/") + envVarName = strings.ReplaceAll(envVarName, "/", "_") + envVarName = strings.ToUpper(envVarName) + return envVarName +} + +func newSecretsDirEnv(newClient newClientFunc, dirPath string) *secretsDirEnv { + return &secretsDirEnv{ + newClient: newClient, + dirPath: dirPath, + } +} + // EnvFlags defines environment variables sourced from command-line flags. type EnvFlags map[string]string diff --git a/internals/secrethub/env_source_test.go b/internals/secrethub/env_source_test.go new file mode 100644 index 00000000..a6ecad93 --- /dev/null +++ b/internals/secrethub/env_source_test.go @@ -0,0 +1,142 @@ +package secrethub + +import ( + "testing" + + "github.com/secrethub/secrethub-go/internals/api" + "github.com/secrethub/secrethub-go/internals/api/uuid" + "github.com/secrethub/secrethub-go/internals/assert" + "github.com/secrethub/secrethub-go/pkg/secrethub" + "github.com/secrethub/secrethub-go/pkg/secrethub/fakeclient" +) + +func TestSecretsDirEnv(t *testing.T) { + const dirPath = "namespace/repo" + rootDirUUID := uuid.New() + subDirUUID := uuid.New() + secretUUID1 := uuid.New() + secretUUID2 := uuid.New() + + cases := map[string]struct { + newClient newClientFunc + expectedValues []string + err error + }{ + "success": { + newClient: func() (secrethub.ClientInterface, error) { + return fakeclient.Client{ + DirService: &fakeclient.DirService{ + GetTreeFunc: func(path string, depth int, ancestors bool) (*api.Tree, error) { + return &api.Tree{ + ParentPath: "namespace", + RootDir: &api.Dir{ + DirID: rootDirUUID, + Name: "repo", + }, + Secrets: map[uuid.UUID]*api.Secret{ + secretUUID1: { + SecretID: secretUUID1, + DirID: rootDirUUID, + Name: "foo", + }, + }, + }, nil + }, + }, + }, nil + }, + expectedValues: []string{"FOO"}, + }, + "success secret in dir": { + newClient: func() (secrethub.ClientInterface, error) { + return fakeclient.Client{ + DirService: &fakeclient.DirService{ + GetTreeFunc: func(path string, depth int, ancestors bool) (*api.Tree, error) { + return &api.Tree{ + ParentPath: "namespace", + RootDir: &api.Dir{ + DirID: rootDirUUID, + Name: "repo", + }, + Dirs: map[uuid.UUID]*api.Dir{ + subDirUUID: { + DirID: subDirUUID, + ParentID: &rootDirUUID, + Name: "foo", + }, + }, + Secrets: map[uuid.UUID]*api.Secret{ + secretUUID1: { + SecretID: secretUUID1, + DirID: subDirUUID, + Name: "bar", + }, + }, + }, nil + }, + }, + }, nil + }, + expectedValues: []string{"FOO_BAR"}, + }, + "name collision": { + newClient: func() (secrethub.ClientInterface, error) { + return fakeclient.Client{ + DirService: &fakeclient.DirService{ + GetTreeFunc: func(path string, depth int, ancestors bool) (*api.Tree, error) { + return &api.Tree{ + ParentPath: "namespace", + RootDir: &api.Dir{ + DirID: rootDirUUID, + Name: "repo", + }, + Dirs: map[uuid.UUID]*api.Dir{ + subDirUUID: { + DirID: subDirUUID, + ParentID: &rootDirUUID, + Name: "foo", + }, + }, + Secrets: map[uuid.UUID]*api.Secret{ + secretUUID1: { + SecretID: secretUUID1, + DirID: subDirUUID, + Name: "bar", + }, + secretUUID2: { + SecretID: secretUUID2, + DirID: rootDirUUID, + Name: "foo_bar", + }, + }, + }, nil + }, + }, + }, nil + }, + err: errNameCollision{ + name: "FOO_BAR", + firstPath: "namespace/repo/foo/bar", + secondPath: "namespace/repo/foo_bar", + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + source := newSecretsDirEnv(tc.newClient, dirPath) + secrets, err := source.env() + if tc.err != nil { + assert.Equal(t, err, tc.err) + } else { + assert.OK(t, err) + assert.Equal(t, len(secrets), len(tc.expectedValues)) + for _, name := range tc.expectedValues { + if _, ok := secrets[name]; !ok { + t.Errorf("expected but not found env var with name: %s", name) + } + } + } + }) + } +} diff --git a/internals/secrethub/run.go b/internals/secrethub/run.go index 939726f4..db770e20 100644 --- a/internals/secrethub/run.go +++ b/internals/secrethub/run.go @@ -63,7 +63,7 @@ func NewRunCommand(io ui.IO, newClient newClientFunc) *RunCommand { return &RunCommand{ io: io, osEnv: os.Environ(), - environment: newEnvironment(io), + environment: newEnvironment(io, newClient), newClient: newClient, } } diff --git a/internals/secrethub/run_test.go b/internals/secrethub/run_test.go index fb6d4295..87ed68a0 100644 --- a/internals/secrethub/run_test.go +++ b/internals/secrethub/run_test.go @@ -10,6 +10,8 @@ import ( "strings" "testing" + "github.com/secrethub/secrethub-go/internals/api/uuid" + "github.com/secrethub/secrethub-cli/internals/cli/ui/fakeui" "github.com/secrethub/secrethub-cli/internals/secrethub/tpl" @@ -657,6 +659,11 @@ func osStatFunc(name string, err error) func(string) (os.FileInfo, error) { } func TestRunCommand_environment(t *testing.T) { + rootDirUUID := uuid.New() + secretUUID := uuid.New() + + const secretPathFoo = "namespace/repo/foo" + cases := map[string]struct { command RunCommand expectedEnv []string @@ -764,7 +771,159 @@ func TestRunCommand_environment(t *testing.T) { expectedSecrets: []string{"bbb"}, expectedEnv: []string{"TEST=bbb"}, }, + "env file has precedence over secrets-dir flag": { + command: RunCommand{ + environment: &environment{ + newClient: func() (secrethub.ClientInterface, error) { + return fakeclient.Client{ + DirService: &fakeclient.DirService{ + GetTreeFunc: func(path string, depth int, ancestors bool) (*api.Tree, error) { + return &api.Tree{ + ParentPath: "namespace", + RootDir: &api.Dir{ + DirID: rootDirUUID, + Name: "repo", + }, + Secrets: map[uuid.UUID]*api.Secret{ + secretUUID: { + SecretID: secretUUID, + DirID: rootDirUUID, + Name: "foo", + }, + }, + }, nil + }, + }, + }, nil + }, + secretsDir: "namespace/repo", + dontPromptMissingTemplateVar: true, + templateVersion: "2", + osEnv: []string{"FOO=bbb"}, + osStat: osStatFunc("secrethub.env", nil), + readFile: readFileFunc("secrethub.env", "FOO= {{ other/secret/path }}"), + }, + newClient: func() (secrethub.ClientInterface, error) { + return fakeclient.Client{ + SecretService: &fakeclient.SecretService{ + VersionService: &fakeclient.SecretVersionService{ + GetWithDataFunc: func(path string) (*api.SecretVersion, error) { + if path == secretPathFoo { + return &api.SecretVersion{Data: []byte("aaa")}, nil + } else if path == "other/secret/path" { + return &api.SecretVersion{Data: []byte("bbb")}, nil + } + return nil, api.ErrSecretNotFound + }, + }, + }, + }, nil + }, + }, + expectedSecrets: []string{"bbb"}, + expectedEnv: []string{"FOO=bbb"}, + }, + "secrets-dir flag has precedence over os environment": { + command: RunCommand{ + environment: &environment{ + newClient: func() (secrethub.ClientInterface, error) { + return fakeclient.Client{ + DirService: &fakeclient.DirService{ + GetTreeFunc: func(path string, depth int, ancestors bool) (*api.Tree, error) { + return &api.Tree{ + ParentPath: "namespace", + RootDir: &api.Dir{ + DirID: rootDirUUID, + Name: "repo", + }, + Secrets: map[uuid.UUID]*api.Secret{ + secretUUID: { + SecretID: secretUUID, + DirID: rootDirUUID, + Name: "foo", + }, + }, + }, nil + }, + }, + }, nil + }, + secretsDir: "namespace/repo", + dontPromptMissingTemplateVar: true, + templateVersion: "2", + osEnv: []string{"FOO=bbb"}, + osStat: osStatFunc("secrethub.env", os.ErrNotExist), + }, + newClient: func() (secrethub.ClientInterface, error) { + return fakeclient.Client{ + SecretService: &fakeclient.SecretService{ + VersionService: &fakeclient.SecretVersionService{ + GetWithDataFunc: func(path string) (*api.SecretVersion, error) { + if path == secretPathFoo { + return &api.SecretVersion{Data: []byte("aaa")}, nil + } + return nil, api.ErrSecretNotFound + }, + }, + }, + }, nil + }, + }, + expectedSecrets: []string{"aaa"}, + expectedEnv: []string{"FOO=aaa"}, + }, // TODO Add test case for: envar flag has precedence over secret reference - requires refactoring of fakeclient + "secret reference has precedence over secrets-dir flag": { + command: RunCommand{ + environment: &environment{ + newClient: func() (secrethub.ClientInterface, error) { + return fakeclient.Client{ + DirService: &fakeclient.DirService{ + GetTreeFunc: func(path string, depth int, ancestors bool) (*api.Tree, error) { + return &api.Tree{ + ParentPath: "namespace", + RootDir: &api.Dir{ + DirID: rootDirUUID, + Name: "repo", + }, + Secrets: map[uuid.UUID]*api.Secret{ + secretUUID: { + SecretID: secretUUID, + DirID: rootDirUUID, + Name: "foo", + }, + }, + }, nil + }, + }, + }, nil + }, + secretsDir: "namespace/repo", + dontPromptMissingTemplateVar: true, + templateVersion: "2", + osEnv: []string{"FOO=secrethub://test/test/test"}, + osStat: osStatFunc("secrethub.env", os.ErrNotExist), + }, + newClient: func() (secrethub.ClientInterface, error) { + return fakeclient.Client{ + SecretService: &fakeclient.SecretService{ + VersionService: &fakeclient.SecretVersionService{ + GetWithDataFunc: func(path string) (*api.SecretVersion, error) { + if path == "test/test/test" { + return &api.SecretVersion{Data: []byte("bbb")}, nil + } else if path == secretPathFoo { + return &api.SecretVersion{Data: []byte("aaa")}, nil + } + return nil, api.ErrSecretNotFound + }, + }, + }, + }, nil + }, + }, + expectedSecrets: []string{"bbb"}, + expectedEnv: []string{"FOO=bbb"}, + }, "secret reference has precedence over .env file": { command: RunCommand{ environment: &environment{