diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 36b19d3..43e7b60 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -3,8 +3,6 @@ name: CI on: pull_request: push: - branches: - - master jobs: ci: diff --git a/pkg/env/env.go b/pkg/env/env.go new file mode 100644 index 0000000..9c55abe --- /dev/null +++ b/pkg/env/env.go @@ -0,0 +1,102 @@ +package env + +import ( + "fmt" + "github.com/pkg/errors" + "regexp" + "strconv" + "strings" +) + +type Env struct{} + +func NewEnv() *Env { + return &Env{} +} + +// ToENV convert a map (JSON structure) to string +// (concatenated by \n) +// This function only support convert simple format +// i.e: string, number +// another types will be ignored +// it will be converted to empty string. +// The key and value will be concatenated by equal sign. +func (e *Env) ToENV(src map[string]interface{}) []byte { + var lines []string + + for key, value := range src { + var raw string + + switch t := value.(type) { + case int: + raw = addQuote(fmt.Sprintf("%v", value)) + case float64: + raw = addQuote(fmt.Sprintf("%v", value)) + case string: + raw = addQuote(fmt.Sprintf("%v", value)) + case bool: + raw = addQuote(fmt.Sprintf("%v", value)) + default: + raw = "" + fmt.Println("WARN: This type", t, "will be ignored") + } + lines = append(lines, fmt.Sprintf("export %s=%s", key, raw)) + } + + content := strings.Join(lines, "\n") + return []byte(content) +} + +// addQuote to input string if it contains special characters +func addQuote(s string) string { + var isValid = regexp.MustCompile(`^[a-zA-Z0-9.,_-]+$`).MatchString + if !isValid(s) { + s = strconv.Quote(s) + } + return s +} + +// ToJSON convert string lines to a map (JSON structure) +// Each line should be formatted like export key=value or key=value +// The value can be empty. The key should contain only +// alphabet, number and underscore. +// Comment lines will be ignored. +func (e *Env) ToJSON(src []string) (map[string]interface{}, error) { + content := make(map[string]interface{}) + var isValid = regexp.MustCompile(`^[a-zA-Z0-9_]+$`).MatchString + for _, line := range src { + line := strings.TrimSpace(line) + // Skip with line start with # or empty + if len(line) == 0 || strings.HasPrefix(line, "#") { + continue + } + + // Remove export + line = strings.TrimSpace(strings.TrimPrefix(line, "export")) + + v := strings.Split(line, "=") + // Validate key + if !isValid(v[0]) { + return nil, errors.New(fmt.Sprintf("Env: Key %s is invalid format", v[0])) + } + + value := "" + if len(v) > 1 { + value = strings.Join(v[1:], "=") + } + + content[v[0]] = trimQuotes(value) + } + + return content, nil +} + +// trimQuotes remove quote from string +func trimQuotes(s string) string { + if len(s) >= 2 { + if c := s[len(s)-1]; s[0] == c && (c == '"' || c == '\'') { + return s[1 : len(s)-1] + } + } + return s +} diff --git a/pkg/env/env_test.go b/pkg/env/env_test.go new file mode 100644 index 0000000..cb007a8 --- /dev/null +++ b/pkg/env/env_test.go @@ -0,0 +1,82 @@ +package env + +import ( + "github.com/stretchr/testify/assert" + "strings" + "testing" +) + +func TestEnv_ToENV(t *testing.T) { + t.Run("With success case: return byte array", func(tc *testing.T) { + e := NewEnv() + input := map[string]interface{}{ + "TEST": "TEST", + "DATA": []int{1, 2, 3}, + "STR": []string{"a", "b", "c"}, + "EMPTY": map[string]interface{}{"test": true}, + "FLOAT": 0.23, + "STR_WITH_SPACE": "string with space", + "BOOL": true, + } + + expected := map[string]bool{ + "export TEST=TEST": true, + "export BOOL=true": true, + "export DATA=": true, + "export STR=": true, + "export EMPTY=": true, + "export FLOAT=0.23": true, + "export STR_WITH_SPACE=\"string with space\"": true, + } + + actual := strings.Split(string(e.ToENV(input)), "\n") + + assert.Equal(tc, len(expected), len(actual)) + for _, a := range actual { + assert.Contains(tc, expected, a) + } + }) +} + +func TestEnv_ToJSON(t *testing.T) { + t.Run("With success case: return a map", func(tc *testing.T) { + e := NewEnv() + input := []string{ + "export TEST=true", + "export DATA=1,2,3", + "#export PASS=no", + "CI=true=test", + "T=", + "export T1='test data'", + "export T2=\"test data\"", + } + + expected := map[string]interface{}{ + "TEST": "true", + "DATA": "1,2,3", + "CI": "true=test", + "T": "", + "T1": "test data", + "T2": "test data", + } + + content, err := e.ToJSON(input) + assert.NoError(tc, err) + assert.Equal(tc, len(expected), len(content)) + + for k, v := range expected { + assert.Contains(tc, content, k) + assert.Equal(tc, v, content[k]) + } + }) + + t.Run("With error case: return an error key invalid", func(tc *testing.T) { + e := NewEnv() + input := []string{ + "export test = true", + } + _, err := e.ToJSON(input) + assert.Error(tc, err) + assert.Contains(tc, err.Error(), "Env: Key test is invalid format") + }) +} diff --git a/pkg/pull/converter.go b/pkg/pull/converter.go index 09ab546..98e78da 100644 --- a/pkg/pull/converter.go +++ b/pkg/pull/converter.go @@ -16,6 +16,8 @@ func NewConverter(format string) (Converter, error) { switch strings.ToLower(format) { case "tfvars": return NewTfvars(), nil + case "env": + return NewEnv(), nil default: return nil, errors.New(fmt.Sprintf("`%s` is not yet supported.", format)) } diff --git a/pkg/pull/env.go b/pkg/pull/env.go new file mode 100644 index 0000000..16b9ed3 --- /dev/null +++ b/pkg/pull/env.go @@ -0,0 +1,36 @@ +package pull + +import ( + "fmt" + "github.com/pkg/errors" + envlib "github.com/vietanhduong/vault-converter/pkg/env" + osext "github.com/vietanhduong/vault-converter/pkg/util/os" +) + +type env struct { + envLib *envlib.Env +} + +func NewEnv() Converter { + return &env{ + envLib: envlib.NewEnv(), + } +} + +// Convert a source to ENV file +// src input is a map. It should be a JSON format +// output should be an absolute path +func (e *env) Convert(src map[string]interface{}, output string) error { + + content := e.envLib.ToENV(src) + + if err := osext.MkdirP(output); err != nil { + return errors.Wrap(err, "Pull: Create folder failed") + } + + if err := osext.Write(content, output); err != nil { + return errors.Wrap(err, fmt.Sprintf("Pull: Write to %s failed", output)) + } + + return nil +} diff --git a/pkg/pull/env_test.go b/pkg/pull/env_test.go new file mode 100644 index 0000000..f85c888 --- /dev/null +++ b/pkg/pull/env_test.go @@ -0,0 +1,41 @@ +package pull + +import ( + "github.com/stretchr/testify/assert" + osext "github.com/vietanhduong/vault-converter/pkg/util/os" + "os" + "testing" +) + +func TestEnv_Convert(t *testing.T) { + dir := os.TempDir() + defer os.Remove(dir) + + t.Run("With success case: return no error", func(tc *testing.T) { + e := NewEnv() + raw := map[string]interface{}{ + "test": "this is a str", + "arr": []interface{}{ + map[string]interface{}{ + "node": 1, + "ready": true, + }, + map[string]interface{}{ + "node": 2, + "ready": false, + }, + }, + "bool_val": false, + } + outputPath := dir + "/.env" + defer os.Remove(outputPath) + err := e.Convert(raw, outputPath) + assert.NoError(tc, err) + + content, _ := osext.Cat(outputPath) + assert.Contains(tc, string(content), `export test="this is a str"`) + assert.Contains(tc, string(content), `export bool_val=false`) + assert.Contains(tc, string(content), `export arr=`) + }) + +} diff --git a/pkg/pull/tfvars_test.go b/pkg/pull/tfvars_test.go index 49f74fc..cc7afdc 100644 --- a/pkg/pull/tfvars_test.go +++ b/pkg/pull/tfvars_test.go @@ -7,7 +7,7 @@ import ( "testing" ) -func TestConvert(t *testing.T) { +func TestTfvars_Convert(t *testing.T) { dir := os.TempDir() defer os.Remove(dir) diff --git a/pkg/push/converter.go b/pkg/push/converter.go index 2efa8c7..6f736c3 100644 --- a/pkg/push/converter.go +++ b/pkg/push/converter.go @@ -17,6 +17,8 @@ func NewConverter(format string) (Converter, error) { switch strings.ToLower(format) { case ".tfvars": return NewTfvars(), nil + case ".env": + return NewEnv(), nil default: return nil, errors.New(fmt.Sprintf("`%s` is not yet supported.", format)) } diff --git a/pkg/push/env.go b/pkg/push/env.go new file mode 100644 index 0000000..deceddd --- /dev/null +++ b/pkg/push/env.go @@ -0,0 +1,30 @@ +package push + +import ( + "github.com/pkg/errors" + envlib "github.com/vietanhduong/vault-converter/pkg/env" + "strings" +) + +type env struct { + envLib *envlib.Env +} + +func NewEnv() Converter { + return &env{ + envLib: envlib.NewEnv(), + } +} + +// Convert an ENV file to JSON +func (e *env) Convert(src []byte) (map[string]interface{}, error){ + + lines := strings.Split(string(src), "\n") + + content, err := e.envLib.ToJSON(lines) + if err != nil { + return nil, errors.Wrap(err, "Push: Convert bytes to json failed") + } + + return content, nil +} diff --git a/pkg/push/env_test.go b/pkg/push/env_test.go new file mode 100644 index 0000000..77e5c61 --- /dev/null +++ b/pkg/push/env_test.go @@ -0,0 +1,35 @@ +package push + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestEnv_Convert(t *testing.T) { + t.Run("With success case: return a map", func(tc *testing.T) { + e := NewEnv() + content, err := e.Convert([]byte(`export test=true +data=1,2,3 +export str='this is test string' +`)) + assert.NoError(tc, err) + expected := map[string]interface{}{ + "test": "true", + "data": "1,2,3", + "str": "this is test string", + } + assert.Equal(tc, len(expected), len(content)) + for k, v := range expected { + assert.Contains(tc, content, k) + assert.Equal(tc, v, content[k]) + } + }) + + t.Run("With error case: src invalid", func(tc *testing.T) { + e := NewTfvars() + _, err := e.Convert([]byte(`export name = "test"`)) + assert.Error(tc, err) + assert.Contains(tc, err.Error(), "Push: Parse content to JSON failed") + + }) +} diff --git a/pkg/push/tfvars_test.go b/pkg/push/tfvars_test.go index 32a6121..d82c696 100644 --- a/pkg/push/tfvars_test.go +++ b/pkg/push/tfvars_test.go @@ -5,7 +5,7 @@ import ( "testing" ) -func TestConvert(t *testing.T) { +func TestTfvars_Convert(t *testing.T) { t.Run("With success case: return a map", func(tc *testing.T) { tf := NewTfvars() content, err := tf.Convert([]byte(`name = "test"