olivetin/service/internal/config/config_reloader.go

192 lines
5.0 KiB
Go

package config
import (
"os"
"path/filepath"
"reflect"
"regexp"
"sort"
"strings"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
log "github.com/sirupsen/logrus"
)
var (
metricConfigActionCount = promauto.NewGauge(prometheus.GaugeOpts{
Name: "olivetin_config_action_count",
Help: "The number of actions in the config file",
})
metricConfigReloadedCount = promauto.NewCounter(prometheus.CounterOpts{
Name: "olivetin_config_reloaded_count",
Help: "The number of times the config has been reloaded",
})
listeners []func()
)
func AddListener(l func()) {
listeners = append(listeners, l)
}
func AppendSource(cfg *Config, k *koanf.Koanf, configPath string) {
log.WithFields(log.Fields{
"configPath": configPath,
}).Info("Appending cfg source")
loadIncludedConfigsFromDir(k, configPath)
if !unmarshalRoot(k, cfg) {
return
}
afterLoadFinalize(cfg, configPath)
}
func unmarshalRoot(k *koanf.Koanf, cfg *Config) bool {
err := k.UnmarshalWithConf("", cfg, koanf.UnmarshalConf{
Tag: "koanf",
})
if err != nil {
log.Errorf("Error unmarshalling config: %v", err)
return false
}
return true
}
func afterLoadFinalize(cfg *Config, configPath string) {
metricConfigReloadedCount.Inc()
metricConfigActionCount.Set(float64(len(cfg.Actions)))
cfg.SetDir(filepath.Dir(configPath))
cfg.Sanitize()
for _, l := range listeners {
l()
}
}
// loadIncludedConfigsFromDir loads configuration files from an include directory and merges them
func loadIncludedConfigsFromDir(k *koanf.Koanf, baseConfigPath string) {
relativeIncludePath := k.String("include")
if relativeIncludePath == "" {
return
}
includePath := filepath.Join(filepath.Dir(baseConfigPath), relativeIncludePath)
log.WithFields(log.Fields{
"includePath": includePath,
}).Infof("Loading included configs from dir")
yamlFiles, ok := listYamlFiles(includePath)
if !ok || len(yamlFiles) == 0 {
return
}
sort.Strings(yamlFiles)
for _, filename := range yamlFiles {
loadAndMergeIncludedFile(k, includePath, filename)
}
log.Infof("Finished loading %d included config file(s)", len(yamlFiles))
}
func listYamlFiles(includePath string) ([]string, bool) {
dirInfo, err := os.Stat(includePath)
if err != nil {
log.Warnf("Include directory not found: %s", includePath)
return nil, false
}
if !dirInfo.IsDir() {
log.Warnf("Include path is not a directory: %s", includePath)
return nil, false
}
entries, err := os.ReadDir(includePath)
if err != nil {
log.Errorf("Error reading include directory: %v", err)
return nil, false
}
var yamlFiles []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") {
yamlFiles = append(yamlFiles, name)
}
}
if len(yamlFiles) == 0 {
log.Infof("No YAML files found in include directory: %s", includePath)
}
return yamlFiles, true
}
func loadAndMergeIncludedFile(k *koanf.Koanf, includePath, filename string) {
filePath := filepath.Join(includePath, filename)
if err := k.Load(file.Provider(filePath), yaml.Parser(), koanf.WithMergeFunc(mergeFunc)); err != nil {
log.Errorf("Error loading included config file %s: %v", filePath, err)
return
}
log.WithFields(log.Fields{
"filePath": filePath,
}).Info("Successfully loaded included config file")
}
func mergeFunc(src map[string]interface{}, dest map[string]interface{}) error {
// Handle actions merging - koanf provides []interface{} not []*Action
// Merge src (new) into dest (existing) by appending src's actions to dest's actions
if srcActions, ok := src["actions"]; ok {
if destActions, ok := dest["actions"]; ok {
// Both have actions - append src to dest
srcSlice, ok1 := srcActions.([]interface{})
destSlice, ok2 := destActions.([]interface{})
if ok1 && ok2 {
dest["actions"] = append(destSlice, srcSlice...)
} else {
// Fallback: if types don't match, just use src
dest["actions"] = srcActions
}
} else {
// dest doesn't have actions, so use src's actions
dest["actions"] = srcActions
}
}
// If src doesn't have actions, leave dest unchanged
return nil
}
var envRegex = regexp.MustCompile(`\${{ *?(\S+) *?}}`)
func envDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error) {
log.Debugf("envDecodeHookFunc called: from=%v, to=%v, data=%v", from, to, data)
if from.Kind() != reflect.String {
return data, nil
}
input := data.(string)
log.Debugf("Processing string input: %q", input)
output := envRegex.ReplaceAllStringFunc(input, func(match string) string {
submatches := envRegex.FindStringSubmatch(match)
key := submatches[1]
val, set := os.LookupEnv(key)
log.Debugf("Environment variable %q: set=%v, value=%q", key, set, val)
if !set {
log.Warnf("Config file references unset environment variable: \"%s\"", key)
}
return val
})
log.Debugf("Environment variable interpolation result: %q -> %q", input, output)
return output, nil
}