diff --git a/README.md b/README.md index 8b4daba..3b76329 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ $ onstaging ./bin/ci/deploy-service.sh - :white_check_mark: Add a version command - :white_check_mark: Accept all valid yaml file extensions - :white_check_mark: Build a CI/CD pipeline -- Implement some tests +- :white_check_mark: Implement some tests - Loading Environment variables from a .env file - Encrypted environment variables via [dragoman](https://github.com/meltwater/dragoman) - Potentially switching to [cobra](https://github.com/spf13/cobra) for the cli \ No newline at end of file diff --git a/config/aws.go b/config/aws.go deleted file mode 100644 index acf2b1d..0000000 --- a/config/aws.go +++ /dev/null @@ -1,116 +0,0 @@ -package config - -import ( - "context" - "fmt" - "os" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/credentials/stscreds" - sm "github.com/aws/aws-sdk-go-v2/service/secretsmanager" - "github.com/aws/aws-sdk-go-v2/service/sts" -) - -// ConfigureAwsEnvironment will load the profile and setup the aws environment variables needed for AWS command execution -func ConfigureAwsEnvironment(profile string) error { - prof, err := loadAwsProfile(profile) - if err != nil { - return fmt.Errorf("unable to load AWS Profile '%s': %v", profile, err) - } - - cfg, err := loadAwsConfig(&prof) - if err != nil { - return fmt.Errorf("unable to load AWS Config in '%s': %v", prof.Region, err) - } - - creds, err := setupAwsSession(&cfg, &prof) - if err != nil { - return fmt.Errorf("unable to setup the AWS Session for profile '%s': %v", prof.Profile, err) - } - - setupAwsEnv(&creds, prof.Region) - - return nil -} - -// loadAwsProfile will load data from the profile in ~/.aws/credentials -func loadAwsProfile(profile string) (config.SharedConfig, error) { - profilecfg, err := config.LoadSharedConfigProfile(context.TODO(), profile) - if err != nil { - return config.SharedConfig{}, err - } - - return profilecfg, nil -} - -// loadAwsConfig will setup the configuration object to communicate with STS -func loadAwsConfig(profile *config.SharedConfig) (aws.Config, error) { - cfg, err := config.LoadDefaultConfig( - context.TODO(), - config.WithSharedConfigProfile(profile.Profile), - config.WithDefaultRegion(profile.Region), - ) - - if err != nil { - return aws.Config{}, err - } - - return cfg, nil -} - -// setupAwsSession will generate the temp credentials from the config and profile -func setupAwsSession(cfg *aws.Config, profile *config.SharedConfig) (aws.Credentials, error) { - // Setup an STS client - client := sts.NewFromConfig(*cfg) - - // Get the credentials using the role - cred_provider := stscreds.NewAssumeRoleProvider(client, profile.RoleARN) - - // Generate the temp credentials - creds, err := cred_provider.Retrieve(context.TODO()) - if err != nil { - return aws.Credentials{}, nil - } - - return creds, nil -} - -// setupAwsEnv will use the credentials and region to set the standard AWS Environment Variables -func setupAwsEnv(creds *aws.Credentials, region string) { - os.Setenv("AWS_ACCESS_KEY_ID", creds.AccessKeyID) - os.Setenv("AWS_SECRET_ACCESS_KEY", creds.SecretAccessKey) - os.Setenv("AWS_SESSION_TOKEN", creds.SessionToken) - os.Setenv("AWS_DEFAULT_REGION", region) -} - -// loadSecretsManagerSecret will pull in a secret from AWS Secrets Manager -func loadSecretsManagerSecret(id string) (string, error) { - var region = os.Getenv("AWS_DEFAULT_REGION") - - cfg, err := config.LoadDefaultConfig( - context.TODO(), - config.WithDefaultRegion(region), - ) - if err != nil { - return "", err - } - - client := sm.NewFromConfig(cfg) - - out, err := client.GetSecretValue( - context.TODO(), - &sm.GetSecretValueInput{ - SecretId: &id, - }, - ) - if err != nil { - return "", err - } - - if *out.SecretString == "" { - return "", fmt.Errorf("only string secrets are supported") - } - - return *out.SecretString, nil -} \ No newline at end of file diff --git a/config/config.go b/config/config.go deleted file mode 100644 index b436d73..0000000 --- a/config/config.go +++ /dev/null @@ -1,84 +0,0 @@ -package config - -import ( - "fmt" - "log" -) - -type BiomeConfig struct { - Name string - AwsProfile string `yaml:"aws_profile"` - Environment map[string]interface{} `yaml:"environment"` -} - -func (bc BiomeConfig) GetEnvs() map[string]string { - newEnvs := make(map[string]string) - - for key, node := range bc.Environment { - var val string - switch node.(type) { - case map[string]interface{}: - var err error - val, err = parseBiomeVariableConfig(node.(map[string]interface{})) - if err != nil { - log.Fatalf("Error processing variable '%s': %v", key, err) - } - default: - val = fmt.Sprint(node) - } - - newEnvs[key] = val - } - - return newEnvs -} - -type BiomeVariableConfig struct { - SecretARN string - SecretJSONKey string -} - -func parseBiomeVariableConfig(cfg map[string]interface{}) (string, error) { - bvc := BiomeVariableConfig{} - - // Parse out any keys, and see what we've got - for key, val := range cfg { - switch val.(type) { - case string: - parsedVal := val.(string) - switch key { - case "secret_arn": - bvc.SecretARN = parsedVal - case "secret_json_key": - bvc.SecretJSONKey = parsedVal - default: - return "", fmt.Errorf("unable to process key %s: unknown key", key) - } - default: - return "", fmt.Errorf("unable to process key %s with value %v", key, val) - } - } - - // Process a secrets manager secret - if bvc.SecretARN != "" { - /*smval, err := loadSecretsManagerSecret(bvc.SecretARN) - if err != nil { - return "", fmt.Errorf("unable to load secret '%s' from secrets manager: %v", bvc.SecretARN, err) - }*/ - - /*if bvc.SecretJSONKey != "" { - jsonData := make(map[string]string) - json.Unmarshal([]byte(smval), &jsonData) - - return jsonData[smval], nil - } else { - return smval, nil - }*/ - - return bvc.SecretARN, nil - } - - // Could not process this variable, error time - return "", fmt.Errorf("unknown error occured while parsing the variable") - -} \ No newline at end of file diff --git a/go.mod b/go.mod index ed67966..5701a90 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.12.4 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.15.9 github.com/aws/aws-sdk-go-v2/service/sts v1.16.6 + github.com/stretchr/testify v1.7.1 gopkg.in/yaml.v3 v3.0.1 ) @@ -19,4 +20,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.5 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.11.7 // indirect github.com/aws/smithy-go v1.11.2 // indirect + github.com/davecgh/go-spew v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.1.0 // indirect ) diff --git a/go.sum b/go.sum index 42d115c..d1f90a0 100644 --- a/go.sum +++ b/go.sum @@ -22,17 +22,23 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.16.6 h1:aYToU0/iazkMY67/BYLt3r6/LT/m github.com/aws/aws-sdk-go-v2/service/sts v1.16.6/go.mod h1:rP1rEOKAGZoXp4iGDxSXFvODAtXpm34Egf0lL0eshaQ= github.com/aws/smithy-go v1.11.2 h1:eG/N+CcUMAvsdffgMvjMKwfyDzIkjM6pfxMJ8Mzc6mE= github.com/aws/smithy-go v1.11.2/go.mod h1:3xHYmszWVx2c0kIwQeEVf9uSm4fYZt67FBJnwub1bgM= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 6db19b3..88531c3 100644 --- a/main.go +++ b/main.go @@ -7,7 +7,7 @@ import ( "os" "os/exec" - "github.com/jeff-roche/biome/parser" + "github.com/jeff-roche/biome/src/services" ) var Version string @@ -39,16 +39,15 @@ func main() { log.Fatalln("No command provided") } - // Load the Biome configuration - err := parser.LoadBiome(*biomeName) - if err != nil { - log.Fatalf("unable to load biome '%s': %v", *biomeName, err) + // Setup the biome + biomeSvc := services.NewBiomeConfigurationService() + + if err := biomeSvc.LoadBiomeFromDefaults(*biomeName); err != nil { + log.Fatalln(err) } - // Setup and configure the Biome for command execution - err = parser.ConfigureBiome() - if err != nil { - log.Fatalf("unable to configure biome '%s': %v", parser.LoadedBiome.Name, err) + if err := biomeSvc.ActivateBiome(); err != nil { + log.Fatalln(err) } // Execute order 66 @@ -56,8 +55,7 @@ func main() { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin - err = cmd.Run() - if err != nil { + if err := cmd.Run(); err != nil { log.Fatal(err) } } diff --git a/parser/parser.go b/parser/parser.go deleted file mode 100644 index 1453ecb..0000000 --- a/parser/parser.go +++ /dev/null @@ -1,150 +0,0 @@ -package parser - -import ( - "bytes" - "fmt" - "io" - "log" - "os" - "os/user" - "path" - - "github.com/jeff-roche/biome/config" - - "gopkg.in/yaml.v3" -) - -var LoadedBiome *config.BiomeConfig -var BiomeConfigFileNames = []string{".biome.yaml", ".biome.yml"} - -// LoadBiome will find the nearest .biome.[yml|yaml] in this priority order -// - Current Directory -// - Home Directory -func LoadBiome(biomeName string) error { - biomeConfigPaths := []string{ - getCdFilePath(BiomeConfigFileNames[0]), - getCdFilePath(BiomeConfigFileNames[1]), - getHomeDirFilePath(BiomeConfigFileNames[0]), - getHomeDirFilePath(BiomeConfigFileNames[1]), - } - - // Reset the loaded biome - LoadedBiome = nil - - for _, fPath := range biomeConfigPaths { - err := tryLoadBiomeConfig(fPath, biomeName) - if err != nil { - continue - } - - // Was the biome loaded? - if LoadedBiome != nil { - return nil - } - } - - return fmt.Errorf("no biome configuration found") -} - -func ConfigureBiome() error { - // Did the Biome get configured? - if LoadedBiome == nil { - return fmt.Errorf("biome not configured") - } - - // Does an AWS session need to be configured? - if LoadedBiome.AwsProfile != "" { - err := config.ConfigureAwsEnvironment(LoadedBiome.AwsProfile) - if err != nil { - return fmt.Errorf("unable to configure AWS environment '%s': %v", LoadedBiome.AwsProfile, err) - } - } - - // Get the environment variables - envs := LoadedBiome.GetEnvs() - - // Set the environment variables - for key, val := range envs { - os.Setenv(key, val) - } - - return nil -} - -func tryLoadBiomeConfig(fPath string, biomeName string) error { - if !fileExists(fPath) { - return fmt.Errorf("could not find file '%s'", fPath) - } - - // Slurp slurp - biomeConfigContents, err := os.ReadFile(fPath) - if err != nil { - return fmt.Errorf("unable to load the biome config '%s': %v", fPath, err) - } - - // Loop over the file contents and try to parse out biomes - biome, err := findBiomeInFileContents(biomeConfigContents, biomeName) - if err != nil { - return fmt.Errorf("error searching for biome in '%s': %v", fPath, err) - } - - // Save off the biome if we found it - if biome != nil { - LoadedBiome = biome - } - - return nil -} - -func findBiomeInFileContents(data []byte, biomeName string) (*config.BiomeConfig, error) { - - // Setup the reader and decoder - r := bytes.NewReader(data) - decoder := yaml.NewDecoder(r) - - // Loop over the yaml documents in the file - for { - var biome config.BiomeConfig - - if err := decoder.Decode(&biome); err != nil { - if err != io.EOF { - return nil, err - } - - break - } - - if biome.Name == biomeName { - return &biome, nil - } - } - - return nil, nil -} - -func getCdFilePath(fname string) string { - cdPath, err := os.Getwd() - if err != nil { - log.Fatalf("unable to get current directory: %v", err) - } - - return path.Join(cdPath, fname) -} - -func getHomeDirFilePath(fname string) string { - currentUser, err := user.Current() - if err != nil { - log.Fatalf("unable to get current user: %v", err) - } - - return path.Join(currentUser.HomeDir, fname) -} - -func fileExists(path string) bool { - _, err := os.Stat(path) - if err != nil { - return false - } - - return true -} diff --git a/parser/types.go b/parser/types.go deleted file mode 100644 index 0986705..0000000 --- a/parser/types.go +++ /dev/null @@ -1,17 +0,0 @@ -package parser - -import "fmt" - -type BiomeNotFoundError struct { - Name string -} - -func NewBiomeNotFoundError(biomeName string) BiomeNotFoundError { - return BiomeNotFoundError{ - Name: biomeName, - } -} - -func (e BiomeNotFoundError) Error() string { - return fmt.Sprintf("unable to find Biome '%s'", e.Name) -} \ No newline at end of file diff --git a/src/lib/fileio/dir_helpers.go b/src/lib/fileio/dir_helpers.go new file mode 100644 index 0000000..0810957 --- /dev/null +++ b/src/lib/fileio/dir_helpers.go @@ -0,0 +1,25 @@ +package fileio + +import ( + "os" + "os/user" +) + +// GetHomeDir will get the path to the current users home directory +func GetHomeDir() (dir string, err error) { + var cu *user.User + + if cu, err = user.Current(); err != nil { + dir = "" + } else { + dir = cu.HomeDir + } + + return +} + +// GetCD will get the path to the current directory +// this is just a wrapper around os.Getwd() +func GetCD() (string, error) { + return os.Getwd() +} diff --git a/src/lib/fileio/file_helpers.go b/src/lib/fileio/file_helpers.go new file mode 100644 index 0000000..e7f74e6 --- /dev/null +++ b/src/lib/fileio/file_helpers.go @@ -0,0 +1,14 @@ +package fileio + +import ( + "os" +) + +// FileExists will simply tell you if the file can be found at the given path +func FileExists(fpath string) bool { + if _, err := os.Stat(fpath); err != nil { + return false + } + + return true +} diff --git a/src/lib/setters/basic_setter.go b/src/lib/setters/basic_setter.go new file mode 100644 index 0000000..2bb1b01 --- /dev/null +++ b/src/lib/setters/basic_setter.go @@ -0,0 +1,22 @@ +package setters + +import ( + "fmt" + "os" +) + +type BasicEnvironmentSetter struct { + Key string + Value string +} + +func NewBasicEnvironmentSetter(key string, value interface{}) *BasicEnvironmentSetter { + return &BasicEnvironmentSetter{ + Key: key, + Value: fmt.Sprint(value), + } +} + +func (s BasicEnvironmentSetter) SetEnv() error { + return os.Setenv(s.Key, s.Value) +} diff --git a/src/lib/setters/basic_setter_test.go b/src/lib/setters/basic_setter_test.go new file mode 100644 index 0000000..86f6874 --- /dev/null +++ b/src/lib/setters/basic_setter_test.go @@ -0,0 +1,41 @@ +package setters + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewBasicSetter(t *testing.T) { + testEnvKey := "FOOBAR_TEST_KEY" + t.Run("should set the key and value", func(t *testing.T) { + testVal := "BAZ" + s := NewBasicEnvironmentSetter(testEnvKey, testVal) + + assert.Equal(t, testEnvKey, s.Key) + assert.Equal(t, testVal, s.Value) + }) + + t.Run("should convert the value to a string", func(t *testing.T) { + testVal := 12345 + s := NewBasicEnvironmentSetter(testEnvKey, testVal) + + assert.Equal(t, testEnvKey, s.Key) + assert.Equal(t, "12345", s.Value) + }) +} + +func TestBasicSetter(t *testing.T) { + testEnvKey := "FOOBAR_TEST_KEY" + t.Run("should set the env", func(t *testing.T) { + testVal := "BAZ" + s := NewBasicEnvironmentSetter(testEnvKey, testVal) + err := s.SetEnv() + + assert.Nil(t, err) + assert.Equal(t, os.Getenv(testEnvKey), testVal) + }) + + os.Unsetenv(testEnvKey) +} diff --git a/src/lib/setters/secrets_manager_setter.go b/src/lib/setters/secrets_manager_setter.go new file mode 100644 index 0000000..dcaf512 --- /dev/null +++ b/src/lib/setters/secrets_manager_setter.go @@ -0,0 +1,120 @@ +package setters + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/jeff-roche/biome/src/repos" +) + +const SECRETS_MANAGER_ENV_ARN_KEY = "secret_arn" +const SECRETS_MANAGER_ENV_JSON_KEY = "secret_json_key" + +// secretsManagerClientIfc allows us to mock out the call to secrets manager + +// SecretsManagerEnvironmentSetter will set an environment variable +// from a JSON secret stored in AWS Secrets Manager +type SecretsManagerEnvironmentSetter struct { + EnvKey string // The environment variable key being set + ARN string // The secrets manager secret ARN to reference + SecretKey string // The JSON key in the secret to use + repo repos.SecretsManagerIfc // The Secrets manager client +} + +// NewSecretsManagerEnvironmentSetter will generete a SM Setter +func NewSecretsManagerEnvironmentSetter(key string, subkeys map[string]interface{}) (*SecretsManagerEnvironmentSetter, error) { + setter := &SecretsManagerEnvironmentSetter{ + EnvKey: key, + } + + // ARN + if val, exists := subkeys[SECRETS_MANAGER_ENV_ARN_KEY]; exists { + setter.ARN = val.(string) + } + + // JSON Key + if val, exists := subkeys[SECRETS_MANAGER_ENV_JSON_KEY]; exists { + setter.SecretKey = val.(string) + } + + // Secrets Manager Repo + var err error + setter.repo, err = repos.NewSecretsManagerRepo() + + if err != nil { + return nil, fmt.Errorf("unable to initialize the secrets manager repository: %v", err) + } + + return setter, nil +} + +func (s SecretsManagerEnvironmentSetter) SetEnv() error { + // Get the value from Secrets Manager + if s.ARN == "" { + return NewSecretsManagerEnvironmentSetterError( + s.EnvKey, + fmt.Sprintf("no Secrets Manager ARN specified. Please use '%s' to specify one", SECRETS_MANAGER_ENV_ARN_KEY), + ) + } + + secret, err := s.repo.GetSecretString(s.ARN) + if err != nil { + return NewSecretsManagerEnvironmentSetterError( + s.EnvKey, + fmt.Sprintf("unable to load secret '%s': %v", s.ARN, err), + ) + } + + // Validate the output + if secret == "" { + return NewSecretsManagerEnvironmentSetterError( + s.EnvKey, + fmt.Sprintf("only string based secrets are currently supported"), + ) + } + + // Get value from the JSON key + if s.SecretKey == "" { + return NewSecretsManagerEnvironmentSetterError( + s.EnvKey, + fmt.Sprintf("no JSON key for the secret was specified. Please use '%s' to specify one", SECRETS_MANAGER_ENV_JSON_KEY), + ) + } + + var jsonData map[string]string + if err := json.Unmarshal([]byte(secret), &jsonData); err != nil { + return NewSecretsManagerEnvironmentSetterError( + s.EnvKey, + fmt.Sprintf("unable to parse secret '%s' JSON: %v", s.ARN, err), + ) + } + + // Check and set the key + if val, exists := jsonData[s.SecretKey]; exists { + os.Setenv(s.EnvKey, val) + } else { + return NewSecretsManagerEnvironmentSetterError( + s.EnvKey, + fmt.Sprintf("secret '%s' does not contain JSON key '%s'", s.ARN, s.SecretKey), + ) + } + + return nil +} + +type SecretsManagerEnvironmentSetterError struct { + varName string + value string +} + +func (e SecretsManagerEnvironmentSetterError) Error() string { + return fmt.Sprintf("error setting env var '%s' from Secrets Manager value: %s", e.varName, e.value) +} + +func NewSecretsManagerEnvironmentSetterError(variable string, err string) error { + return SecretsManagerEnvironmentSetterError{ + varName: variable, + value: err, + } +} diff --git a/src/lib/setters/secrets_manager_setter_test.go b/src/lib/setters/secrets_manager_setter_test.go new file mode 100644 index 0000000..5edb146 --- /dev/null +++ b/src/lib/setters/secrets_manager_setter_test.go @@ -0,0 +1,180 @@ +package setters + +import ( + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type mockSecretsManagerRepo struct { + mock.Mock +} + +func (r mockSecretsManagerRepo) GetSecretString(val string) (string, error) { + args := r.Called(val) + return args.String(0), args.Error(1) +} + +func TestSecretsManagerSetterBuilder(t *testing.T) { + t.Run("should set all the keys specified", func(t *testing.T) { + // Assemble + envKey := "MY_ENV_VAR" + configKeys := make(map[string]interface{}) + configKeys[SECRETS_MANAGER_ENV_ARN_KEY] = "myArn" + configKeys[SECRETS_MANAGER_ENV_JSON_KEY] = "myJsonKey" + + // Act + setter, err := NewSecretsManagerEnvironmentSetter( + envKey, + configKeys, + ) + + // Assert + assert.Nil(t, err) + assert.Equal(t, envKey, setter.EnvKey) + assert.Equal(t, configKeys[SECRETS_MANAGER_ENV_ARN_KEY], setter.ARN) + assert.Equal(t, configKeys[SECRETS_MANAGER_ENV_JSON_KEY], setter.SecretKey) + assert.NotNil(t, setter.repo) + }) + + t.Run("should not set keys that are not specified", func(t *testing.T) { + // Assemble + envKey := "MY_ENV_VAR" + configKeys := make(map[string]interface{}) + + // Act + setter, err := NewSecretsManagerEnvironmentSetter( + envKey, + configKeys, + ) + + // Assert + assert.Nil(t, err) + assert.Empty(t, setter.ARN) + assert.Empty(t, setter.SecretKey) + assert.NotNil(t, setter.repo) + }) +} + +func TestSecretsManagerSetter(t *testing.T) { + testARN := "myARN" + testJSONKey := "mykey" + testJSONValue := "myvalue" + testJSON := fmt.Sprintf(`{"%s": "%s"}`, testJSONKey, testJSONValue) + testEnv := "BIOME_TEST_ENV_VAR" + + // Helper test builder function for setup + getTestSetter := func(mockRepo *mockSecretsManagerRepo) *SecretsManagerEnvironmentSetter { + return &SecretsManagerEnvironmentSetter{ + EnvKey: testEnv, + ARN: testARN, + SecretKey: testJSONKey, + repo: mockRepo, + } + } + + t.Run("should set the env var from the returned JSON", func(t *testing.T) { + // Assemble + mockRepo := &mockSecretsManagerRepo{} + mockRepo.On("GetSecretString", testARN).Return(testJSON, nil) + setter := getTestSetter(mockRepo) + + t.Cleanup(func() { + os.Unsetenv(testEnv) + }) + + // Act + err := setter.SetEnv() + + // Assert + assert.Nil(t, err) + assert.Equal(t, testJSONValue, os.Getenv(testEnv)) + }) + + t.Run("should report an error if no ARN is specified", func(t *testing.T) { + // Assemble + setter := getTestSetter(nil) + setter.ARN = "" + + // Act + err := setter.SetEnv() + + // Assert + assert.NotNil(t, err) + assert.ErrorContains(t, err, "ARN") + }) + + t.Run("should report an error getting the secret", func(t *testing.T) { + // Assemble + customErr := "unable to get secret" + mockRepo := &mockSecretsManagerRepo{} + mockRepo.On("GetSecretString", testARN).Return("", fmt.Errorf(customErr)) + setter := getTestSetter(mockRepo) + + // Act + err := setter.SetEnv() + + // Assert + assert.ErrorContains(t, err, customErr) + }) + + t.Run("should report an error if no secret is returned", func(t *testing.T) { + // Assemble + mockRepo := &mockSecretsManagerRepo{} + mockRepo.On("GetSecretString", testARN).Return("", nil) + setter := getTestSetter(mockRepo) + + // Act + err := setter.SetEnv() + + // Assert + assert.NotNil(t, err) + }) + + t.Run("should report an error if no JSON key is provided", func(t *testing.T) { + // Assemble + mockRepo := &mockSecretsManagerRepo{} + mockRepo.On("GetSecretString", testARN).Return(testJSON, nil) + setter := getTestSetter(mockRepo) + setter.SecretKey = "" + + // Act + err := setter.SetEnv() + + // Assert + assert.NotNil(t, err) + assert.ErrorContains(t, err, "JSON") + }) + + t.Run("should report an error if invalid JSON is returned", func(t *testing.T) { + // Assemble + mockRepo := &mockSecretsManagerRepo{} + mockRepo.On("GetSecretString", testARN).Return("I'm Not Valid }", nil) + setter := getTestSetter(mockRepo) + + // Act + err := setter.SetEnv() + + // Assert + assert.NotNil(t, err) + assert.ErrorContains(t, err, "unable to parse secret") + }) + + t.Run("should report an error if the specified JSON key does not exist", func(t *testing.T) { + // Assemble + mockRepo := &mockSecretsManagerRepo{} + mockRepo.On("GetSecretString", testARN).Return(testJSON, nil) + setter := getTestSetter(mockRepo) + setter.SecretKey = "invalidKey" + + // Act + err := setter.SetEnv() + + // Assert + assert.NotNil(t, err) + assert.ErrorContains(t, err, "does not contain JSON key") + }) +} diff --git a/src/lib/setters/setter_helpers.go b/src/lib/setters/setter_helpers.go new file mode 100644 index 0000000..454b2ed --- /dev/null +++ b/src/lib/setters/setter_helpers.go @@ -0,0 +1,31 @@ +package setters + +import ( + "fmt" +) + +func GetEnvironmentSetter(key string, node interface{}) (EnvironmentSetter, error) { + var setter EnvironmentSetter + switch node.(type) { + case map[string]interface{}: // Complex keys + var err error + setter, err = getComplexSetter(key, node.(map[string]interface{})) + if err != nil { + return nil, err + } + default: // basic types + setter = NewBasicEnvironmentSetter(key, node) + } + + return setter, nil +} + +func getComplexSetter(key string, node map[string]interface{}) (EnvironmentSetter, error) { + // Secrets Manager Secret + if _, exists := node[SECRETS_MANAGER_ENV_ARN_KEY]; exists { + + return NewSecretsManagerEnvironmentSetter(key, node) + } + + return nil, fmt.Errorf("unkown environment config for variable '%s'", key) +} diff --git a/src/lib/setters/setter_ifc.go b/src/lib/setters/setter_ifc.go new file mode 100644 index 0000000..f9cb0e5 --- /dev/null +++ b/src/lib/setters/setter_ifc.go @@ -0,0 +1,6 @@ +package setters + +// EnvironmentSetter is the minimum contract needed to set an environment variable of any kind +type EnvironmentSetter interface { + SetEnv() error +} diff --git a/src/lib/types/aws_env_config.go b/src/lib/types/aws_env_config.go new file mode 100644 index 0000000..d301d5d --- /dev/null +++ b/src/lib/types/aws_env_config.go @@ -0,0 +1,8 @@ +package types + +type AwsEnvConfig struct { + AccessKeyID string + SecretAccessKey string + SessionToken string + DefaultRegion string +} diff --git a/src/lib/types/biome.go b/src/lib/types/biome.go new file mode 100644 index 0000000..4b77b5a --- /dev/null +++ b/src/lib/types/biome.go @@ -0,0 +1,12 @@ +package types + +type Biome struct { + Config *BiomeConfig + SourceFile string +} + +type BiomeConfig struct { + Name string + AwsProfile string `yaml:"aws_profile"` + Environment map[string]interface{} `yaml:"environment"` +} diff --git a/src/repos/aws_secrets_manager.go b/src/repos/aws_secrets_manager.go new file mode 100644 index 0000000..9ab388a --- /dev/null +++ b/src/repos/aws_secrets_manager.go @@ -0,0 +1,58 @@ +package repos + +import ( + "context" + "fmt" + "os" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" +) + +// The interface for the AWS SDK Secrets Manager Service +type awsSecretsManagerServiceIfc interface { + GetSecretValue(ctx context.Context, params *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) +} + +// The interface for the Secrets Manager Repository +type SecretsManagerIfc interface { + GetSecretString(string) (string, error) +} + +// The Secrets Manager Repository for proxying requests to secrets manager +type SecretsManager struct { + client awsSecretsManagerServiceIfc +} + +// NewSecretsManagerRepo builds the SecretsManagerRepository and its dependencies +func NewSecretsManagerRepo() (*SecretsManager, error) { + // Setup the secrets manager client + cfg, err := config.LoadDefaultConfig( + context.TODO(), + config.WithDefaultRegion(os.Getenv("AWS_DEFAULT_REGION")), + ) + + if err != nil { + return nil, fmt.Errorf("unable to load AWS configuration: %v", err) + } + + return &SecretsManager{ + client: secretsmanager.NewFromConfig(cfg), + }, nil +} + +// GetSecretString will pull the secret from Secrets Manager and return the string value +func (smrepo SecretsManager) GetSecretString(arn string) (string, error) { + response, err := smrepo.client.GetSecretValue( + context.TODO(), + &secretsmanager.GetSecretValueInput{ + SecretId: &arn, + }, + ) + + if err != nil { + return "", fmt.Errorf("unable to retreive '%s' from secrets manager: %v", arn, err) + } + + return *response.SecretString, nil +} diff --git a/src/repos/aws_sts.go b/src/repos/aws_sts.go new file mode 100644 index 0000000..2f2afcb --- /dev/null +++ b/src/repos/aws_sts.go @@ -0,0 +1,101 @@ +package repos + +import ( + "context" + "os" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials/stscreds" + "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/jeff-roche/biome/src/lib/types" +) + +type AwsStsRepositoryIfc interface { + ConfigureSession(profile string) (*types.AwsEnvConfig, error) + SetAwsEnvs(*types.AwsEnvConfig) +} + +// AwsStsRepository handles setting up an AWS Session +type AwsStsRepository struct{} + +func NewAwsStsRepository() *AwsStsRepository { + return &AwsStsRepository{} +} + +// +func (repo AwsStsRepository) SetAwsEnvs(cfg *types.AwsEnvConfig) { + os.Setenv("AWS_ACCESS_KEY_ID", cfg.AccessKeyID) + os.Setenv("AWS_SECRET_ACCESS_KEY", cfg.SecretAccessKey) + os.Setenv("AWS_SESSION_TOKEN", cfg.SessionToken) + os.Setenv("AWS_DEFAULT_REGION", cfg.DefaultRegion) +} + +// Configure Session will setup an AWS Session and return the needed values for the environment variables +func (repo AwsStsRepository) ConfigureSession(profile string) (*types.AwsEnvConfig, error) { + // Load the profile + prof, err := repo.loadProfile(profile) + if err != nil { + return nil, err + } + + cfg, err := repo.loadAwsConfig(&prof) + if err != nil { + return nil, err + } + + creds, err := repo.setupAwsSession(&cfg, &prof) + if err != nil { + return nil, err + } + + return &types.AwsEnvConfig{ + AccessKeyID: creds.AccessKeyID, + SecretAccessKey: creds.SecretAccessKey, + SessionToken: creds.SessionToken, + DefaultRegion: prof.Region, + }, nil +} + +func (repo AwsStsRepository) loadProfile(profile string) (config.SharedConfig, error) { + profileCfg, err := config.LoadSharedConfigProfile(context.TODO(), profile) + if err != nil { + return config.SharedConfig{}, err + } + + return profileCfg, nil +} + +func (repo AwsStsRepository) loadAwsConfig(profCfg *config.SharedConfig) (aws.Config, error) { + return config.LoadDefaultConfig( + context.TODO(), + config.WithSharedConfigProfile(profCfg.Profile), + config.WithDefaultRegion(profCfg.Region), + config.WithAssumeRoleCredentialOptions( + func(aro *stscreds.AssumeRoleOptions) { + if profCfg.MFASerial != "" { + aro.SerialNumber = &profCfg.MFASerial + aro.TokenProvider = stscreds.StdinTokenProvider + } + }, + ), + ) +} + +func (repo AwsStsRepository) setupAwsSession(cfg *aws.Config, profile *config.SharedConfig) (aws.Credentials, error) { + // Setup an STS client + client := sts.NewFromConfig(*cfg) + + // Get the credentials using the role + cred_provider := stscreds.NewAssumeRoleProvider(client, profile.RoleARN) + + // Generate the temp credentials + creds, err := cred_provider.Retrieve(context.TODO()) + if err != nil { + return aws.Credentials{}, nil + } + + cfg.Credentials = aws.NewCredentialsCache(cred_provider) + + return creds, nil +} diff --git a/src/repos/biome_file_parser.go b/src/repos/biome_file_parser.go new file mode 100644 index 0000000..e8c864f --- /dev/null +++ b/src/repos/biome_file_parser.go @@ -0,0 +1,77 @@ +package repos + +import ( + "bytes" + "fmt" + "io" + "os" + + "github.com/jeff-roche/biome/src/lib/fileio" + "github.com/jeff-roche/biome/src/lib/types" + "gopkg.in/yaml.v3" +) + +type BiomeFileParserIfc interface { + FindBiome(biomeName string, searchFiles []string) (*types.Biome, error) +} + +type BiomeFileParser struct{} + +func NewBiomeFileParser() *BiomeFileParser { + return &BiomeFileParser{} +} + +func (parser BiomeFileParser) FindBiome(biomeName string, searchFiles []string) (*types.Biome, error) { + var biome types.Biome // Setup a new biome + + // If we have any errors with the file, continue on to the next one + for _, fPath := range searchFiles { + if !fileio.FileExists(fPath) { + continue + } + + // Open the file and try to load the biome from it + freader, err := os.Open(fPath) + if err != nil { + continue + } + + biomeConfig := parser.loadBiomeFromFile(biomeName, freader) + if biomeConfig != nil { + biome.Config = biomeConfig + biome.SourceFile = fPath + + return &biome, nil + } + } + + return nil, fmt.Errorf("unable to locate the '%s' biome", biomeName) +} + +// loadBiomeFromFile will search for the biome in the file and if it finds it will parse and return it +func (parser BiomeFileParser) loadBiomeFromFile(biomeName string, fcontents io.Reader) *types.BiomeConfig { + buff := new(bytes.Buffer) + buff.ReadFrom(fcontents) + + // Loop over the documents in the .biome.yaml config + reader := bytes.NewReader(buff.Bytes()) + decoder := yaml.NewDecoder(reader) + + for { + var biomeCfg types.BiomeConfig + + if err := decoder.Decode(&biomeCfg); err != nil { + if err != io.EOF { + continue + } + + break + } + + if biomeCfg.Name == biomeName { + return &biomeCfg + } + } + + return nil +} diff --git a/src/repos/testing_types.go b/src/repos/testing_types.go new file mode 100644 index 0000000..f5079c0 --- /dev/null +++ b/src/repos/testing_types.go @@ -0,0 +1,26 @@ +package repos + +import ( + "github.com/jeff-roche/biome/src/lib/types" + "github.com/stretchr/testify/mock" +) + +type MockBiomeFileParser struct { + mock.Mock +} + +func (m MockBiomeFileParser) FindBiome(biomeName string, searchFiles []string) (*types.Biome, error) { + args := m.Called(biomeName, searchFiles) + return args.Get(0).(*types.Biome), args.Error(1) +} + +type MockAwsStsRepository struct { + mock.Mock +} + +func (m MockAwsStsRepository) ConfigureSession(profile string) (*types.AwsEnvConfig, error) { + args := m.Called(profile) + return args.Get(0).(*types.AwsEnvConfig), args.Error(1) +} + +func (m MockAwsStsRepository) SetAwsEnvs(cfg *types.AwsEnvConfig) {} diff --git a/src/services/biome_configuration_service.go b/src/services/biome_configuration_service.go new file mode 100644 index 0000000..b927574 --- /dev/null +++ b/src/services/biome_configuration_service.go @@ -0,0 +1,102 @@ +package services + +import ( + "fmt" + "path" + + "github.com/jeff-roche/biome/src/lib/fileio" + "github.com/jeff-roche/biome/src/lib/setters" + "github.com/jeff-roche/biome/src/lib/types" + "github.com/jeff-roche/biome/src/repos" +) + +var defaultFileNames = []string{".biome.yaml", ".biome.yml"} + +// BiomeConfigurationService handles the loading and activation of biomes +type BiomeConfigurationService struct { + ActiveBiome *types.Biome + configFileRepo repos.BiomeFileParserIfc + awsStsRepo repos.AwsStsRepositoryIfc +} + +// NewBiomeConfigurationService is a builder function to generate the service +func NewBiomeConfigurationService() *BiomeConfigurationService { + return &BiomeConfigurationService{ + configFileRepo: repos.NewBiomeFileParser(), + awsStsRepo: repos.NewAwsStsRepository(), + } +} + +// LoadBiome will search for the biome in the default locations +// - Current directory .biome.[yaml|yml] +// - Current user's home directory .biome.[yaml|yml] +func (svc *BiomeConfigurationService) LoadBiomeFromDefaults(biomeName string) error { + // Setup the valid paths + var validPaths []string + if dir, err := fileio.GetCD(); err == nil { + for _, fname := range defaultFileNames { + validPaths = append(validPaths, path.Join(dir, fname)) + } + } + + if dir, err := fileio.GetHomeDir(); err == nil { + for _, fname := range defaultFileNames { + validPaths = append(validPaths, path.Join(dir, fname)) + } + } + + // Start blasting + biome, err := svc.configFileRepo.FindBiome(biomeName, validPaths) + if err != nil { + svc.ActiveBiome = nil + return err + } + + svc.ActiveBiome = biome + + return nil +} + +// LoadBiomeFromFile will search for the biome in the file specified +func (svc *BiomeConfigurationService) LoadBiomeFromFile(biomeName string, fpath string) error { + biome, err := svc.configFileRepo.FindBiome(biomeName, []string{fpath}) + if err != nil { + svc.ActiveBiome = nil + return err + } + + svc.ActiveBiome = biome + + return nil +} + +func (svc *BiomeConfigurationService) ActivateBiome() error { + if svc.ActiveBiome == nil { + return fmt.Errorf("no biome loaded") + } + + // AWS Profile Configuration + if svc.ActiveBiome.Config.AwsProfile != "" { + envCfg, err := svc.awsStsRepo.ConfigureSession(svc.ActiveBiome.Config.AwsProfile) + if err != nil { + return err + } + + svc.awsStsRepo.SetAwsEnvs(envCfg) + } + + // Loop over the envs and set them + for env, val := range svc.ActiveBiome.Config.Environment { + setter, err := setters.GetEnvironmentSetter(env, val) + if err != nil { + return fmt.Errorf("error setting '%s': %v", env, err) + } + + err = setter.SetEnv() + if err != nil { + return fmt.Errorf("error setting '%s': %v", env, err) + } + } + + return nil +} diff --git a/src/services/biome_configuration_service_test.go b/src/services/biome_configuration_service_test.go new file mode 100644 index 0000000..f855bd3 --- /dev/null +++ b/src/services/biome_configuration_service_test.go @@ -0,0 +1,193 @@ +package services + +import ( + "fmt" + "os" + "testing" + + "github.com/jeff-roche/biome/src/lib/types" + "github.com/jeff-roche/biome/src/repos" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestBiomeConfigurationService(t *testing.T) { + sourceFilePath := "aFilePath" + biomeName := "myBiome" + testEnv := "MY_TEST_ENV" + + getTestBiome := func() types.Biome { + return types.Biome{ + SourceFile: sourceFilePath, + Config: &types.BiomeConfig{ + Name: biomeName, + AwsProfile: "myProfile", + Environment: map[string]interface{}{ + testEnv: "my_test_env_var", + }, + }, + } + } + + t.Run("LoadBiomeFromDefaults", func(t *testing.T) { + + t.Run("should load and save the biome", func(t *testing.T) { + // Assemble + mockRepo := new(repos.MockBiomeFileParser) + + testBiome := getTestBiome() + + mockRepo.On("FindBiome", biomeName, mock.Anything).Return(&testBiome, nil) + testSvc := &BiomeConfigurationService{ + configFileRepo: mockRepo, + } + + // Act + err := testSvc.LoadBiomeFromDefaults(biomeName) + + // Assert + assert.Nil(t, err) + assert.True(t, assert.ObjectsAreEqual(testBiome.Config, testSvc.ActiveBiome.Config)) + assert.Equal(t, testBiome.SourceFile, testSvc.ActiveBiome.SourceFile) + }) + + t.Run("should report an error if the biome can not be found", func(t *testing.T) { + // Assemble + mockRepo := new(repos.MockBiomeFileParser) + testSvc := &BiomeConfigurationService{ + configFileRepo: mockRepo, + } + testErr := fmt.Errorf("biome not found") + + mockRepo.On("FindBiome", biomeName, mock.Anything).Return(&types.Biome{}, testErr) + + // Act + err := testSvc.LoadBiomeFromDefaults(biomeName) + + // Assert + assert.NotNil(t, err) + assert.ErrorIs(t, err, testErr) + assert.Nil(t, testSvc.ActiveBiome) + }) + + }) + + t.Run("LoadBiomeFromFile", func(t *testing.T) { + t.Run("should load and save the biome", func(t *testing.T) { + // Assemble + mockRepo := new(repos.MockBiomeFileParser) + testSvc := &BiomeConfigurationService{ + configFileRepo: mockRepo, + } + testBiome := getTestBiome() + + mockRepo.On("FindBiome", biomeName, mock.Anything).Return(&testBiome, nil) + + // Act + err := testSvc.LoadBiomeFromFile(biomeName, sourceFilePath) + + // Assert + assert.Nil(t, err) + assert.True(t, assert.ObjectsAreEqual(testBiome.Config, testSvc.ActiveBiome.Config)) + assert.Equal(t, testBiome.SourceFile, testSvc.ActiveBiome.SourceFile) + }) + + t.Run("should report an error if the biome can not be found", func(t *testing.T) { + // Assemble + mockRepo := new(repos.MockBiomeFileParser) + testSvc := &BiomeConfigurationService{ + configFileRepo: mockRepo, + } + testErr := fmt.Errorf("biome not found") + + mockRepo.On("FindBiome", biomeName, mock.Anything).Return(&types.Biome{}, testErr) + + // Act + err := testSvc.LoadBiomeFromFile(biomeName, sourceFilePath) + + // Assert + assert.NotNil(t, err) + assert.ErrorIs(t, err, testErr) + assert.Nil(t, testSvc.ActiveBiome) + }) + }) + + t.Run("ActivateBiome", func(t *testing.T) { + + t.Run("should set the environment variable", func(t *testing.T) { + // Assemble + b := getTestBiome() + + testSvc := &BiomeConfigurationService{ + ActiveBiome: &b, + } + + b.Config.AwsProfile = "" + + t.Cleanup(func() { + os.Unsetenv(testEnv) + }) + + // Act + err := testSvc.ActivateBiome() + + // Assert + assert.Nil(t, err) + assert.Equal(t, b.Config.Environment[testEnv], os.Getenv(testEnv)) + }) + + t.Run("should load the AWS environment", func(t *testing.T) { + // Assemble + b := getTestBiome() + b.Config.Environment = map[string]interface{}{} + mockRepo := repos.MockAwsStsRepository{} + + testSvc := &BiomeConfigurationService{ + ActiveBiome: &b, + awsStsRepo: &mockRepo, + } + + mockRepo.On("ConfigureSession", b.Config.AwsProfile).Return(&types.AwsEnvConfig{}, nil) + mockRepo.On("SetAwsEnvs", mock.Anything).Return() + + // Act + err := testSvc.ActivateBiome() + + // Assert + assert.Nil(t, err) + }) + + t.Run("should report an error if the biome is not loaded", func(t *testing.T) { + // Assemble + testSvc := &BiomeConfigurationService{} + + // Act + err := testSvc.ActivateBiome() + + // Assert + assert.NotNil(t, err) + }) + + t.Run("should report an error if the AWS profile can not be loaded", func(t *testing.T) { + // Assemble + b := getTestBiome() + mockRepo := repos.MockAwsStsRepository{} + testError := fmt.Errorf("my dummy error") + mockRepo.On("ConfigureSession", b.Config.AwsProfile).Return(&types.AwsEnvConfig{}, testError) + mockRepo.On("SetAwsEnvs", mock.Anything).Return() + + testSvc := &BiomeConfigurationService{ + ActiveBiome: &b, + awsStsRepo: &mockRepo, + } + + // Act + err := testSvc.ActivateBiome() + + // Assert + assert.NotNil(t, err) + assert.ErrorContains(t, err, testError.Error()) + }) + + }) +}