Skip to content

Commit

Permalink
Replace Viper as configuration manager (#12)
Browse files Browse the repository at this point in the history
Remove Viper from project and add custom config parser.

Viper is not well suited for reading Beacon config - it drops keys without values. It has also other issues and a replacement is needed. Rolling own config parser is questionable and it might need rethinking in the future, but for now the minimal required functionality is provided.
  • Loading branch information
davidmasek authored Jan 6, 2025
1 parent b88ef05 commit 446a250
Show file tree
Hide file tree
Showing 19 changed files with 461 additions and 307 deletions.
11 changes: 6 additions & 5 deletions README-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,16 @@ Some design choices:
- for creating new data there is HealthCheckInput - currently same as HealthCheck without ID, in future possibly different
- naming conventions:
- ID will be lowercased when used in variable name - FooId - to follow CamelCaseNaming
- dependency chain / architecture:
- storage < monitor < handlers < cmd
- storage (DB) is the base, handles persistence, should depend on nothing (nothing internal, can depend e.g. on SQLite)
- modules:
- storage (DB) is the base, handles persistence
- monitors interact with the outside world and store health checks to DB
- handlers
- take data from DB and do something with it
- display / generate reports
- send notifications
- cmd
- entrypoints
- can depend on anything (apart from each other)
- should be simple and high-level
- should be simple, only wrap existing functionality
- conf
- store/load configuration
- name chosen to prevent naming variables `config` (not super happy about naming here)
9 changes: 4 additions & 5 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,21 @@ import (
"log"
"strings"

"github.com/davidmasek/beacon/monitor"
"github.com/davidmasek/beacon/conf"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)

func loadConfig(cmd *cobra.Command) (*viper.Viper, error) {
func loadConfig(cmd *cobra.Command) (*conf.Config, error) {
configFile, err := cmd.Flags().GetString("config")
if err != nil {
return nil, err
}

if configFile != "" {
return monitor.DefaultConfigFrom(configFile)
return conf.DefaultConfigFrom(configFile)
}

config, err := monitor.DefaultConfig()
config, err := conf.DefaultConfig()
// TODO: quick fix to enable start when no config file found
// default one should be created instead
if err != nil && strings.Contains(err.Error(), `Config File "beacon.yaml" Not Found in`) {
Expand Down
File renamed without changes.
308 changes: 308 additions & 0 deletions conf/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
package conf

import (
_ "embed"
"errors"
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"time"

"gopkg.in/yaml.v3"
)

// prefix for environment variables
const ENV_VAR_PREFIX = "BEACON_"

type Config struct {
envPrefix string
parents []string
settings map[string]interface{}
// manually set, should take precedence
overrides map[string]interface{}
}

func (config *Config) AllSettings() map[string]interface{} {
settings := config.settings
for _, parent := range config.parents {
if settings == nil {
return nil
}
settingsSub, ok := settings[parent].(map[string]interface{})
if ok {
settings = settingsSub
} else {
return nil
}
}
return settings
}

func (config *Config) keyToEnvVar(key string) string {
// todo: nested access
if strings.Contains(key, ".") {
panic("nested access with `.` not implemented")
}
// key = strings.ReplaceAll(key, ".", "_")
key = config.envPrefix + strings.Join(config.parents, "_") + "_" + key
key = strings.ToUpper(key)
return key
}

func (config *Config) get(key string) interface{} {
if config == nil {
return nil
}
// todo: nested access
if strings.Contains(key, ".") {
panic("nested access with `.` not implemented")
}
overrides := config.overrides
for _, parent := range config.parents {
if overrides == nil {
break
}
overridesSub, ok := overrides[parent].(map[string]interface{})
if ok {
overrides = overridesSub
}
}
val, ok := overrides[key]
if ok {
return val
}
// overwrite with ENV var if available
envVal, isSet := os.LookupEnv(config.keyToEnvVar(key))
if isSet {
return envVal
}
settings := config.settings
for _, parent := range config.parents {
if settings == nil {
return nil
}
settingsSub, ok := settings[parent].(map[string]interface{})
if ok {
settings = settingsSub
} else {
return nil
}
}
val, ok = settings[key]

if !ok {
return nil
}
return val
}

func (config *Config) GetString(key string) string {
val := config.get(key)
strVal, ok := val.(string)
if ok {
return strVal
}
return fmt.Sprint(val)
}

func (config *Config) GetInt(key string) int {
val := config.get(key)
intVal, ok := val.(int)
if ok {
return intVal
}
strVal, ok := val.(string)
if !ok {
panic(fmt.Sprintf("For key %q - cannot convert %q to int", key, val))
}
intVal, err := strconv.Atoi(strVal)
if err != nil {
panic(fmt.Sprintf("For key %q - cannot convert %q to int", key, val))
}
return intVal
}

var boolyStrings = map[string]bool{
"true": true,
"1": true,
"TRUE": true,
"false": false,
"0": false,
"FALSE": false,
}

func (config *Config) GetBool(key string) bool {
val := config.get(key)
boolVal, isBool := val.(bool)
if isBool {
return boolVal
}
strVal, isString := val.(string)
if !isString {
panic(fmt.Sprintf("Cannot parse key %q with value %q as bool", key, config.settings[key]))
}
boolVal, isExpectedFormat := boolyStrings[strVal]
if !isExpectedFormat {
panic(fmt.Sprintf("Cannot parse key %q with value %q as bool", key, config.settings[key]))
}
return boolVal
}

func (config *Config) GetDuration(key string) time.Duration {
value := config.get(key)
durationValue, isDuration := value.(time.Duration)
if isDuration {
return durationValue
}
parsedValue, err := time.ParseDuration(value.(string))
if err != nil {
panic(fmt.Sprintf("Cannot parse %q as time.Duration", value))
}
return parsedValue
}

func (config *Config) Set(key string, value interface{}) {
// todo: nested access
if strings.Contains(key, ".") {
panic("nested access with `.` not implemented")
}
if len(config.parents) > 0 {
// todo: sub configs are read only for now
// not sure what to do with them atm
panic("Cannot set values for .Sub configs")
}
config.overrides[key] = value
}

func (config *Config) SetDefault(key string, value interface{}) {
// todo: nested access
if strings.Contains(key, ".") {
panic("nested access with `.` not implemented")
}
if len(config.parents) > 0 {
// todo: sub configs are read only for now
// not sure what to do with them atm
panic("Cannot set values for .Sub configs")
}
val := config.get(key)
if val == nil {
config.settings[key] = value
}
}

func (config *Config) IsSet(key string) bool {
// here if the key exists but has nil value we return false
// now this is kinda stupid but it kinda makes sense for our use-cases
// I don't have a solution that would be simple to do and work well atm
// todo: probably want to rethink the whole Config anyway
val := config.get(key)
return val != nil
}

func (config *Config) Sub(key string) *Config {
// todo: kinda weird implementation, not sure how I want to use this yet
if !config.IsSet(key) {
return nil
}
return &Config{
envPrefix: config.envPrefix,
parents: append(config.parents, key),
settings: config.settings,
overrides: config.overrides,
}
}

//go:embed config.default.yaml
var DEFAULT_CONFIG []byte

func ensureConfigFile(path string) error {
_, err := os.Stat(path)
if errors.Is(err, fs.ErrNotExist) {
err = os.WriteFile(path, DEFAULT_CONFIG, 0644)
return err
}
return err
}

// Load config file from home dir (such as `~/beacon.yaml`).
//
// Create config file if not found.
// Setup config to use env variables.
func DefaultConfig() (*Config, error) {
homedir, err := os.UserHomeDir()
if err != nil {
return nil, err
}
configFile := filepath.Join(homedir, "beacon.yaml")
err = ensureConfigFile(configFile)
if err != nil {
return nil, err
}
return DefaultConfigFrom(configFile)
}

// Load config file from `config.sample.yaml`. Useful for testing.
//
// Fail if example config file not found.
// Setup config to use env variables.
func ExampleConfig() (*Config, error) {
// test can be run with different working dir
locations := []string{
filepath.Join("config.sample.yaml"),
filepath.Join("..", "config.sample.yaml"),
}
for _, loc := range locations {
_, err := os.Stat(loc)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, err
}
return DefaultConfigFrom(loc)
}
return nil, fmt.Errorf("config.sample.yaml file not found")
}

// Load config file from the specified path.
//
// Create config file if not found.
// Setup config to use env variables.
func DefaultConfigFrom(configFile string) (*Config, error) {
err := ensureConfigFile(configFile)
if err != nil {
return nil, err
}
return setupConfig(configFile)
}

// Empty config
func NewConfig() *Config {
config := &Config{
envPrefix: ENV_VAR_PREFIX,
parents: []string{},
settings: make(map[string]interface{}),
overrides: make(map[string]interface{}),
}
return config
}

// Setup config to use ENV variables and read specified config file.
func setupConfig(configFile string) (*Config, error) {
log.Printf("reading config from %q\n", configFile)
data, err := os.ReadFile(configFile)
if err != nil {
return nil, err
}
config := NewConfig()
err = yaml.Unmarshal(data, config.settings)
if err != nil {
return nil, err
}
log.Println(">>>>", config, "<<<<")
return config, err
}
Loading

0 comments on commit 446a250

Please sign in to comment.