diff --git a/db/AccessKey.go b/db/AccessKey.go index a95e41ab..2f72d3f3 100644 --- a/db/AccessKey.go +++ b/db/AccessKey.go @@ -198,7 +198,7 @@ func (key *AccessKey) SerializeSecret() error { return fmt.Errorf("invalid access token type") } - encryptionString := util.Config.GetAccessKeyEncryption() + encryptionString := util.Config.AccessKeyEncryption if encryptionString == "" { secret := base64.StdEncoding.EncodeToString(plaintext) @@ -258,7 +258,7 @@ func (key *AccessKey) unmarshalAppropriateField(secret []byte) (err error) { //} func (key *AccessKey) DeserializeSecret() error { - return key.DeserializeSecret2(util.Config.GetAccessKeyEncryption()) + return key.DeserializeSecret2(util.Config.AccessKeyEncryption) } func (key *AccessKey) DeserializeSecret2(encryptionString string) error { diff --git a/db/sql/SqlDb.go b/db/sql/SqlDb.go index 093fa099..6a391f30 100644 --- a/db/sql/SqlDb.go +++ b/db/sql/SqlDb.go @@ -150,7 +150,7 @@ func connect() (*sql.DB, error) { return nil, err } - dialect := cfg.Dialect.String() + dialect := cfg.Dialect return sql.Open(dialect, connectionString) } @@ -169,7 +169,7 @@ func createDb() error { return err } - conn, err := sql.Open(cfg.Dialect.String(), connectionString) + conn, err := sql.Open(cfg.Dialect, connectionString) if err != nil { return err } diff --git a/go.mod b/go.mod index 82af413a..39a1279c 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/robfig/cron/v3 v3.0.1 github.com/snikch/goodman v0.0.0-20171125024755-10e37e294daa github.com/spf13/cobra v1.2.1 + github.com/stretchr/testify v1.7.0 go.etcd.io/bbolt v1.3.2 golang.org/x/crypto v0.3.0 golang.org/x/oauth2 v0.7.0 @@ -32,6 +33,7 @@ require ( github.com/ProtonMail/go-crypto v0.0.0-20221026131551-cf6655e29de4 // indirect github.com/acomagu/bufpipe v1.0.3 // indirect github.com/cloudflare/circl v1.1.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-asn1-ber/asn1-ber v1.5.1 // indirect github.com/go-git/gcfg v1.5.0 // indirect @@ -47,6 +49,7 @@ require ( github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sergi/go-diff v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect @@ -58,4 +61,5 @@ require ( gopkg.in/airbrake/gobrake.v2 v2.0.9 // indirect gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v3 v3.0.0 // indirect ) diff --git a/go.sum b/go.sum index 7c973bbc..dbcf731d 100644 --- a/go.sum +++ b/go.sum @@ -168,7 +168,6 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -449,8 +448,6 @@ golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5o golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU= -golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= @@ -535,14 +532,11 @@ golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.2.0 h1:z85xZCsEl7bi/KwbNADeBYoOP0++7W1ipu+aGnpwzRM= -golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= @@ -555,7 +549,6 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/util/config.go b/util/config.go index a212a9c8..82737f3e 100644 --- a/util/config.go +++ b/util/config.go @@ -6,15 +6,18 @@ import ( "encoding/json" "errors" "fmt" - "github.com/google/go-github/github" "io" "net/url" "os" "os/exec" "path" "path/filepath" + "reflect" + "regexp" + "strconv" "strings" + "github.com/google/go-github/github" "github.com/gorilla/securecookie" ) @@ -24,21 +27,19 @@ var Cookie *securecookie.SecureCookie // WebHostURL is the public route to the semaphore server var WebHostURL *url.URL -type DbDriver string - const ( - DbDriverMySQL DbDriver = "mysql" - DbDriverBolt DbDriver = "bolt" - DbDriverPostgres DbDriver = "postgres" + DbDriverMySQL = "mysql" + DbDriverBolt = "bolt" + DbDriverPostgres = "postgres" ) type DbConfig struct { - Dialect DbDriver `json:"-"` + Dialect string `json:"-"` - Hostname string `json:"host"` - Username string `json:"user"` - Password string `json:"pass"` - DbName string `json:"name"` + Hostname string `json:"host" env:"SEMAPHORE_DB_HOST"` + Username string `json:"user" env:"SEMAPHORE_DB_USER"` + Password string `json:"pass" env:"SEMAPHORE_DB_PASS"` + DbName string `json:"name" env:"SEMAPHORE_DB"` Options map[string]string `json:"options"` } @@ -71,23 +72,32 @@ type oidcProvider struct { EmailClaim string `json:"email_claim"` } -type GitClientId string - const ( // GoGitClientId is builtin Git client. It is not require external dependencies and is preferred. // Use it if you don't need external SSH authorization. - GoGitClientId GitClientId = "go_git" + GoGitClientId = "go_git" // CmdGitClientId is external Git client. // Default Git client. It is use external Git binary to clone repositories. - CmdGitClientId GitClientId = "cmd_git" + CmdGitClientId = "cmd_git" ) +// // basic config validation using regex +// /* NOTE: other basic regex could be used: +// +// ipv4: ^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$ +// ipv6: ^(?:[A-Fa-f0-9]{1,4}:|:){3,7}[A-Fa-f0-9]{1,4}$ +// domain: ^([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,}$ +// path+filename: ^([\\/[a-zA-Z0-9_\\-${}:~]*]*\\/)?[a-zA-Z0-9\\.~_${}\\-:]*$ +// email address: ^(|.*@[A-Za-z0-9-\\.]*)$ +// +// */ + type RunnerSettings struct { - ApiURL string `json:"api_url"` - RegistrationToken string `json:"registration_token"` - ConfigFile string `json:"config_file"` + ApiURL string `json:"api_url" env:"SEMAPHORE_RUNNER_API_URL"` + RegistrationToken string `json:"registration_token" env:"SEMAPHORE_RUNNER_REGISTRATION_TOKEN"` + ConfigFile string `json:"config_file" env:"SEMAPHORE_RUNNER_CONFIG_FILE"` // OneOff indicates than runner runs only one job and exit - OneOff bool `json:"one_off"` + OneOff bool `json:"one_off" env:"SEMAPHORE_RUNNER_ONE_OFF"` } // ConfigType mapping between Config and the json file that sets it @@ -96,75 +106,74 @@ type ConfigType struct { BoltDb DbConfig `json:"bolt"` Postgres DbConfig `json:"postgres"` - Dialect DbDriver `json:"dialect"` + Dialect string `json:"dialect" rule:"^mysql|bolt|postgres$" env:"SEMAPHORE_DB_DIALECT"` // Format `:port_num` eg, :3000 // if : is missing it will be corrected - Port string `json:"port"` + Port string `json:"port" default:":3000" rule:"^:([0-9]{1,5})$" env:"SEMAPHORE_PORT"` // Interface ip, put in front of the port. // defaults to empty - Interface string `json:"interface"` + Interface string `json:"interface" env:"SEMAPHORE_INTERFACE"` // semaphore stores ephemeral projects here - TmpPath string `json:"tmp_path"` + TmpPath string `json:"tmp_path" default:"/tmp/semaphore" env:"SEMAPHORE_TMP_PATH"` // SshConfigPath is a path to the custom SSH config file. // Default path is ~/.ssh/config. - SshConfigPath string `json:"ssh_config_path"` + SshConfigPath string `json:"ssh_config_path" env:"SEMAPHORE_TMP_PATH"` - GitClientId GitClientId `json:"git_client"` + GitClientId string `json:"git_client" rule:"^go_git|cmd_git$" env:"SEMAPHORE_GIT_CLIENT" default:"cmd_git"` // web host - WebHost string `json:"web_host"` + WebHost string `json:"web_host" env:"SEMAPHORE_WEB_ROOT"` // cookie hashing & encryption - CookieHash string `json:"cookie_hash"` - CookieEncryption string `json:"cookie_encryption"` + CookieHash string `json:"cookie_hash" env:"SEMAPHORE_COOKIE_HASH"` + CookieEncryption string `json:"cookie_encryption" env:"SEMAPHORE_COOKIE_ENCRYPTION"` // AccessKeyEncryption is BASE64 encoded byte array used // for encrypting and decrypting access keys stored in database. - // Do not use it! Use method GetAccessKeyEncryption instead of it. - AccessKeyEncryption string `json:"access_key_encryption"` + AccessKeyEncryption string `json:"access_key_encryption" env:"SEMAPHORE_ACCESS_KEY_ENCRYPTION"` // email alerting - EmailAlert bool `json:"email_alert"` - EmailSender string `json:"email_sender"` - EmailHost string `json:"email_host"` - EmailPort string `json:"email_port"` - EmailUsername string `json:"email_username"` - EmailPassword string `json:"email_password"` - EmailSecure bool `json:"email_secure"` + EmailAlert bool `json:"email_alert" env:"SEMAPHORE_EMAIL_ALERT"` + EmailSender string `json:"email_sender" env:"SEMAPHORE_EMAIL_SENDER"` + EmailHost string `json:"email_host" env:"SEMAPHORE_EMAIL_HOST"` + EmailPort string `json:"email_port" rule:"^(|[0-9]{1,5})$" env:"SEMAPHORE_EMAIL_PORT"` + EmailUsername string `json:"email_username" env:"SEMAPHORE_EMAIL_USERNAME"` + EmailPassword string `json:"email_password" env:"SEMAPHORE_EMAIL_PASSWORD"` + EmailSecure bool `json:"email_secure" env:"SEMAPHORE_EMAIL_SECURE"` // ldap settings - LdapEnable bool `json:"ldap_enable"` - LdapBindDN string `json:"ldap_binddn"` - LdapBindPassword string `json:"ldap_bindpassword"` - LdapServer string `json:"ldap_server"` - LdapSearchDN string `json:"ldap_searchdn"` - LdapSearchFilter string `json:"ldap_searchfilter"` + LdapEnable bool `json:"ldap_enable" env:"SEMAPHORE_LDAP_ENABLE"` + LdapBindDN string `json:"ldap_binddn" env:"SEMAPHORE_LDAP_BIND_DN"` + LdapBindPassword string `json:"ldap_bindpassword" env:"SEMAPHORE_LDAP_BIND_PASSWORD"` + LdapServer string `json:"ldap_server" env:"SEMAPHORE_LDAP_SERVER"` + LdapSearchDN string `json:"ldap_searchdn" env:"SEMAPHORE_LDAP_SEARCH_DN"` + LdapSearchFilter string `json:"ldap_searchfilter" env:"SEMAPHORE_LDAP_SEARCH_FILTER"` LdapMappings ldapMappings `json:"ldap_mappings"` - LdapNeedTLS bool `json:"ldap_needtls"` + LdapNeedTLS bool `json:"ldap_needtls" env:"SEMAPHORE_LDAP_NEEDTLS"` // telegram and slack alerting - TelegramAlert bool `json:"telegram_alert"` - TelegramChat string `json:"telegram_chat"` - TelegramToken string `json:"telegram_token"` - SlackAlert bool `json:"slack_alert"` - SlackUrl string `json:"slack_url"` + TelegramAlert bool `json:"telegram_alert" env:"SEMAPHORE_TELEGRAM_ALERT"` + TelegramChat string `json:"telegram_chat" env:"SEMAPHORE_TELEGRAM_CHAT"` + TelegramToken string `json:"telegram_token" env:"SEMAPHORE_TELEGRAM_TOKEN"` + SlackAlert bool `json:"slack_alert" env:"SEMAPHORE_SLACK_ALERT"` + SlackUrl string `json:"slack_url" env:"SEMAPHORE_SLACK_URL"` // oidc settings OidcProviders map[string]oidcProvider `json:"oidc_providers"` // task concurrency - MaxParallelTasks int `json:"max_parallel_tasks"` + MaxParallelTasks int `json:"max_parallel_tasks" rule:"^[0-9]{1,10}$" env:"SEMAPHORE_MAX_PARALLEL_TASKS"` - RunnerRegistrationToken string `json:"runner_registration_token"` + RunnerRegistrationToken string `json:"runner_registration_token" env:"SEMAPHORE_RUNNER_REGISTRATION_TOKEN"` // feature switches - PasswordLoginDisable bool `json:"password_login_disable"` - NonAdminCanCreateProject bool `json:"non_admin_can_create_project"` + PasswordLoginDisable bool `json:"password_login_disable" env:"SEMAPHORE_PASSWORD_LOGIN_DISABLED"` + NonAdminCanCreateProject bool `json:"non_admin_can_create_project" env:"SEMAPHORE_NON_ADMIN_CAN_CREATE_PROJECT"` - UseRemoteRunner bool `json:"use_remote_runner"` + UseRemoteRunner bool `json:"use_remote_runner" env:"SEMAPHORE_USE_REMOTE_RUNNER"` Runner RunnerSettings `json:"runner"` } @@ -177,19 +186,14 @@ func (conf *ConfigType) ToJSON() ([]byte, error) { return json.MarshalIndent(&conf, " ", "\t") } -func (conf *ConfigType) GetAccessKeyEncryption() string { - ret := os.Getenv("SEMAPHORE_ACCESS_KEY_ENCRYPTION") - - if ret == "" { - ret = conf.AccessKeyEncryption - } - - return ret -} - // ConfigInit reads in cli flags, and switches actions appropriately on them func ConfigInit(configPath string) { - loadConfig(configPath) + fmt.Println("Loading config") + loadConfigFile(configPath) + loadConfigEnvironment() + loadConfigDefaults() + + fmt.Println("Validating config") validateConfig() var encryption []byte @@ -206,7 +210,7 @@ func ConfigInit(configPath string) { } } -func loadConfig(configPath string) { +func loadConfigFile(configPath string) { if configPath == "" { configPath = os.Getenv("SEMAPHORE_CONFIG_PATH") } @@ -216,7 +220,7 @@ func loadConfig(configPath string) { if configPath == "" { cwd, err := os.Getwd() - exitOnConfigError(err) + exitOnConfigFileError(err) paths := []string{ path.Join(cwd, "config.json"), "/usr/local/etc/semaphore/config.json", @@ -234,46 +238,215 @@ func loadConfig(configPath string) { decodeConfig(file) break } - exitOnConfigError(err) + exitOnConfigFileError(err) } else { p := configPath file, err := os.Open(p) - exitOnConfigError(err) + exitOnConfigFileError(err) decodeConfig(file) } } +func loadDefaultsToObject(obj interface{}) error { + var t = reflect.TypeOf(obj) + var v = reflect.ValueOf(obj) + + if t.Kind() == reflect.Ptr { + t = t.Elem() + v = reflect.Indirect(v) + } + + for i := 0; i < t.NumField(); i++ { + fieldType := t.Field(i) + fieldValue := v.Field(i) + + if fieldType.Type.Kind() == reflect.Struct { + err := loadDefaultsToObject(fieldValue.Addr()) + if err != nil { + return err + } + continue + } + + defaultVar := fieldType.Tag.Get("default") + if defaultVar == "" { + continue + } + + setConfigValue(fieldValue, defaultVar) + } + + return nil +} + +func loadConfigDefaults() { + + err := loadDefaultsToObject(Config) + if err != nil { + panic(err) + } +} + +func castStringToInt(value string) int { + + valueInt, err := strconv.Atoi(value) + if err != nil { + panic(err) + } + return valueInt + +} + +func castStringToBool(value string) bool { + + var valueBool bool + if value == "1" || strings.ToLower(value) == "true" { + valueBool = true + } else { + valueBool = false + } + return valueBool + +} + +func setConfigValue(attribute reflect.Value, value interface{}) { + + if attribute.IsValid() { + switch attribute.Kind() { + case reflect.Int: + if reflect.ValueOf(value).Kind() != reflect.Int { + value = castStringToInt(fmt.Sprintf("%v", reflect.ValueOf(value))) + } + case reflect.Bool: + if reflect.ValueOf(value).Kind() != reflect.Bool { + value = castStringToBool(fmt.Sprintf("%v", reflect.ValueOf(value))) + } + } + attribute.Set(reflect.ValueOf(value)) + } else { + panic(fmt.Errorf("got non-existent config attribute")) + } + +} + +func getConfigValue(path string) string { + + attribute := reflect.ValueOf(Config) + nested_path := strings.Split(path, ".") + + for i, nested := range nested_path { + attribute = reflect.Indirect(attribute).FieldByName(nested) + lastDepth := len(nested_path) == i+1 + if !lastDepth && attribute.Kind() != reflect.Struct || lastDepth && attribute.Kind() == reflect.Invalid { + panic(fmt.Errorf("got non-existent config attribute '%v'", path)) + } + } + + return fmt.Sprintf("%v", attribute) +} + +func validate(value interface{}) error { + var t = reflect.TypeOf(value) + var v = reflect.ValueOf(value) + + if t.Kind() == reflect.Ptr { + t = t.Elem() + v = reflect.Indirect(v) + } + + for i := 0; i < t.NumField(); i++ { + fieldType := t.Field(i) + fieldValue := v.Field(i) + + rule := fieldType.Tag.Get("rule") + if rule == "" { + continue + } + + var value string + + if fieldType.Type.Kind() == reflect.Int { + value = strconv.FormatInt(fieldValue.Int(), 10) + } else if fieldType.Type.Kind() == reflect.Uint { + value = strconv.FormatUint(fieldValue.Uint(), 10) + } else { + value = fieldValue.String() + } + + match, _ := regexp.MatchString(rule, value) + if !match { + return fmt.Errorf( + "value of field '%v' is not valid! (Must match regex: '%v')", + fieldType.Name, rule, + ) + } + } + + return nil +} + func validateConfig() { - validatePort() + err := validate(Config) - if len(Config.TmpPath) == 0 { - Config.TmpPath = "/tmp/semaphore" - } - - if Config.MaxParallelTasks < 1 { - Config.MaxParallelTasks = 10 - } -} - -func validatePort() { - - //TODO - why do we do this only with this variable? - if len(os.Getenv("PORT")) > 0 { - Config.Port = ":" + os.Getenv("PORT") - } - if len(Config.Port) == 0 { - Config.Port = ":3000" - } - if !strings.HasPrefix(Config.Port, ":") { - Config.Port = ":" + Config.Port - } -} - -func exitOnConfigError(err error) { if err != nil { - fmt.Println("Cannot Find configuration! Use --config parameter to point to a JSON file generated by `semaphore setup`.") - os.Exit(1) + panic(err) + } +} + +func loadEnvironmentToObject(obj interface{}) error { + var t = reflect.TypeOf(obj) + var v = reflect.ValueOf(obj) + + if t.Kind() == reflect.Ptr { + t = t.Elem() + v = reflect.Indirect(v) + } + + for i := 0; i < t.NumField(); i++ { + fieldType := t.Field(i) + fieldValue := v.Field(i) + + if fieldType.Type.Kind() == reflect.Struct { + err := loadEnvironmentToObject(fieldValue.Addr().Interface()) + if err != nil { + return err + } + continue + } + + envVar := fieldType.Tag.Get("env") + if envVar == "" { + continue + } + + envValue, exists := os.LookupEnv(envVar) + + if !exists { + continue + } + + setConfigValue(fieldValue, envValue) + } + + return nil +} + +func loadConfigEnvironment() { + err := loadEnvironmentToObject(Config) + if err != nil { + panic(err) + } +} + +func exitOnConfigError(msg string) { + fmt.Println(msg) + os.Exit(1) +} + +func exitOnConfigFileError(err error) { + if err != nil { + exitOnConfigError("Cannot Find configuration! Use --config parameter to point to a JSON file generated by `semaphore setup`.") } } @@ -335,11 +508,6 @@ func CheckUpdate() (updateAvailable *github.RepositoryRelease, err error) { return } -// String returns dialect name for GORP. -func (d DbDriver) String() string { - return string(d) -} - func (d *DbConfig) IsPresent() bool { return d.GetHostname() != "" } @@ -451,7 +619,7 @@ func (conf *ConfigType) PrintDbInfo() { } } -func (conf *ConfigType) GetDialect() (dialect DbDriver, err error) { +func (conf *ConfigType) GetDialect() (dialect string, err error) { if conf.Dialect == "" { switch { case conf.MySQL.IsPresent(): @@ -471,7 +639,7 @@ func (conf *ConfigType) GetDialect() (dialect DbDriver, err error) { } func (conf *ConfigType) GetDBConfig() (dbConfig DbConfig, err error) { - var dialect DbDriver + var dialect string dialect, err = conf.GetDialect() if err != nil { diff --git a/util/config_test.go b/util/config_test.go index 73bf866b..6ae8dbdc 100644 --- a/util/config_test.go +++ b/util/config_test.go @@ -1,28 +1,340 @@ package util import ( + "fmt" "os" + "reflect" "testing" ) -func TestValidatePort(t *testing.T) { +func mockError(msg string) { + panic(msg) +} - Config = new(ConfigType) - Config.Port = "" - validatePort() - if Config.Port != ":3000" { - t.Error("no port should get set to default") +func TestValidate(t *testing.T) { + var val struct { + Test string `rule:"^\\d+$"` } + val.Test = "45243524" - Config.Port = "4000" - validatePort() - if Config.Port != ":4000" { - t.Error("Port without : suffix should have it added") - } - - os.Setenv("PORT", "5000") - validatePort() - if Config.Port != ":5000" { - t.Error("Port value should be overwritten by env var, and it should be prefixed appropriately") + err := validate(val) + if err != nil { + t.Error(err) } } + +func TestLoadEnvironmentToObject(t *testing.T) { + var val struct { + Test string `env:"TEST_ENV_VAR"` + Subfield struct { + Value string `env:"TEST_VALUE_ENV_VAR"` + } + } + + err := os.Setenv("TEST_ENV_VAR", "758478") + if err != nil { + panic(err) + } + + err = os.Setenv("TEST_VALUE_ENV_VAR", "test_value") + if err != nil { + panic(err) + } + + err = loadEnvironmentToObject(&val) + if err != nil { + t.Error(err) + } + + if val.Test != "758478" { + t.Error("Invalid value") + } + + if val.Subfield.Value != "test_value" { + t.Error("Invalid value") + } +} + +func TestCastStringToInt(t *testing.T) { + + var errMsg string = "Cast string => int failed" + + if castStringToInt("5") != 5 { + t.Error(errMsg) + } + if castStringToInt("0") != 0 { + t.Error(errMsg) + } + if castStringToInt("-1") != -1 { + t.Error(errMsg) + } + if castStringToInt("999") != 999 { + t.Error(errMsg) + } + + defer func() { + if r := recover(); r == nil { + t.Errorf("Cast string => int did not panic on invalid input") + } + }() + castStringToInt("xxx") + +} + +func TestCastStringToBool(t *testing.T) { + + var errMsg string = "Cast string => bool failed" + + if castStringToBool("1") != true { + t.Error(errMsg) + } + if castStringToBool("0") != false { + t.Error(errMsg) + } + if castStringToBool("true") != true { + t.Error(errMsg) + } + if castStringToBool("false") != false { + t.Error(errMsg) + } + if castStringToBool("xxx") != false { + t.Error(errMsg) + } + if castStringToBool("") != false { + t.Error(errMsg) + } + +} + +func TestGetConfigValue(t *testing.T) { + + Config = new(ConfigType) + + var testPort string = "1337" + var testCookieHash string = "0Sn+edH3doJ4EO4Rl49Y0KrxjUkXuVtR5zKHGGWerxQ=" + var testMaxParallelTasks int = 5 + var testLdapNeedTls bool = true + var testDbHost string = "192.168.0.1" + + Config.Port = testPort + Config.CookieHash = testCookieHash + Config.MaxParallelTasks = testMaxParallelTasks + Config.LdapNeedTLS = testLdapNeedTls + Config.BoltDb.Hostname = testDbHost + + if getConfigValue("Port") != testPort { + t.Error("Could not get value for config attribute 'Port'!") + } + if getConfigValue("CookieHash") != testCookieHash { + t.Error("Could not get value for config attribute 'CookieHash'!") + } + if getConfigValue("MaxParallelTasks") != fmt.Sprintf("%v", testMaxParallelTasks) { + t.Error("Could not get value for config attribute 'MaxParallelTasks'!") + } + if getConfigValue("LdapNeedTLS") != fmt.Sprintf("%v", testLdapNeedTls) { + t.Error("Could not get value for config attribute 'LdapNeedTLS'!") + } + if getConfigValue("BoltDb.Hostname") != fmt.Sprintf("%v", testDbHost) { + t.Error("Could not get value for config attribute 'BoltDb.Hostname'!") + } + + defer func() { + if r := recover(); r == nil { + t.Error("Did not fail on non-existent config attribute!") + } + }() + getConfigValue("NotExistent") + + defer func() { + if r := recover(); r == nil { + t.Error("Did not fail on non-existent config attribute!") + } + }() + getConfigValue("Not.Existent") + +} + +func TestSetConfigValue(t *testing.T) { + + Config = new(ConfigType) + + configValue := reflect.ValueOf(Config).Elem() + + var testPort string = "1337" + var testCookieHash string = "0Sn+edH3doJ4EO4Rl49Y0KrxjUkXuVtR5zKHGGWerxQ=" + var testMaxParallelTasks int = 5 + var testLdapNeedTls bool = true + //var testDbHost string = "192.168.0.1" + var testEmailSecure string = "1" + var expectEmailSecure bool = true + + setConfigValue(configValue.FieldByName("Port"), testPort) + setConfigValue(configValue.FieldByName("CookieHash"), testCookieHash) + setConfigValue(configValue.FieldByName("MaxParallelTasks"), testMaxParallelTasks) + setConfigValue(configValue.FieldByName("LdapNeedTLS"), testLdapNeedTls) + //setConfigValue(configValue.FieldByName("BoltDb.Hostname"), testDbHost) + setConfigValue(configValue.FieldByName("EmailSecure"), testEmailSecure) + + if Config.Port != testPort { + t.Error("Could not set value for config attribute 'Port'!") + } + if Config.CookieHash != testCookieHash { + t.Error("Could not set value for config attribute 'CookieHash'!") + } + if Config.MaxParallelTasks != testMaxParallelTasks { + t.Error("Could not set value for config attribute 'MaxParallelTasks'!") + } + if Config.LdapNeedTLS != testLdapNeedTls { + t.Error("Could not set value for config attribute 'LdapNeedTls'!") + } + //if Config.BoltDb.Hostname != testDbHost { + // t.Error("Could not set value for config attribute 'BoltDb.Hostname'!") + //} + if Config.EmailSecure != expectEmailSecure { + t.Error("Could not set value for config attribute 'EmailSecure'!") + } + + defer func() { + if r := recover(); r == nil { + t.Error("Did not fail on non-existent config attribute!") + } + }() + setConfigValue(configValue.FieldByName("NotExistent"), "someValue") + + defer func() { + if r := recover(); r == nil { + t.Error("Did not fail on non-existent config attribute!") + } + }() + //setConfigValue(configValue.FieldByName("Not.Existent"), "someValue") + +} + +func TestLoadConfigEnvironmet(t *testing.T) { + + Config = new(ConfigType) + Config.Dialect = DbDriverBolt + + var envPort string = "1337" + var envCookieHash string = "0Sn+edH3doJ4EO4Rl49Y0KrxjUkXuVtR5zKHGGWerxQ=" + var envAccessKeyEncryption string = "1/wRYXQltDGwbzNZRP9ZfJb2IoWcn1hYrxA0vOdvVos=" + var envMaxParallelTasks string = "5" + var expectMaxParallelTasks int = 5 + var expectLdapNeedTls bool = true + var envLdapNeedTls string = "1" + var envDbHost string = "192.168.0.1" + + os.Setenv("SEMAPHORE_PORT", envPort) + os.Setenv("SEMAPHORE_COOKIE_HASH", envCookieHash) + os.Setenv("SEMAPHORE_ACCESS_KEY_ENCRYPTION", envAccessKeyEncryption) + os.Setenv("SEMAPHORE_MAX_PARALLEL_TASKS", envMaxParallelTasks) + os.Setenv("SEMAPHORE_LDAP_NEEDTLS", envLdapNeedTls) + os.Setenv("SEMAPHORE_DB_HOST", envDbHost) + + loadConfigEnvironment() + + if Config.Port != envPort { + t.Error("Setting 'Port' was not loaded from environment-vars!") + } + if Config.CookieHash != envCookieHash { + t.Error("Setting 'CookieHash' was not loaded from environment-vars!") + } + if Config.AccessKeyEncryption != envAccessKeyEncryption { + t.Error("Setting 'AccessKeyEncryption' was not loaded from environment-vars!") + } + if Config.MaxParallelTasks != expectMaxParallelTasks { + t.Error("Setting 'MaxParallelTasks' was not loaded from environment-vars!") + } + if Config.LdapNeedTLS != expectLdapNeedTls { + t.Error("Setting 'LdapNeedTLS' was not loaded from environment-vars!") + } + if Config.BoltDb.Hostname != envDbHost { + t.Error("Setting 'BoltDb.Hostname' was not loaded from environment-vars!") + } + + //if Config.MySQL.Hostname == envDbHost || Config.Postgres.Hostname == envDbHost { + // // inactive db-dialects could be set as they share the same env-vars; but should be ignored + // t.Error("DB-Hostname was loaded for inactive DB-dialects!") + //} + +} + +func TestLoadConfigDefaults(t *testing.T) { + + Config = new(ConfigType) + var errMsg string = "Failed to load config-default" + + loadConfigDefaults() + + if Config.Port != ":3000" { + t.Error(errMsg) + } + if Config.TmpPath != "/tmp/semaphore" { + t.Error(errMsg) + } +} + +func ensureConfigValidationFailure(t *testing.T, attribute string, value interface{}) { + + defer func() { + if r := recover(); r == nil { + t.Errorf( + "Config validation for attribute '%v' did not fail! (value '%v')", + attribute, value, + ) + } + }() + validateConfig() + +} + +func TestValidateConfig(t *testing.T) { + //assert := assert.New(t) + + Config = new(ConfigType) + + var testPort string = ":3000" + var testDbDialect = DbDriverBolt + var testCookieHash string = "0Sn+edH3doJ4EO4Rl49Y0KrxjUkXuVtR5zKHGGWerxQ=" + var testMaxParallelTasks int = 0 + + Config.Port = testPort + Config.Dialect = testDbDialect + Config.CookieHash = testCookieHash + Config.MaxParallelTasks = testMaxParallelTasks + Config.GitClientId = GoGitClientId + Config.CookieEncryption = testCookieHash + Config.AccessKeyEncryption = testCookieHash + validateConfig() + + Config.Port = "INVALID" + ensureConfigValidationFailure(t, "Port", Config.Port) + + Config.Port = ":100000" + ensureConfigValidationFailure(t, "Port", Config.Port) + Config.Port = testPort + + Config.MaxParallelTasks = -1 + ensureConfigValidationFailure(t, "MaxParallelTasks", Config.MaxParallelTasks) + + ensureConfigValidationFailure(t, "MaxParallelTasks", Config.MaxParallelTasks) + Config.MaxParallelTasks = testMaxParallelTasks + + //Config.CookieHash = "\"0Sn+edH3doJ4EO4Rl49Y0KrxjUkXuVtR5zKHGGWerxQ=\"" // invalid with quotes (can happen when supplied as env-var) + //ensureConfigValidationFailure(t, "CookieHash", Config.CookieHash) + + //Config.CookieHash = "!)394340" + //ensureConfigValidationFailure(t, "CookieHash", Config.CookieHash) + + //Config.CookieHash = "" + //ensureConfigValidationFailure(t, "CookieHash", Config.CookieHash) + + //Config.CookieHash = "TQwjDZ5fIQtaIw==" // valid b64, but too small + //ensureConfigValidationFailure(t, "CookieHash", Config.CookieHash) + Config.CookieHash = testCookieHash + + Config.Dialect = "someOtherDB" + ensureConfigValidationFailure(t, "Dialect", Config.Dialect) + Config.Dialect = testDbDialect + +}