diff --git a/pkg/ssh/config/parse_flags.go b/pkg/ssh/config/parse_flags.go index 837fa66..36280bf 100644 --- a/pkg/ssh/config/parse_flags.go +++ b/pkg/ssh/config/parse_flags.go @@ -18,10 +18,8 @@ import ( "fmt" "io" "os" - "os/user" "path/filepath" "sort" - "strconv" "strings" "github.com/deckhouse/lib-dhctl/pkg/log" @@ -32,6 +30,8 @@ import ( "github.com/deckhouse/lib-connection/pkg/settings" "github.com/deckhouse/lib-connection/pkg/ssh/utils" "github.com/deckhouse/lib-connection/pkg/ssh/utils/terminal" + "github.com/deckhouse/lib-connection/pkg/utils/defaults" + "github.com/deckhouse/lib-connection/pkg/utils/env" ) const ( @@ -84,7 +84,7 @@ type Flags struct { forceNoPrivateKeys bool flagSet *flag.FlagSet - envExtractor *envExtractor + envExtractor *env.Extractor } func (f *Flags) IsConflictBetweenFlags() error { @@ -106,7 +106,7 @@ func (f *Flags) IsConflictBetweenFlags() error { func (f *Flags) FillDefaults() error { if len(f.PrivateKeysPaths) == 0 && !f.forceNoPrivateKeys { - home, err := getHomeDir(f.envExtractor) + home, err := defaults.HomeDir(f.envExtractor) if err != nil { return err } @@ -152,38 +152,35 @@ func (f *Flags) RewriteFromEnvs() error { return notInitializedError("envExtractor") } - f.envExtractor.Bool(ForceNoPrivateKeysEnv, &f.forceNoPrivateKeys) - - if !f.forceNoPrivateKeys { - isSet := f.envExtractor.Strings(AgentPrivateKeysEnv, &f.PrivateKeysPaths) - - if isSet && len(f.PrivateKeysPaths) == 0 { - f.forceNoPrivateKeys = true - } - } + privateKeysVal := env.NewVar(AgentPrivateKeysEnv, &f.PrivateKeysPaths) + + err := f.envExtractor.ExtractAllVars( + env.NewVar(BastionPortEnv, &f.BastionPort), + env.NewVar(PortEnv, &f.Port), + env.NewVar(ForceNoPrivateKeysEnv, &f.forceNoPrivateKeys), + privateKeysVal, + env.NewVar(BastionHostEnv, &f.BastionHost), + env.NewVar(BastionUserEnv, &f.BastionUser), + env.NewVar(UserEnv, &f.User), + env.NewVar(HostsEnv, &f.Hosts), + env.NewVar(ExtraArgsEnv, &f.ExtraArgs), + env.NewVar(ConnectionConfigEnv, &f.ConnectionConfigPath), + env.NewVar(LegacyModeEnv, &f.ForceLegacy), + env.NewVar(ModernModeEnv, &f.ForceModern), + env.NewVar(AskBastionPasswordEnv, &f.AskBastionPass), + env.NewVar(AskSudoPasswordEnv, &f.AskSudoPass), + ) - f.envExtractor.String(BastionHostEnv, &f.BastionHost) - f.envExtractor.String(BastionUserEnv, &f.BastionUser) - if _, err := f.envExtractor.Int(BastionPortEnv, &f.BastionPort); err != nil { + if err != nil { return err } - f.envExtractor.String(UserEnv, &f.User) - f.envExtractor.Strings(HostsEnv, &f.Hosts) - if _, err := f.envExtractor.Int(PortEnv, &f.Port); err != nil { - return err + if !f.forceNoPrivateKeys { + if privateKeysVal.Present && len(f.PrivateKeysPaths) == 0 { + f.forceNoPrivateKeys = true + } } - f.envExtractor.String(ExtraArgsEnv, &f.ExtraArgs) - - f.envExtractor.String(ConnectionConfigEnv, &f.ConnectionConfigPath) - - f.envExtractor.Bool(LegacyModeEnv, &f.ForceLegacy) - f.envExtractor.Bool(ModernModeEnv, &f.ForceModern) - - f.envExtractor.Bool(AskBastionPasswordEnv, &f.AskBastionPass) - f.envExtractor.Bool(AskSudoPasswordEnv, &f.AskSudoPass) - return nil } @@ -218,7 +215,7 @@ func (f *Flags) userExtractor() func() (string, error) { return *currentUser, nil } - userName, err := getCurrentUser(f.envExtractor) + userName, err := defaults.CurrentUserName(f.envExtractor) if err != nil { return "", err } @@ -231,7 +228,6 @@ func (f *Flags) userExtractor() func() (string, error) { type ( AskPasswordFunc func(promt string) ([]byte, error) - EnvsLookupFunc func(name string) (string, bool) PrivateKeyExtractorFunc func(path string, logger log.Logger) (password string, err error) ) @@ -239,7 +235,7 @@ type FlagsParser struct { envsPrefix string ask AskPasswordFunc sett settings.Settings - envsLookup EnvsLookupFunc + envsLookup env.EnvsLookupFunc // extractPrivateKey // custom extract content and password for private key file @@ -274,9 +270,7 @@ func NewFlagsParser(sett settings.Settings) *FlagsParser { // This method trim right all _ ang - symbols and spaces left and right // By default parser add _ after prefix for all env vars func (p *FlagsParser) WithEnvsPrefix(envsPrefix string) *FlagsParser { - envsPrefix = strings.TrimSpace(envsPrefix) - envsPrefix = strings.TrimRight(envsPrefix, "_-") - p.envsPrefix = envsPrefix + p.envsPrefix = env.SimplifyPrefix(envsPrefix) return p } @@ -290,7 +284,7 @@ func (p *FlagsParser) WithAsk(ask AskPasswordFunc) *FlagsParser { return p } -func (p *FlagsParser) WithEnvsLookup(lookup EnvsLookupFunc) *FlagsParser { +func (p *FlagsParser) WithEnvsLookup(lookup env.EnvsLookupFunc) *FlagsParser { if govalue.Nil(lookup) { p.sett.Logger().WarnF("Envs lookup function is nil. Skip set ask function.") return p @@ -607,8 +601,8 @@ func (p *FlagsParser) ParseFlagsAndExtractConfig(arguments []string, set *flag.F return p.ExtractConfigAfterParse(flags, opts...) } -func (p *FlagsParser) envsExtractor() *envExtractor { - return newEnvExtractor(p.envsPrefix, p.envsLookup) +func (p *FlagsParser) envsExtractor() *env.Extractor { + return env.NewExtractor(p.envsPrefix, p.envsLookup) } func (p *FlagsParser) readPrivateKeysFromFlags(flags *Flags, logger log.Logger) ([]AgentPrivateKey, error) { @@ -671,67 +665,6 @@ func (p *FlagsParser) getPasswordsFromUser(flags *Flags) (*passwordsFromUser, er return res, nil } -func getHomeDir(extractor *envExtractor) (string, error) { - home := "" - - extractor.StringWithoutPrefix("HOME", &home) - - if home == "" { - var err error - home, err = os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("Cannot get user home dir: %w", err) - } - - if home == "" { - return "", fmt.Errorf("Cannot get user home dir: empty after call os.UserHomeDir") - } - } - - var err error - home, err = filepath.Abs(home) - if err != nil { - return "", fmt.Errorf("Cannot get absolute path of home directory: %w", err) - } - - stat, err := os.Stat(home) - if err != nil { - return "", fmt.Errorf("Cannot get user home dir stat: %w", err) - } - - if !stat.IsDir() { - return "", fmt.Errorf("Cannot get user home dir: '%s' not a directory", home) - } - - return home, nil -} - -// getCurrentUser -// returns current user name -// first attempt get user from env -// can be call multiple times because user.Current() cache user info -func getCurrentUser(extractor *envExtractor) (string, error) { - userName := "" - - extractor.StringWithoutPrefix("USER", &userName) - - if userName != "" { - return userName, nil - } - - currentUser, err := user.Current() - if err != nil { - return "", fmt.Errorf("cannot get current user: %w", err) - } - - userName = currentUser.Username - if userName == "" { - return "", fmt.Errorf("Cannot get current user: empty after call user.Current") - } - - return userName, nil -} - func fileReader(path string, fileType string) (io.ReadCloser, error) { fullPath, err := filepath.Abs(path) if err != nil { @@ -750,113 +683,6 @@ func fileReader(path string, fileType string) (io.ReadCloser, error) { return os.Open(fullPath) } -type envExtractor struct { - prefix string - lookupFunc func(string) (string, bool) -} - -func newEnvExtractor(prefix string, lookupFunc EnvsLookupFunc) *envExtractor { - return &envExtractor{ - prefix: prefix, - lookupFunc: lookupFunc, - } -} - -func (e *envExtractor) NameWithPrefix(name string) string { - if e.prefix != "" { - name = fmt.Sprintf("%s_%s", e.prefix, name) - } - - return name -} - -func (e *envExtractor) AddEnvToUsage(usage string, envName string) string { - if envName == "" { - return usage - } - - return fmt.Sprintf("%s (Can rewrite with %s env)", usage, e.NameWithPrefix(envName)) -} - -func (e *envExtractor) Var(name string) (string, bool) { - return e.lookupFunc(e.NameWithPrefix(name)) -} - -func (e *envExtractor) VarWithoutPrefix(name string) (string, bool) { - return e.lookupFunc(name) -} - -func (e *envExtractor) Int(name string, destination *int) (bool, error) { - strVar, ok := e.Var(name) - if !ok { - return false, nil - } - - value, err := strconv.Atoi(strVar) - if err != nil { - return false, fmt.Errorf("Cannot convert '%s' to int for %s: %w", strVar, e.NameWithPrefix(name), err) - } - - *destination = value - - return true, nil -} - -func (e *envExtractor) StringWithoutPrefix(name string, destination *string) bool { - strVar, ok := e.VarWithoutPrefix(name) - if !ok { - return false - } - - *destination = strVar - - return true -} - -func (e *envExtractor) String(name string, destination *string) bool { - strVar, ok := e.Var(name) - if !ok { - return false - } - - *destination = strVar - - return true -} - -func (e *envExtractor) Strings(name string, destination *[]string) bool { - valsStr, ok := e.Var(name) - if !ok { - return false - } - - valsSplit := strings.Split(valsStr, ",") - vals := make([]string, 0, len(valsSplit)) - for _, v := range valsSplit { - if strings.TrimSpace(v) != "" { - vals = append(vals, v) - } - } - - *destination = vals - - return true -} - -// Bool -// returns that env is set -func (e *envExtractor) Bool(name string, destination *bool) bool { - strVar, ok := e.Var(name) - if !ok { - return false - } - value := strVar != "" - - *destination = value - - return true -} - func terminalPrivateKeyPasswordExtractor(path string, defaultPassword []byte, logger log.Logger) (string, error) { _, password, err := utils.ParseSSHPrivateKeyFile(path, string(defaultPassword), logger) diff --git a/pkg/ssh/config/parse_flags_test.go b/pkg/ssh/config/parse_flags_test.go index ec94248..e14017e 100644 --- a/pkg/ssh/config/parse_flags_test.go +++ b/pkg/ssh/config/parse_flags_test.go @@ -520,6 +520,7 @@ func TestParseFlags(t *testing.T) { envs: map[string]string{ "DHCTL_SSH_HOSTS": "192.168.0.2,192.168.0.3", "DHCTL_SSH_MODERN_MODE": "true", + "DHCTL_SSH_LEGACY_MODE": "false", "DHCTL_SSH_BASTION_PORT": "2200", }, diff --git a/pkg/utils/defaults/user.go b/pkg/utils/defaults/user.go new file mode 100644 index 0000000..a4055d3 --- /dev/null +++ b/pkg/utils/defaults/user.go @@ -0,0 +1,90 @@ +// Copyright 2026 Flant JSC +// +// 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 defaults + +import ( + "fmt" + "os" + "os/user" + "path/filepath" + + "github.com/deckhouse/lib-connection/pkg/utils/env" +) + +// HomeDir +// extract absolute user home dir in next order +// HOME env +// os.UserHomeDir +// also check that HOME is present and is dir +func HomeDir(extractor *env.Extractor) (string, error) { + home := "" + + extractor.StringWithoutPrefix("HOME", &home) + + if home == "" { + var err error + home, err = os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("Cannot get user home dir: %w", err) + } + + if home == "" { + return "", fmt.Errorf("Cannot get user home dir: empty after call os.UserHomeDir") + } + } + + var err error + home, err = filepath.Abs(home) + if err != nil { + return "", fmt.Errorf("Cannot get absolute path of home directory: %w", err) + } + + stat, err := os.Stat(home) + if err != nil { + return "", fmt.Errorf("Cannot get user home dir stat: %w", err) + } + + if !stat.IsDir() { + return "", fmt.Errorf("Cannot get user home dir: '%s' not a directory", home) + } + + return home, nil +} + +// CurrentUserName +// returns current username +// first attempt get user from env +// can be call multiple times because user.Current() cache user info +func CurrentUserName(extractor *env.Extractor) (string, error) { + userName := "" + + extractor.StringWithoutPrefix("USER", &userName) + + if userName != "" { + return userName, nil + } + + currentUser, err := user.Current() + if err != nil { + return "", fmt.Errorf("cannot get current user: %w", err) + } + + userName = currentUser.Username + if userName == "" { + return "", fmt.Errorf("Cannot get current user: empty after call user.Current") + } + + return userName, nil +} diff --git a/pkg/utils/env/extractor.go b/pkg/utils/env/extractor.go new file mode 100644 index 0000000..ecc7bfa --- /dev/null +++ b/pkg/utils/env/extractor.go @@ -0,0 +1,332 @@ +// Copyright 2026 Flant JSC +// +// 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 env + +import ( + "fmt" + "os" + "reflect" + "slices" + "strconv" + "strings" + + "github.com/hashicorp/go-multierror" + "github.com/name212/govalue" +) + +type ( + EnvsLookupFunc func(name string) (string, bool) +) + +// SimplifyPrefix +// This method trim right all _ ang - symbols and spaces left and right. +func SimplifyPrefix(prefix string) string { + prefix = strings.TrimSpace(prefix) + prefix = strings.TrimRight(prefix, "_-") + + return prefix +} + +type Extractor struct { + prefixSeparator string + sliceSeparator string + prefix string + lookupFunc func(string) (string, bool) +} + +// NewOsExtractor +// create extractor with os.LookupEnv lookup function +func NewOsExtractor(prefix string) *Extractor { + return NewExtractor(prefix, os.LookupEnv) +} + +// NewExtractor +// utils for extract env and pass to destination +// by default extractor use _ for separate prefix and env name +// if need we use WithPrefixSeparator method for set your own or set to empty +// by default slice string extractor split env string by , symbol +// if need we use WithSliceSeparator method for set your own slice separator +func NewExtractor(prefix string, lookupFunc EnvsLookupFunc) *Extractor { + return &Extractor{ + prefixSeparator: "_", + sliceSeparator: ",", + prefix: prefix, + lookupFunc: lookupFunc, + } +} + +// WithSliceSeparator +// set only not empty string +func (e *Extractor) WithSliceSeparator(s string) *Extractor { + if s != "" { + e.sliceSeparator = s + } + + return e +} + +func (e *Extractor) WithPrefixSeparator(s string) *Extractor { + e.prefixSeparator = s + return e +} + +func (e *Extractor) AddEnvToUsage(usage string, envName string) string { + if envName == "" { + return usage + } + + return fmt.Sprintf("%s (Can rewrite with %s env)", usage, e.nameWithPrefix(envName)) +} + +func (e *Extractor) Int(name string, destination *int) (bool, error) { + strVar, ok := e.getVar(name) + if !ok { + return false, nil + } + + value, err := strconv.Atoi(strVar) + if err != nil { + return false, fmt.Errorf("Cannot convert '%s' to int for %s: %w", strVar, e.nameWithPrefix(name), err) + } + + *destination = value + + return true, nil +} + +func (e *Extractor) StringWithoutPrefix(name string, destination *string) bool { + strVar, ok := e.lookupFunc(name) + if !ok { + return false + } + + *destination = strVar + + return true +} + +func (e *Extractor) String(name string, destination *string) bool { + strVar, ok := e.getVar(name) + if !ok { + return false + } + + *destination = strVar + + return true +} + +func (e *Extractor) Strings(name string, destination *[]string) bool { + valsStr, ok := e.getVar(name) + if !ok { + return false + } + + valsSplit := strings.Split(valsStr, e.sliceSeparator) + vals := make([]string, 0, len(valsSplit)) + for _, v := range valsSplit { + if strings.TrimSpace(v) != "" { + vals = append(vals, v) + } + } + + *destination = vals + + return true +} + +var falseBoolValues = []string{ + "false", + "no", + "none", + "0", +} + +// Bool +// trim spaces env and to lower value string +// lower value string "false" "no" "none" "0" interpreter as false +// returns that env is set +func (e *Extractor) Bool(name string, destination *bool) bool { + strVar, ok := e.getVar(name) + if !ok { + return false + } + + valueLower := strings.TrimSpace(strings.ToLower(strVar)) + + value := valueLower != "" + + if value && slices.Contains(falseBoolValues, valueLower) { + value = false + } + + *destination = value + + return true +} + +func (e *Extractor) nameWithPrefix(name string) string { + if e.prefix != "" { + name = fmt.Sprintf("%s%s%s", e.prefix, e.prefixSeparator, name) + } + + return name +} + +func (e *Extractor) getVar(name string) (string, bool) { + return e.lookupFunc(e.nameWithPrefix(name)) +} + +type Var struct { + Name string + Destination any + Present bool +} + +func NewVar(name string, destination any) *Var { + return &Var{ + Name: name, + Destination: destination, + } +} + +// ExtractAllVars +// same as ExtractAll but can pass as variadic arguments +func (e *Extractor) ExtractAllVars(vars ...*Var) error { + var errs *multierror.Error + appendError := func(envName string, msg string) { + errs = multierror.Append(errs, fmt.Errorf("%s for env variable '%s'", msg, envName)) + } + + names := make(map[string]int, len(vars)) + for _, v := range vars { + names[v.Name]++ + } + + for name, count := range names { + if count > 1 { + appendError(name, fmt.Sprintf("have multiple names %d", count)) + } + } + + if err := errs.ErrorOrNil(); err != nil { + return err + } + + for i, val := range vars { + name := val.Name + if name == "" { + appendError(fmt.Sprintf("vars[%d]", i), "name is empty") + continue + } + + valAny := val.Destination + + if govalue.Nil(valAny) { + appendError(name, "value is nil") + continue + } + + v := reflect.ValueOf(valAny) + + if v.Kind() != reflect.Ptr { + appendError(name, "value should be pointer") + continue + } + + if v.IsNil() { + appendError(name, "value is nil") + continue + } + + elem := v.Elem() + kind := v.Type().Elem().Kind() + switch kind { + case reflect.Int: + var destInt int + + present, err := e.Int(name, &destInt) + if err != nil { + appendError(name, err.Error()) + continue + } + + if present { + elem.SetInt(int64(destInt)) + val.Present = present + } + case reflect.String: + strDest := "" + val.Present = e.String(name, &strDest) + if val.Present { + elem.SetString(strDest) + } + case reflect.Bool: + var destBool bool + val.Present = e.Bool(name, &destBool) + if val.Present { + elem.SetBool(destBool) + } + case reflect.Slice: + if err := e.processSlice(name, elem, val); err != "" { + appendError(name, err) + } + default: + appendError(name, incorrectValErr(kind, false)) + } + } + + return errs.ErrorOrNil() +} + +// ExtractAll +// extract all envs from map +// if env present but have empty value set Var Present field to true +// you can process it if need +// ExtractAll found need type for Destination, but destination should be pointer +// Warning! if error returned some Destination can be set +func (e *Extractor) ExtractAll(vars []*Var) error { + return e.ExtractAllVars(vars...) +} + +func (e *Extractor) processSlice(name string, slice reflect.Value, val *Var) string { + kind := slice.Type().Elem().Kind() + switch kind { + case reflect.String: + var destStrSlice []string + val.Present = e.Strings(name, &destStrSlice) + if val.Present { + slice.Set(reflect.ValueOf(destStrSlice)) + } + default: + return incorrectValErr(kind, true) + } + + return "" +} + +func incorrectValErr(kind reflect.Kind, isSlice bool) string { + msg := []string{ + "incorrect value", + } + + if isSlice { + msg = append(msg, "slice") + } + + msg = append(msg, fmt.Sprintf("pointer type '%s'. Should be int, string, bool or []string", kind)) + + return strings.Join(msg, " ") +} diff --git a/pkg/utils/env/extractor_test.go b/pkg/utils/env/extractor_test.go new file mode 100644 index 0000000..ad6ec6c --- /dev/null +++ b/pkg/utils/env/extractor_test.go @@ -0,0 +1,435 @@ +// Copyright 2026 Flant JSC +// +// 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 env + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestExtractAll(t *testing.T) { + type someStruct struct { + Bool bool + String string + Int int + StringSlice []string + } + + prefix := "MY" + + appendPrefix := func(envs map[string]string) map[string]string { + res := make(map[string]string, len(envs)) + + for k, v := range envs { + res[fmt.Sprintf("%s_%s", prefix, k)] = v + } + + return res + } + + const ( + BoolEnv = "BOOL_VAR" + StringEnv = "STRING_VAR" + IntEnv = "INT_VAR" + SliceEnv = "SLICE_VAR" + ) + + getExtractor := func(envs map[string]string) *Extractor { + return NewExtractor(prefix, func(name string) (string, bool) { + value, ok := envs[name] + return value, ok + }) + } + + assertErr := func(e error, contains ...string) { + if len(contains) == 0 { + require.NoError(t, e, "should not fail") + return + } + + require.Error(t, e, "should fail") + for _, c := range contains { + require.Contains(t, e.Error(), c, "error should contains") + } + } + + t.Run("not parsed", func(t *testing.T) { + envsForErrorCases := appendPrefix(map[string]string{ + StringEnv: "incorrect", + IntEnv: "incorrect", + }) + + t.Run("has empty name", func(t *testing.T) { + var str string + var i int + + extractor := getExtractor(envsForErrorCases) + + err := extractor.ExtractAllVars( + NewVar(StringEnv, &str), + NewVar("", &i), + ) + + assertErr(err, "name is empty for env variable 'vars[1]'") + }) + + t.Run("not unique names", func(t *testing.T) { + dest := someStruct{} + + extractor := getExtractor(envsForErrorCases) + + err := extractor.ExtractAllVars( + NewVar(StringEnv, &dest.String), + NewVar(BoolEnv, &dest.Bool), + NewVar(StringEnv, &dest.String), + NewVar(IntEnv, &dest.Int), + NewVar(IntEnv, &dest.Int), + NewVar(SliceEnv, &dest.StringSlice), + NewVar(IntEnv, &dest.Int), + ) + + assertErr( + err, + "have multiple names 2 for env variable 'STRING_VAR'", + "have multiple names 3 for env variable 'INT_VAR'", + ) + }) + + t.Run("has nil", func(t *testing.T) { + var str string + + extractor := getExtractor(envsForErrorCases) + + err := extractor.ExtractAllVars( + NewVar(StringEnv, &str), + NewVar(IntEnv, nil), + ) + + assertErr(err, "value is nil for env variable 'INT_VAR'") + }) + + t.Run("has not ptr", func(t *testing.T) { + var str string + var i int + + extractor := getExtractor(envsForErrorCases) + + err := extractor.ExtractAllVars( + NewVar(StringEnv, &str), + NewVar(IntEnv, i), + ) + + assertErr(err, "value should be pointer for env variable 'INT_VAR'") + }) + + t.Run("has not supported type", func(t *testing.T) { + type myStruct struct { + Var int + } + + t.Run("scalar", func(t *testing.T) { + var str string + var my myStruct + + extractor := getExtractor(envsForErrorCases) + + err := extractor.ExtractAllVars( + NewVar(StringEnv, &str), + NewVar(IntEnv, &my), + ) + + assertErr(err, "incorrect value pointer type 'struct'. Should be int, string, bool or []string for env variable 'INT_VAR'") + }) + + t.Run("slice", func(t *testing.T) { + var str string + var my []myStruct + + extractor := getExtractor(envsForErrorCases) + + err := extractor.ExtractAllVars( + NewVar(StringEnv, &str), + NewVar(IntEnv, &my), + ) + + assertErr(err, "incorrect value slice pointer type 'struct'. Should be int, string, bool or []string for env variable 'INT_VAR'") + }) + }) + + t.Run("incorrect int", func(t *testing.T) { + dest := someStruct{} + + extractor := getExtractor(appendPrefix(map[string]string{ + IntEnv: "1not int", + })) + + err := extractor.ExtractAllVars( + NewVar(IntEnv, &dest.Int), + ) + + assertErr(err, "Cannot convert '1not int' to int for MY_INT_VAR") + }) + + t.Run("all errors present", func(t *testing.T) { + var i int + + extractor := getExtractor(envsForErrorCases) + + err := extractor.ExtractAllVars( + NewVar(StringEnv, nil), + NewVar(IntEnv, i), + ) + + assertErr( + err, + "value is nil for env variable 'STRING_VAR'", + "value should be pointer for env variable 'INT_VAR'", + ) + }) + }) + + t.Run("parsed", func(t *testing.T) { + t.Run("all", func(t *testing.T) { + dest := someStruct{} + + extractor := getExtractor(appendPrefix(map[string]string{ + StringEnv: "my string", + IntEnv: "22", + BoolEnv: "true", + SliceEnv: "first,second,third", + })) + + err := extractor.ExtractAllVars( + NewVar(StringEnv, &dest.String), + NewVar(IntEnv, &dest.Int), + NewVar(BoolEnv, &dest.Bool), + NewVar(SliceEnv, &dest.StringSlice), + ) + + assertErr(err) + + require.Equal(t, "my string", dest.String, "should set val") + require.Equal(t, 22, dest.Int, "should set val") + require.True(t, dest.Bool, "should set val") + require.Equal(t, []string{"first", "second", "third"}, dest.StringSlice, "should set val") + }) + + t.Run("partly set present", func(t *testing.T) { + dest := someStruct{} + + extractor := getExtractor(appendPrefix(map[string]string{ + BoolEnv: "", + SliceEnv: "", + })) + + presentVals := make([]*Var, 0, 2) + + boolVal := NewVar(BoolEnv, &dest.Bool) + presentVals = append(presentVals, boolVal) + + sliceVal := NewVar(SliceEnv, &dest.StringSlice) + presentVals = append(presentVals, sliceVal) + + notPresentsVals := make([]*Var, 0, 2) + + stringVal := NewVar(StringEnv, &dest.String) + notPresentsVals = append(notPresentsVals, stringVal) + + intVal := NewVar(IntEnv, &dest.Int) + notPresentsVals = append(notPresentsVals, intVal) + + err := extractor.ExtractAllVars( + stringVal, + intVal, + boolVal, + sliceVal, + ) + + assertErr(err) + + for _, val := range presentVals { + require.True(t, val.Present, "should set present for %s", val.Name) + } + + for _, val := range notPresentsVals { + require.False(t, val.Present, "should not set present for %s", val.Name) + } + + require.Empty(t, dest.String, "should not set val") + require.Equal(t, 0, dest.Int, "should not set val") + require.False(t, dest.Bool, "should set val") + require.Equal(t, make([]string, 0), dest.StringSlice, "should set val") + }) + + t.Run("with custom slice separator", func(t *testing.T) { + dest := someStruct{} + + extractor := getExtractor(appendPrefix(map[string]string{ + SliceEnv: "first;second;third", + })).WithSliceSeparator(";") + + err := extractor.ExtractAll([]*Var{ + NewVar(SliceEnv, &dest.StringSlice), + }) + + assertErr(err) + + require.Equal(t, []string{"first", "second", "third"}, dest.StringSlice, "should set val") + }) + + t.Run("with custom prefix separator", func(t *testing.T) { + dest := someStruct{} + + extractor := getExtractor(map[string]string{ + prefix + IntEnv: "-22", + }).WithPrefixSeparator("") + + err := extractor.ExtractAllVars( + NewVar(IntEnv, &dest.Int), + ) + + assertErr(err) + + require.Equal(t, -22, dest.Int, "should set val") + }) + + t.Run("no rewrite destination is env not present", func(t *testing.T) { + dest := someStruct{ + Bool: true, + String: "my string", + Int: 22, + StringSlice: []string{"first", "second"}, + } + + extractor := getExtractor(make(map[string]string)) + + err := extractor.ExtractAllVars( + NewVar(StringEnv, &dest.String), + NewVar(IntEnv, &dest.Int), + NewVar(BoolEnv, &dest.Bool), + NewVar(SliceEnv, &dest.StringSlice), + ) + + assertErr(err) + + require.Equal(t, "my string", dest.String, "should set rewrite val") + require.Equal(t, 22, dest.Int, "should set rewrite val") + require.True(t, dest.Bool, "should set rewrite val") + require.Equal(t, []string{"first", "second"}, dest.StringSlice, "should set rewrite val") + }) + }) +} + +func TestBool(t *testing.T) { + type testCase struct { + name string + envVar string + expected bool + } + + newCase := func(v string, expected bool) testCase { + return testCase{ + name: v, + envVar: v, + expected: expected, + } + } + + falseCase := func(v string) testCase { + return newCase(v, false) + } + + trueCase := func(v string) testCase { + return newCase(v, true) + } + + tests := []testCase{ + { + name: "empty", + envVar: "", + expected: false, + }, + + { + name: "all spaces", + envVar: " ", + expected: false, + }, + + falseCase("NO"), + falseCase("No"), + falseCase("no"), + falseCase("FALSE"), + falseCase("fALSe"), + falseCase("False"), + falseCase("false"), + falseCase("None"), + falseCase("NONE"), + falseCase("none"), + falseCase("0"), + + trueCase("TRUE"), + trueCase("tRUe"), + trueCase("True"), + trueCase("true"), + trueCase("not empty string"), + trueCase("not_empty_string"), + trueCase(" not empty string with spaces "), + } + + const envName = "TST_BOOL" + + getExtractor := func(envs map[string]string) *Extractor { + return NewExtractor("", func(name string) (string, bool) { + value, ok := envs[name] + return value, ok + }) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + envs := map[string]string{ + envName: tt.envVar, + } + + var b bool + + getExtractor(envs).Bool(envName, &b) + + require.Equal(t, tt.expected, b, "should set correct val") + }) + } + + t.Run("empty extract all present set", func(t *testing.T) { + envs := map[string]string{ + envName: "", + } + + var b bool + boolVal := NewVar(envName, &b) + + err := getExtractor(envs).ExtractAllVars(boolVal) + require.NoError(t, err, "should not return error") + + require.False(t, b, "should correct val") + require.True(t, boolVal.Present, "should correct val") + }) +} + +func TestSimplifyPrefix(t *testing.T) { + require.Equal(t, "MY", SimplifyPrefix(" MY_")) +}