From 2a1681792da877aa282a9b347faedb0ee85c7e06 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 24 Sep 2019 00:01:58 +0200 Subject: [PATCH] Add release level attribute to config option, improve internal handling --- config/get.go | 146 ++++++++++-- config/get_test.go | 144 +++++++++++- config/layers.go | 293 ------------------------- config/main.go | 1 + config/option.go | 41 ++-- config/persistence.go | 54 +++-- config/registry.go | 3 +- config/registry_test.go | 11 +- config/release.go | 57 +++++ config/set.go | 207 +++++++++++++++++ config/{layers_test.go => set_test.go} | 20 +- 11 files changed, 611 insertions(+), 366 deletions(-) delete mode 100644 config/layers.go create mode 100644 config/release.go create mode 100644 config/set.go rename config/{layers_test.go => set_test.go} (89%) diff --git a/config/get.go b/config/get.go index 867a27b..4862ae5 100644 --- a/config/get.go +++ b/config/get.go @@ -1,14 +1,7 @@ package config import ( - "sync" - - "github.com/tevino/abool" -) - -var ( - validityFlag = abool.NewBool(true) - validityFlagLock sync.RWMutex + "github.com/safing/portbase/log" ) type ( @@ -22,19 +15,6 @@ type ( BoolOption func() bool ) -func getValidityFlag() *abool.AtomicBool { - validityFlagLock.RLock() - defer validityFlagLock.RUnlock() - return validityFlag -} - -func resetValidityFlag() { - validityFlagLock.Lock() - defer validityFlagLock.Unlock() - validityFlag.SetTo(false) - validityFlag = abool.NewBool(true) -} - // GetAsString returns a function that returns the wanted string with high performance. func GetAsString(name string, fallback string) StringOption { valid := getValidityFlag() @@ -86,3 +66,127 @@ func GetAsBool(name string, fallback bool) BoolOption { return value } } + +// findValue find the correct value in the user or default config. +func findValue(key string) interface{} { + optionsLock.RLock() + option, ok := options[key] + optionsLock.RUnlock() + if !ok { + log.Errorf("config: request for unregistered option: %s", key) + return nil + } + + // lock option + option.Lock() + defer option.Unlock() + + // check if option is active + optionActive := true + switch getReleaseLevel() { + case ReleaseLevelStable: + // In stable, only stable is active + optionActive = option.ReleaseLevel == ReleaseLevelStable + case ReleaseLevelBeta: + // In beta, only stable and beta are active + optionActive = option.ReleaseLevel == ReleaseLevelStable || option.ReleaseLevel == ReleaseLevelBeta + case ReleaseLevelExperimental: + // In experimental, everything is active + optionActive = true + } + + if optionActive && option.activeValue != nil { + return option.activeValue + } + + if option.activeDefaultValue != nil { + return option.activeDefaultValue + } + + return option.DefaultValue +} + +// findStringValue validates and returns the value with the given key. +func findStringValue(key string, fallback string) (value string) { + result := findValue(key) + if result == nil { + return fallback + } + v, ok := result.(string) + if ok { + return v + } + return fallback +} + +// findStringArrayValue validates and returns the value with the given key. +func findStringArrayValue(key string, fallback []string) (value []string) { + result := findValue(key) + if result == nil { + return fallback + } + + v, ok := result.([]interface{}) + if ok { + new := make([]string, len(v)) + for i, val := range v { + s, ok := val.(string) + if ok { + new[i] = s + } else { + return fallback + } + } + return new + } + + return fallback +} + +// findIntValue validates and returns the value with the given key. +func findIntValue(key string, fallback int64) (value int64) { + result := findValue(key) + if result == nil { + return fallback + } + switch v := result.(type) { + case int: + return int64(v) + case int8: + return int64(v) + case int16: + return int64(v) + case int32: + return int64(v) + case int64: + return v + case uint: + return int64(v) + case uint8: + return int64(v) + case uint16: + return int64(v) + case uint32: + return int64(v) + case uint64: + return int64(v) + case float32: + return int64(v) + case float64: + return int64(v) + } + return fallback +} + +// findBoolValue validates and returns the value with the given key. +func findBoolValue(key string, fallback bool) (value bool) { + result := findValue(key) + if result == nil { + return fallback + } + v, ok := result.(bool) + if ok { + return v + } + return fallback +} diff --git a/config/get_test.go b/config/get_test.go index 0efc216..d092c85 100644 --- a/config/get_test.go +++ b/config/get_test.go @@ -24,13 +24,36 @@ func parseAndSetDefaultConfig(jsonData string) error { return SetDefaultConfig(m) } +func quickRegister(t *testing.T, key string, optType uint8, defaultValue interface{}) { + err := Register(&Option{ + Name: key, + Key: key, + Description: "test config", + ReleaseLevel: ReleaseLevelStable, + ExpertiseLevel: ExpertiseLevelUser, + OptType: optType, + DefaultValue: defaultValue, + }) + if err != nil { + t.Fatal(err) + } +} + func TestGet(t *testing.T) { + // reset + options = make(map[string]*Option) err := log.Start() if err != nil { t.Fatal(err) } + quickRegister(t, "monkey", OptTypeInt, -1) + quickRegister(t, "zebras/zebra", OptTypeStringArray, []string{"a", "b"}) + quickRegister(t, "elephant", OptTypeInt, -1) + quickRegister(t, "hot", OptTypeBool, false) + quickRegister(t, "cold", OptTypeBool, true) + err = parseAndSetConfig(` { "monkey": "1", @@ -59,27 +82,27 @@ func TestGet(t *testing.T) { monkey := GetAsString("monkey", "none") if monkey() != "1" { - t.Fatalf("monkey should be 1, is %s", monkey()) + t.Errorf("monkey should be 1, is %s", monkey()) } zebra := GetAsStringArray("zebras/zebra", []string{}) if len(zebra()) != 2 || zebra()[0] != "black" || zebra()[1] != "white" { - t.Fatalf("zebra should be [\"black\", \"white\"], is %v", zebra()) + t.Errorf("zebra should be [\"black\", \"white\"], is %v", zebra()) } elephant := GetAsInt("elephant", -1) if elephant() != 2 { - t.Fatalf("elephant should be 2, is %d", elephant()) + t.Errorf("elephant should be 2, is %d", elephant()) } hot := GetAsBool("hot", false) if !hot() { - t.Fatalf("hot should be true, is %v", hot()) + t.Errorf("hot should be true, is %v", hot()) } cold := GetAsBool("cold", true) if cold() { - t.Fatalf("cold should be false, is %v", cold()) + t.Errorf("cold should be false, is %v", cold()) } err = parseAndSetConfig(` @@ -92,19 +115,126 @@ func TestGet(t *testing.T) { } if monkey() != "3" { - t.Fatalf("monkey should be 0, is %s", monkey()) + t.Errorf("monkey should be 0, is %s", monkey()) } if elephant() != 0 { - t.Fatalf("elephant should be 0, is %d", elephant()) + t.Errorf("elephant should be 0, is %d", elephant()) } zebra() hot() + // concurrent + GetAsString("monkey", "none")() + GetAsStringArray("zebras/zebra", []string{})() + GetAsInt("elephant", -1)() + GetAsBool("hot", false)() + +} + +func TestReleaseLevel(t *testing.T) { + // reset + options = make(map[string]*Option) + registerReleaseLevelOption() + + // setup + subsystemOption := &Option{ + Name: "test subsystem", + Key: "subsystem/test", + Description: "test config", + ReleaseLevel: ReleaseLevelStable, + ExpertiseLevel: ExpertiseLevelUser, + OptType: OptTypeBool, + DefaultValue: false, + } + err := Register(subsystemOption) + if err != nil { + t.Fatal(err) + } + err = SetConfigOption("subsystem/test", true) + if err != nil { + t.Fatal(err) + } + testSubsystem := GetAsBool("subsystem/test", false) + + // test option level stable + subsystemOption.ReleaseLevel = ReleaseLevelStable + err = SetConfigOption(releaseLevelKey, ReleaseLevelStable) + if err != nil { + t.Fatal(err) + } + if !testSubsystem() { + t.Error("should be active") + } + err = SetConfigOption(releaseLevelKey, ReleaseLevelBeta) + if err != nil { + t.Fatal(err) + } + if !testSubsystem() { + t.Error("should be active") + } + err = SetConfigOption(releaseLevelKey, ReleaseLevelExperimental) + if err != nil { + t.Fatal(err) + } + if !testSubsystem() { + t.Error("should be active") + } + + // test option level beta + subsystemOption.ReleaseLevel = ReleaseLevelBeta + err = SetConfigOption(releaseLevelKey, ReleaseLevelStable) + if err != nil { + t.Fatal(err) + } + if testSubsystem() { + t.Errorf("should be inactive: opt=%s system=%s", subsystemOption.ReleaseLevel, releaseLevel) + } + err = SetConfigOption(releaseLevelKey, ReleaseLevelBeta) + if err != nil { + t.Fatal(err) + } + if !testSubsystem() { + t.Error("should be active") + } + err = SetConfigOption(releaseLevelKey, ReleaseLevelExperimental) + if err != nil { + t.Fatal(err) + } + if !testSubsystem() { + t.Error("should be active") + } + + // test option level experimental + subsystemOption.ReleaseLevel = ReleaseLevelExperimental + err = SetConfigOption(releaseLevelKey, ReleaseLevelStable) + if err != nil { + t.Fatal(err) + } + if testSubsystem() { + t.Error("should be inactive") + } + err = SetConfigOption(releaseLevelKey, ReleaseLevelBeta) + if err != nil { + t.Fatal(err) + } + if testSubsystem() { + t.Error("should be inactive") + } + err = SetConfigOption(releaseLevelKey, ReleaseLevelExperimental) + if err != nil { + t.Fatal(err) + } + if !testSubsystem() { + t.Error("should be active") + } } func BenchmarkGetAsStringCached(b *testing.B) { + // reset + options = make(map[string]*Option) + // Setup err := parseAndSetConfig(` { diff --git a/config/layers.go b/config/layers.go deleted file mode 100644 index 34f1dcb..0000000 --- a/config/layers.go +++ /dev/null @@ -1,293 +0,0 @@ -package config - -import ( - "errors" - "fmt" - "sync" - - "github.com/safing/portbase/log" -) - -var ( - configLock sync.RWMutex - - userConfig = make(map[string]interface{}) - defaultConfig = make(map[string]interface{}) - - // ErrInvalidJSON is returned by SetConfig and SetDefaultConfig if they receive invalid json. - ErrInvalidJSON = errors.New("json string invalid") - - // ErrInvalidOptionType is returned by SetConfigOption and SetDefaultConfigOption if given an unsupported option type. - ErrInvalidOptionType = errors.New("invalid option value type") - - changedSignal = make(chan struct{}) -) - -// Changed signals if any config option was changed. -func Changed() <-chan struct{} { - configLock.RLock() - defer configLock.RUnlock() - return changedSignal -} - -// triggerChange signals listeners that a config option was changed. -func triggerChange() { - // must be locked! - close(changedSignal) - changedSignal = make(chan struct{}) -} - -// setConfig sets the (prioritized) user defined config. -func setConfig(m map[string]interface{}) error { - configLock.Lock() - defer configLock.Unlock() - userConfig = m - resetValidityFlag() - - go pushFullUpdate() - triggerChange() - - return nil -} - -// SetDefaultConfig sets the (fallback) default config. -func SetDefaultConfig(m map[string]interface{}) error { - configLock.Lock() - defer configLock.Unlock() - defaultConfig = m - resetValidityFlag() - - go pushFullUpdate() - triggerChange() - - return nil -} - -func validateValue(name string, value interface{}) (*Option, error) { - optionsLock.RLock() - defer optionsLock.RUnlock() - - option, ok := options[name] - if !ok { - return nil, errors.New("config option does not exist") - } - - switch v := value.(type) { - case string: - if option.OptType != OptTypeString { - return nil, fmt.Errorf("expected type %s for option %s, got type %T", getTypeName(option.OptType), name, v) - } - if option.compiledRegex != nil { - if !option.compiledRegex.MatchString(v) { - return nil, fmt.Errorf("validation failed: string \"%s\" did not match regex for option %s", v, name) - } - } - return option, nil - case []string: - if option.OptType != OptTypeStringArray { - return nil, fmt.Errorf("expected type %s for option %s, got type %T", getTypeName(option.OptType), name, v) - } - if option.compiledRegex != nil { - for pos, entry := range v { - if !option.compiledRegex.MatchString(entry) { - return nil, fmt.Errorf("validation failed: string \"%s\" at index %d did not match regex for option %s", entry, pos, name) - } - } - } - return option, nil - case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: - if option.OptType != OptTypeInt { - return nil, fmt.Errorf("expected type %s for option %s, got type %T", getTypeName(option.OptType), name, v) - } - if option.compiledRegex != nil { - if !option.compiledRegex.MatchString(fmt.Sprintf("%d", v)) { - return nil, fmt.Errorf("validation failed: number \"%d\" did not match regex for option %s", v, name) - } - } - return option, nil - case bool: - if option.OptType != OptTypeBool { - return nil, fmt.Errorf("expected type %s for option %s, got type %T", getTypeName(option.OptType), name, v) - } - return option, nil - default: - return nil, fmt.Errorf("invalid option value type: %T", value) - } -} - -// SetConfigOption sets a single value in the (prioritized) user defined config. -func SetConfigOption(name string, value interface{}) error { - return setConfigOption(name, value, true) -} - -func setConfigOption(name string, value interface{}, push bool) error { - configLock.Lock() - defer configLock.Unlock() - - var err error - - if value == nil { - delete(userConfig, name) - } else { - var option *Option - option, err = validateValue(name, value) - if err == nil { - userConfig[name] = value - if push { - go pushUpdate(option) - } - } - } - - if err == nil { - resetValidityFlag() - go saveConfig() //nolint:errcheck // error is logged - triggerChange() - } - - return err -} - -// SetDefaultConfigOption sets a single value in the (fallback) default config. -func SetDefaultConfigOption(name string, value interface{}) error { - return setDefaultConfigOption(name, value, true) -} - -func setDefaultConfigOption(name string, value interface{}, push bool) error { - configLock.Lock() - defer configLock.Unlock() - - var err error - - if value == nil { - delete(defaultConfig, name) - } else { - var option *Option - option, err = validateValue(name, value) - if err == nil { - defaultConfig[name] = value - if push { - go pushUpdate(option) - } - } - } - - if err == nil { - resetValidityFlag() - triggerChange() - } - - return err -} - -// findValue find the correct value in the user or default config. -func findValue(name string) (result interface{}) { - configLock.RLock() - defer configLock.RUnlock() - - result, ok := userConfig[name] - if ok { - return - } - - result, ok = defaultConfig[name] - if ok { - return - } - - optionsLock.RLock() - defer optionsLock.RUnlock() - - option, ok := options[name] - if ok { - return option.DefaultValue - } - - log.Errorf("config: request for unregistered option: %s", name) - return nil -} - -// findStringValue validates and returns the value with the given name. -func findStringValue(name string, fallback string) (value string) { - result := findValue(name) - if result == nil { - return fallback - } - v, ok := result.(string) - if ok { - return v - } - return fallback -} - -// findStringArrayValue validates and returns the value with the given name. -func findStringArrayValue(name string, fallback []string) (value []string) { - result := findValue(name) - if result == nil { - return fallback - } - - v, ok := result.([]interface{}) - if ok { - new := make([]string, len(v)) - for i, val := range v { - s, ok := val.(string) - if ok { - new[i] = s - } else { - return fallback - } - } - return new - } - - return fallback -} - -// findIntValue validates and returns the value with the given name. -func findIntValue(name string, fallback int64) (value int64) { - result := findValue(name) - if result == nil { - return fallback - } - switch v := result.(type) { - case int: - return int64(v) - case int8: - return int64(v) - case int16: - return int64(v) - case int32: - return int64(v) - case int64: - return v - case uint: - return int64(v) - case uint8: - return int64(v) - case uint16: - return int64(v) - case uint32: - return int64(v) - case uint64: - return int64(v) - case float32: - return int64(v) - case float64: - return int64(v) - } - return fallback -} - -// findBoolValue validates and returns the value with the given name. -func findBoolValue(name string, fallback bool) (value bool) { - result := findValue(name) - if result == nil { - return fallback - } - v, ok := result.(bool) - if ok { - return v - } - return fallback -} diff --git a/config/main.go b/config/main.go index 3b0d7d5..5267634 100644 --- a/config/main.go +++ b/config/main.go @@ -30,6 +30,7 @@ func prep() error { if dataRoot == nil { return errors.New("data root is not set") } + return nil } diff --git a/config/option.go b/config/option.go index 9d4a2f1..85322db 100644 --- a/config/option.go +++ b/config/option.go @@ -4,13 +4,14 @@ import ( "encoding/json" "fmt" "regexp" + "sync" "github.com/tidwall/sjson" "github.com/safing/portbase/database/record" ) -// Variable Type IDs for frontend Identification. Use ExternalOptType for extended types in the frontend. +// Various attribute options. Use ExternalOptType for extended types in the frontend. const ( OptTypeString uint8 = 1 OptTypeStringArray uint8 = 2 @@ -20,6 +21,10 @@ const ( ExpertiseLevelUser uint8 = 1 ExpertiseLevelExpert uint8 = 2 ExpertiseLevelDeveloper uint8 = 3 + + ReleaseLevelStable = "stable" + ReleaseLevelBeta = "beta" + ReleaseLevelExperimental = "experimental" ) func getTypeName(t uint8) string { @@ -39,48 +44,52 @@ func getTypeName(t uint8) string { // Option describes a configuration option. type Option struct { + sync.Mutex + Name string Key string // in path format: category/sub/key Description string - ExpertiseLevel uint8 - OptType uint8 + ReleaseLevel string + ExpertiseLevel uint8 + OptType uint8 + RequiresRestart bool DefaultValue interface{} ExternalOptType string ValidationRegex string - compiledRegex *regexp.Regexp + activeValue interface{} // runtime value (loaded from config file or set by user) + activeDefaultValue interface{} // runtime default value (may be set internally) + compiledRegex *regexp.Regexp } // Export expors an option to a Record. -func (opt *Option) Export() (record.Record, error) { - data, err := json.Marshal(opt) +func (option *Option) Export() (record.Record, error) { + option.Lock() + defer option.Unlock() + + data, err := json.Marshal(option) if err != nil { return nil, err } - configLock.RLock() - defer configLock.RUnlock() - - userValue, ok := userConfig[opt.Key] - if ok { - data, err = sjson.SetBytes(data, "Value", userValue) + if option.activeValue != nil { + data, err = sjson.SetBytes(data, "Value", option.activeValue) if err != nil { return nil, err } } - defaultValue, ok := defaultConfig[opt.Key] - if ok { - data, err = sjson.SetBytes(data, "DefaultValue", defaultValue) + if option.activeDefaultValue != nil { + data, err = sjson.SetBytes(data, "DefaultValue", option.activeDefaultValue) if err != nil { return nil, err } } - r, err := record.NewWrapper(fmt.Sprintf("config:%s", opt.Key), nil, record.JSON, data) + r, err := record.NewWrapper(fmt.Sprintf("config:%s", option.Key), nil, record.JSON, data) if err != nil { return nil, err } diff --git a/config/persistence.go b/config/persistence.go index 3edf647..dd2a182 100644 --- a/config/persistence.go +++ b/config/persistence.go @@ -14,30 +14,54 @@ var ( ) func loadConfig() error { + // check if persistence is configured + if configFilePath == "" { + return nil + } + + // read config file data, err := ioutil.ReadFile(configFilePath) if err != nil { return err } - m, err := JSONToMap(data) + // convert to map + newValues, err := JSONToMap(data) if err != nil { return err } - return setConfig(m) + // apply + return setConfig(newValues) } -func saveConfig() (err error) { - data, err := MapToJSON(userConfig) - if err == nil { - err = ioutil.WriteFile(configFilePath, data, 0600) +func saveConfig() error { + // check if persistence is configured + if configFilePath == "" { + return nil } + // extract values + activeValues := make(map[string]interface{}) + optionsLock.RLock() + for key, option := range options { + option.Lock() + if option.activeValue != nil { + activeValues[key] = option.activeValue + } + option.Unlock() + } + optionsLock.RUnlock() + + // convert to JSON + data, err := MapToJSON(activeValues) if err != nil { log.Errorf("config: failed to save config: %s", err) + return err } - return err + // write file + return ioutil.WriteFile(configFilePath, data, 0600) } // JSONToMap parses and flattens a hierarchical json object. @@ -73,18 +97,10 @@ func flatten(rootMap, subMap map[string]interface{}, subKey string) { } } -// MapToJSON expands a flattened map and returns it as json. -func MapToJSON(mapData map[string]interface{}) ([]byte, error) { - configLock.RLock() - defer configLock.RUnlock() - - new := make(map[string]interface{}) - for key, value := range mapData { - new[key] = value - } - - expand(new) - return json.MarshalIndent(new, "", " ") +// MapToJSON expands a flattened map and returns it as json. The map is altered in the process. +func MapToJSON(values map[string]interface{}) ([]byte, error) { + expand(values) + return json.MarshalIndent(values, "", " ") } // expand expands a flattened map. diff --git a/config/registry.go b/config/registry.go index a772ec4..d837529 100644 --- a/config/registry.go +++ b/config/registry.go @@ -21,8 +21,9 @@ func Register(option *Option) error { if option.Name == "" || option.Key == "" || option.Description == "" || + option.OptType == 0 || option.ExpertiseLevel == 0 || - option.OptType == 0 { + option.ReleaseLevel == "" { return ErrIncompleteCall } diff --git a/config/registry_test.go b/config/registry_test.go index 99504bb..66fcb7f 100644 --- a/config/registry_test.go +++ b/config/registry_test.go @@ -5,12 +5,15 @@ import ( ) func TestRegistry(t *testing.T) { + // reset + options = make(map[string]*Option) if err := Register(&Option{ Name: "name", Key: "key", Description: "description", - ExpertiseLevel: 1, + ReleaseLevel: ReleaseLevelStable, + ExpertiseLevel: ExpertiseLevelUser, OptType: OptTypeString, DefaultValue: "default", ValidationRegex: "^(banana|water)$", @@ -22,7 +25,8 @@ func TestRegistry(t *testing.T) { Name: "name", Key: "key", Description: "description", - ExpertiseLevel: 1, + ReleaseLevel: ReleaseLevelStable, + ExpertiseLevel: ExpertiseLevelUser, OptType: 0, DefaultValue: "default", ValidationRegex: "^[A-Z][a-z]+$", @@ -34,7 +38,8 @@ func TestRegistry(t *testing.T) { Name: "name", Key: "key", Description: "description", - ExpertiseLevel: 1, + ReleaseLevel: ReleaseLevelStable, + ExpertiseLevel: ExpertiseLevelUser, OptType: OptTypeString, DefaultValue: "default", ValidationRegex: "[", diff --git a/config/release.go b/config/release.go new file mode 100644 index 0000000..7804421 --- /dev/null +++ b/config/release.go @@ -0,0 +1,57 @@ +package config + +import ( + "fmt" + "sync" +) + +const ( + releaseLevelKey = "core/release_level" +) + +var ( + releaseLevel = ReleaseLevelStable + releaseLevelLock sync.Mutex +) + +func init() { + registerReleaseLevelOption() +} + +func registerReleaseLevelOption() { + err := Register(&Option{ + Name: "Release Selection", + Key: releaseLevelKey, + Description: "Select maturity level of features that should be available", + + OptType: OptTypeString, + ExpertiseLevel: ExpertiseLevelExpert, + ReleaseLevel: ReleaseLevelStable, + + RequiresRestart: false, + DefaultValue: ReleaseLevelStable, + + ExternalOptType: "string list", + ValidationRegex: fmt.Sprintf("^(%s|%s|%s)$", ReleaseLevelStable, ReleaseLevelBeta, ReleaseLevelExperimental), + }) + if err != nil { + panic(err) + } +} + +func updateReleaseLevel() { + new := findStringValue(releaseLevelKey, "") + releaseLevelLock.Lock() + if new == "" { + releaseLevel = ReleaseLevelStable + } else { + releaseLevel = new + } + releaseLevelLock.Unlock() +} + +func getReleaseLevel() string { + releaseLevelLock.Lock() + defer releaseLevelLock.Unlock() + return releaseLevel +} diff --git a/config/set.go b/config/set.go new file mode 100644 index 0000000..7bd3dc3 --- /dev/null +++ b/config/set.go @@ -0,0 +1,207 @@ +package config + +import ( + "errors" + "fmt" + "sync" + + "github.com/tevino/abool" +) + +var ( + // ErrInvalidJSON is returned by SetConfig and SetDefaultConfig if they receive invalid json. + ErrInvalidJSON = errors.New("json string invalid") + + // ErrInvalidOptionType is returned by SetConfigOption and SetDefaultConfigOption if given an unsupported option type. + ErrInvalidOptionType = errors.New("invalid option value type") + + validityFlag = abool.NewBool(true) + validityFlagLock sync.RWMutex + + changedSignal = make(chan struct{}) + changedSignalLock sync.Mutex +) + +func getValidityFlag() *abool.AtomicBool { + validityFlagLock.RLock() + defer validityFlagLock.RUnlock() + return validityFlag +} + +// Changed signals if any config option was changed. +func Changed() <-chan struct{} { + changedSignalLock.Lock() + defer changedSignalLock.Unlock() + return changedSignal +} + +func signalChanges() { + // refetch and save release level + updateReleaseLevel() + + // reset validity flag + validityFlagLock.Lock() + validityFlag.SetTo(false) + validityFlag = abool.NewBool(true) + validityFlagLock.Unlock() + + // trigger change signal: signal listeners that a config option was changed. + changedSignalLock.Lock() + close(changedSignal) + changedSignal = make(chan struct{}) + changedSignalLock.Unlock() +} + +// setConfig sets the (prioritized) user defined config. +func setConfig(newValues map[string]interface{}) error { + optionsLock.Lock() + for key, option := range options { + newValue, ok := newValues[key] + option.Lock() + if ok { + option.activeValue = newValue + } else { + option.activeValue = nil + } + option.Unlock() + } + optionsLock.Unlock() + + signalChanges() + go pushFullUpdate() + return nil +} + +// SetDefaultConfig sets the (fallback) default config. +func SetDefaultConfig(newValues map[string]interface{}) error { + optionsLock.Lock() + for key, option := range options { + newValue, ok := newValues[key] + option.Lock() + if ok { + option.activeDefaultValue = newValue + } else { + option.activeDefaultValue = nil + } + option.Unlock() + } + optionsLock.Unlock() + + signalChanges() + go pushFullUpdate() + return nil +} + +func validateValue(option *Option, value interface{}) error { + switch v := value.(type) { + case string: + if option.OptType != OptTypeString { + return fmt.Errorf("expected type %s for option %s, got type %T", getTypeName(option.OptType), option.Key, v) + } + if option.compiledRegex != nil { + if !option.compiledRegex.MatchString(v) { + return fmt.Errorf("validation failed: string \"%s\" did not match regex for option %s", v, option.Key) + } + } + return nil + case []string: + if option.OptType != OptTypeStringArray { + return fmt.Errorf("expected type %s for option %s, got type %T", getTypeName(option.OptType), option.Key, v) + } + if option.compiledRegex != nil { + for pos, entry := range v { + if !option.compiledRegex.MatchString(entry) { + return fmt.Errorf("validation failed: string \"%s\" at index %d did not match regex for option %s", entry, pos, option.Key) + } + } + } + return nil + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + if option.OptType != OptTypeInt { + return fmt.Errorf("expected type %s for option %s, got type %T", getTypeName(option.OptType), option.Key, v) + } + if option.compiledRegex != nil { + if !option.compiledRegex.MatchString(fmt.Sprintf("%d", v)) { + return fmt.Errorf("validation failed: number \"%d\" did not match regex for option %s", v, option.Key) + } + } + return nil + case bool: + if option.OptType != OptTypeBool { + return fmt.Errorf("expected type %s for option %s, got type %T", getTypeName(option.OptType), option.Key, v) + } + return nil + default: + return fmt.Errorf("invalid option value type: %T", value) + } +} + +// SetConfigOption sets a single value in the (prioritized) user defined config. +func SetConfigOption(key string, value interface{}) error { + return setConfigOption(key, value, true) +} + +func setConfigOption(key string, value interface{}, push bool) (err error) { + optionsLock.Lock() + option, ok := options[key] + optionsLock.Unlock() + if !ok { + return fmt.Errorf("config option %s does not exist", key) + } + + option.Lock() + if value == nil { + option.activeValue = nil + } else { + err = validateValue(option, value) + if err == nil { + option.activeValue = value + } + } + option.Unlock() + if err != nil { + return err + } + + // finalize change, activate triggers + signalChanges() + if push { + go pushUpdate(option) + } + return saveConfig() +} + +// SetDefaultConfigOption sets a single value in the (fallback) default config. +func SetDefaultConfigOption(key string, value interface{}) error { + return setDefaultConfigOption(key, value, true) +} + +func setDefaultConfigOption(key string, value interface{}, push bool) (err error) { + optionsLock.Lock() + option, ok := options[key] + optionsLock.Unlock() + if !ok { + return fmt.Errorf("config option %s does not exist", key) + } + + option.Lock() + if value == nil { + option.activeDefaultValue = nil + } else { + err = validateValue(option, value) + if err == nil { + option.activeDefaultValue = value + } + } + option.Unlock() + if err != nil { + return err + } + + // finalize change, activate triggers + signalChanges() + if push { + go pushUpdate(option) + } + return saveConfig() +} diff --git a/config/layers_test.go b/config/set_test.go similarity index 89% rename from config/layers_test.go rename to config/set_test.go index 68f05bd..dc99ddb 100644 --- a/config/layers_test.go +++ b/config/set_test.go @@ -4,6 +4,8 @@ package config import "testing" func TestLayersGetters(t *testing.T) { + // reset + options = make(map[string]*Option) mapData, err := JSONToMap([]byte(` { @@ -79,12 +81,15 @@ func TestLayersGetters(t *testing.T) { } func TestLayersSetters(t *testing.T) { + // reset + options = make(map[string]*Option) Register(&Option{ Name: "name", Key: "monkey", Description: "description", - ExpertiseLevel: 1, + ReleaseLevel: ReleaseLevelStable, + ExpertiseLevel: ExpertiseLevelUser, OptType: OptTypeString, DefaultValue: "banana", ValidationRegex: "^(banana|water)$", @@ -93,7 +98,8 @@ func TestLayersSetters(t *testing.T) { Name: "name", Key: "zebras/zebra", Description: "description", - ExpertiseLevel: 1, + ReleaseLevel: ReleaseLevelStable, + ExpertiseLevel: ExpertiseLevelUser, OptType: OptTypeStringArray, DefaultValue: []string{"black", "white"}, ValidationRegex: "^[a-z]+$", @@ -102,7 +108,8 @@ func TestLayersSetters(t *testing.T) { Name: "name", Key: "elephant", Description: "description", - ExpertiseLevel: 1, + ReleaseLevel: ReleaseLevelStable, + ExpertiseLevel: ExpertiseLevelUser, OptType: OptTypeInt, DefaultValue: 2, ValidationRegex: "", @@ -111,7 +118,8 @@ func TestLayersSetters(t *testing.T) { Name: "name", Key: "hot", Description: "description", - ExpertiseLevel: 1, + ReleaseLevel: ReleaseLevelStable, + ExpertiseLevel: ExpertiseLevelUser, OptType: OptTypeBool, DefaultValue: true, ValidationRegex: "", @@ -180,8 +188,8 @@ func TestLayersSetters(t *testing.T) { if err := SetDefaultConfigOption("elephant", nil); err != nil { t.Error(err) } - if err := SetDefaultConfigOption("invalid_delete", nil); err != nil { - t.Error(err) + if err := SetDefaultConfigOption("invalid_delete", nil); err == nil { + t.Error("should fail") } }