Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(config): use viper.Unmarshal capabilities #1079

Merged
merged 14 commits into from
Oct 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ require (
github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3
github.com/lib/pq v1.10.7
github.com/mattn/go-sqlite3 v1.14.15
github.com/mitchellh/mapstructure v1.5.0
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/phyber/negroni-gzip v1.0.0
github.com/prometheus/client_golang v1.13.0
Expand All @@ -38,6 +39,7 @@ require (
go.opentelemetry.io/otel/sdk v1.11.0
go.opentelemetry.io/otel/trace v1.11.0
go.uber.org/zap v1.23.0
golang.org/x/exp v0.0.0-20221012211006-4de253d81b95
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f
google.golang.org/grpc v1.50.1
google.golang.org/protobuf v1.28.1
Expand Down Expand Up @@ -78,7 +80,6 @@ require (
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/moby/sys/mount v0.3.3 // indirect
github.com/moby/sys/mountinfo v0.6.2 // indirect
github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect
Expand Down Expand Up @@ -113,7 +114,6 @@ require (
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 // indirect
golang.org/x/exp v0.0.0-20210916165020-5cb4fee858ee // indirect
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e // indirect
golang.org/x/sys v0.0.0-20220919091848-fb04ddd9f9c8 // indirect
golang.org/x/text v0.3.7 // indirect
Expand Down
3 changes: 2 additions & 1 deletion go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1192,8 +1192,9 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20210916165020-5cb4fee858ee h1:qlrAyYdKz4o7rWVUjiKqQJMa4PEpd55fqBU8jpsl4Iw=
golang.org/x/exp v0.0.0-20210916165020-5cb4fee858ee/go.mod h1:a3o/VtDNHN+dCVLEpzjjUHOzR+Ln3DHX056ZPzoZGGA=
golang.org/x/exp v0.0.0-20221012211006-4de253d81b95 h1:sBdrWpxhGDdTAYNqbgBLAR+ULAPPhfgncLr1X0lyWtg=
golang.org/x/exp v0.0.0-20221012211006-4de253d81b95/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
Expand Down
99 changes: 36 additions & 63 deletions internal/config/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,73 +7,46 @@ import (
"github.com/spf13/viper"
)

const (
// configuration keys
cacheBackend = "cache.backend"
cacheEnabled = "cache.enabled"
cacheTTL = "cache.ttl"
cacheMemoryEnabled = "cache.memory.enabled" // deprecated in v1.10.0
cacheMemoryExpiration = "cache.memory.expiration" // deprecated in v1.10.0
cacheMemoryEvictionInterval = "cache.memory.eviction_interval"
cacheRedisHost = "cache.redis.host"
cacheRedisPort = "cache.redis.port"
cacheRedisPassword = "cache.redis.password"
cacheRedisDB = "cache.redis.db"
)

// CacheConfig contains fields, which enable and configure
// Flipt's various caching mechanisms.
//
// Currently, flipt support in-memory and redis backed caching.
type CacheConfig struct {
Enabled bool `json:"enabled"`
TTL time.Duration `json:"ttl,omitempty"`
Backend CacheBackend `json:"backend,omitempty"`
Memory MemoryCacheConfig `json:"memory,omitempty"`
Redis RedisCacheConfig `json:"redis,omitempty"`
Enabled bool `json:"enabled" mapstructure:"enabled"`
TTL time.Duration `json:"ttl,omitempty" mapstructure:"ttl"`
Backend CacheBackend `json:"backend,omitempty" mapstructure:"backend"`
Memory MemoryCacheConfig `json:"memory,omitempty" mapstructure:"memory"`
Redis RedisCacheConfig `json:"redis,omitempty" mapstructure:"redis"`
}

func (c *CacheConfig) init() (warnings []string, _ error) {
if viper.GetBool(cacheMemoryEnabled) { // handle deprecated memory config
c.Backend = CacheMemory
c.Enabled = true

warnings = append(warnings, deprecatedMsgMemoryEnabled)

if viper.IsSet(cacheMemoryExpiration) {
c.TTL = viper.GetDuration(cacheMemoryExpiration)
warnings = append(warnings, deprecatedMsgMemoryExpiration)
func (c *CacheConfig) setDefaults(v *viper.Viper) (warnings []string) {
v.SetDefault("cache", map[string]any{
"backend": CacheMemory,
"ttl": 1 * time.Minute,
"redis": map[string]any{
"host": "localhost",
"port": 6379,
},
"memory": map[string]any{
"eviction_interval": 5 * time.Minute,
},
})

if mem := v.Sub("cache.memory"); mem != nil {
mem.SetDefault("eviction_interval", 5*time.Minute)
// handle legacy memory structure
if mem.GetBool("enabled") {
warnings = append(warnings, deprecatedMsgMemoryEnabled)
// forcibly set top-level `enabled` to true
v.Set("cache.enabled", true)
// ensure ttl is mapped to the value at memory.expiration
v.RegisterAlias("cache.ttl", "cache.memory.expiration")
// ensure ttl default is set
v.SetDefault("cache.memory.expiration", 1*time.Minute)
}

} else if viper.IsSet(cacheEnabled) {
c.Enabled = viper.GetBool(cacheEnabled)
if viper.IsSet(cacheBackend) {
c.Backend = stringToCacheBackend[viper.GetString(cacheBackend)]
}
if viper.IsSet(cacheTTL) {
c.TTL = viper.GetDuration(cacheTTL)
}
}

if c.Enabled {
switch c.Backend {
case CacheRedis:
if viper.IsSet(cacheRedisHost) {
c.Redis.Host = viper.GetString(cacheRedisHost)
}
if viper.IsSet(cacheRedisPort) {
c.Redis.Port = viper.GetInt(cacheRedisPort)
}
if viper.IsSet(cacheRedisPassword) {
c.Redis.Password = viper.GetString(cacheRedisPassword)
}
if viper.IsSet(cacheRedisDB) {
c.Redis.DB = viper.GetInt(cacheRedisDB)
}
case CacheMemory:
if viper.IsSet(cacheMemoryEvictionInterval) {
c.Memory.EvictionInterval = viper.GetDuration(cacheMemoryEvictionInterval)
}
if mem.IsSet("expiration") {
warnings = append(warnings, deprecatedMsgMemoryExpiration)
}
}

Expand Down Expand Up @@ -113,14 +86,14 @@ var (

// MemoryCacheConfig contains fields, which configure in-memory caching.
type MemoryCacheConfig struct {
EvictionInterval time.Duration `json:"evictionInterval,omitempty"`
EvictionInterval time.Duration `json:"evictionInterval,omitempty" mapstructure:"eviction_interval"`
}

// RedisCacheConfig contains fields, which configure the connection
// credentials for redis backed caching.
type RedisCacheConfig struct {
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
Password string `json:"password,omitempty"`
DB int `json:"db,omitempty"`
Host string `json:"host,omitempty" mapstructure:"host"`
Port int `json:"port,omitempty" mapstructure:"port"`
Password string `json:"password,omitempty" mapstructure:"password"`
DB int `json:"db,omitempty" mapstructure:"db"`
}
198 changes: 104 additions & 94 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,123 +4,114 @@ import (
"encoding/json"
"fmt"
"net/http"
"reflect"
"strings"
"time"

"github.com/mitchellh/mapstructure"
"github.com/spf13/viper"
"golang.org/x/exp/constraints"
)

jaeger "github.com/uber/jaeger-client-go"
var decodeHooks = mapstructure.ComposeDecodeHookFunc(
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
stringToEnumHookFunc(stringToLogEncoding),
stringToEnumHookFunc(stringToCacheBackend),
stringToEnumHookFunc(stringToScheme),
stringToEnumHookFunc(stringToDatabaseProtocol),
)

// Config contains all of Flipts configuration needs.
//
// The root of this structure contains a collection of sub-configuration categories,
// along with a set of warnings derived once the configuration has been loaded.
//
// Each sub-configuration (e.g. LogConfig) optionally implements either or both of
// the defaulter or validator interfaces.
// Given the sub-config implements a `setDefaults(*viper.Viper) []string` method
// then this will be called with the viper context before unmarshalling.
// This allows the sub-configuration to set any appropriate defaults.
// Given the sub-config implements a `validate() error` method
// then this will be called after unmarshalling, such that the function can emit
// any errors derived from the resulting state of the configuration.
type Config struct {
Log LogConfig `json:"log,omitempty"`
UI UIConfig `json:"ui,omitempty"`
Cors CorsConfig `json:"cors,omitempty"`
Cache CacheConfig `json:"cache,omitempty"`
Server ServerConfig `json:"server,omitempty"`
Tracing TracingConfig `json:"tracing,omitempty"`
Database DatabaseConfig `json:"database,omitempty"`
Meta MetaConfig `json:"meta,omitempty"`
Log LogConfig `json:"log,omitempty" mapstructure:"log"`
UI UIConfig `json:"ui,omitempty" mapstructure:"ui"`
Cors CorsConfig `json:"cors,omitempty" mapstructure:"cors"`
Cache CacheConfig `json:"cache,omitempty" mapstructure:"cache"`
Server ServerConfig `json:"server,omitempty" mapstructure:"server"`
Tracing TracingConfig `json:"tracing,omitempty" mapstructure:"tracing"`
Database DatabaseConfig `json:"database,omitempty" mapstructure:"db"`
Meta MetaConfig `json:"meta,omitempty" mapstructure:"meta"`
Warnings []string `json:"warnings,omitempty"`
}

func Default() *Config {
return &Config{
Log: LogConfig{
Level: "INFO",
Encoding: LogEncodingConsole,
GRPCLevel: "ERROR",
},

UI: UIConfig{
Enabled: true,
},

Cors: CorsConfig{
Enabled: false,
AllowedOrigins: []string{"*"},
},

Cache: CacheConfig{
Enabled: false,
Backend: CacheMemory,
TTL: 1 * time.Minute,
Memory: MemoryCacheConfig{
EvictionInterval: 5 * time.Minute,
},
Redis: RedisCacheConfig{
Host: "localhost",
Port: 6379,
Password: "",
DB: 0,
},
},

Server: ServerConfig{
Host: "0.0.0.0",
Protocol: HTTP,
HTTPPort: 8080,
HTTPSPort: 443,
GRPCPort: 9000,
},

Tracing: TracingConfig{
Jaeger: JaegerTracingConfig{
Enabled: false,
Host: jaeger.DefaultUDPSpanServerHost,
Port: jaeger.DefaultUDPSpanServerPort,
},
},

Database: DatabaseConfig{
URL: "file:/var/opt/flipt/flipt.db",
MigrationsPath: "/etc/flipt/config/migrations",
MaxIdleConn: 2,
},

Meta: MetaConfig{
CheckForUpdates: true,
TelemetryEnabled: true,
StateDirectory: "",
},
}
}

func Load(path string) (*Config, error) {
viper.SetEnvPrefix("FLIPT")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
v := viper.New()
v.SetEnvPrefix("FLIPT")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()

viper.SetConfigFile(path)
v.SetConfigFile(path)

if err := viper.ReadInConfig(); err != nil {
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("loading configuration: %w", err)
}

cfg := Default()
for _, initializer := range []interface {
init() (warnings []string, err error)
}{
&cfg.Log,
&cfg.UI,
&cfg.Cors,
&cfg.Cache,
&cfg.Server,
&cfg.Tracing,
&cfg.Database,
&cfg.Meta,
} {
warnings, err := initializer.init()
if err != nil {
var (
cfg = &Config{}
fields = cfg.fields()
)

// set viper defaults per field
for _, defaulter := range fields.defaulters {
cfg.Warnings = append(cfg.Warnings, defaulter.setDefaults(v)...)
}

if err := v.Unmarshal(cfg, viper.DecodeHook(decodeHooks)); err != nil {
return nil, err
}

// run any validation steps
for _, validator := range fields.validators {
if err := validator.validate(); err != nil {
return nil, err
}

cfg.Warnings = append(cfg.Warnings, warnings...)
}

return cfg, nil
}

type defaulter interface {
setDefaults(v *viper.Viper) []string
}

type validator interface {
validate() error
}

type fields struct {
defaulters []defaulter
validators []validator
}

func (c *Config) fields() (fields fields) {
structVal := reflect.ValueOf(c).Elem()
for i := 0; i < structVal.NumField(); i++ {
field := structVal.Field(i).Addr().Interface()

if defaulter, ok := field.(defaulter); ok {
fields.defaulters = append(fields.defaulters, defaulter)
}

if validator, ok := field.(validator); ok {
fields.validators = append(fields.validators, validator)
}
}

return
}

func (c *Config) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var (
out []byte
Expand All @@ -143,3 +134,22 @@ func (c *Config) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
}

// stringToEnumHookFunc returns a DecodeHookFunc that converts strings to a target enum
func stringToEnumHookFunc[T constraints.Integer](mappings map[string]T) mapstructure.DecodeHookFunc {
return func(
f reflect.Type,
t reflect.Type,
data interface{}) (interface{}, error) {
if f.Kind() != reflect.String {
return data, nil
}
if t != reflect.TypeOf(T(0)) {
return data, nil
}

enum := mappings[data.(string)]

return enum, nil
}
}
Loading