package config import ( "os" "path/filepath" "reflect" "regexp" "sort" "strings" "github.com/go-viper/mapstructure/v2" "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", DecoderConfig: &mapstructure.DecoderConfig{ DecodeHook: mapstructure.ComposeDecodeHookFunc( envDecodeHookFunc, mapstructure.StringToTimeDurationHookFunc(), mapstructure.TextUnmarshallerHookFunc(), ), WeaklyTypedInput: true, }, }) 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() } } // buildIncludePath constructs the full path to the include directory. func buildIncludePath(k *koanf.Koanf, baseConfigPath string) string { relativeIncludePath := k.String("include") return filepath.Join(filepath.Dir(baseConfigPath), relativeIncludePath) } // loadAndMergeYamlFiles loads and merges all YAML files from the include directory. func loadAndMergeYamlFiles(k *koanf.Koanf, includePath string, yamlFiles []string) { sort.Strings(yamlFiles) for _, filename := range yamlFiles { loadAndMergeIncludedFile(k, includePath, filename) } log.Infof("Finished loading %d included config file(s)", len(yamlFiles)) } // 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 := buildIncludePath(k, baseConfigPath) log.WithFields(log.Fields{ "includePath": includePath, }).Infof("Loading included configs from dir") yamlFiles, ok := listYamlFiles(includePath) if !ok || len(yamlFiles) == 0 { return } loadAndMergeYamlFiles(k, includePath, yamlFiles) } // validateIncludeDirectory checks if the given path exists and is a directory. func validateIncludeDirectory(includePath string) bool { dirInfo, err := os.Stat(includePath) if err != nil { log.Warnf("Include directory not found: %s", includePath) return false } if !dirInfo.IsDir() { log.Warnf("Include path is not a directory: %s", includePath) return false } return true } // isYamlFile checks if a filename has a YAML extension. func isYamlFile(name string) bool { return strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") } // filterYamlFilesFromEntries extracts YAML file names from directory entries. func filterYamlFilesFromEntries(entries []os.DirEntry) []string { var yamlFiles []string for _, entry := range entries { if entry.IsDir() { continue } if isYamlFile(entry.Name()) { yamlFiles = append(yamlFiles, entry.Name()) } } return yamlFiles } func listYamlFiles(includePath string) ([]string, bool) { if !validateIncludeDirectory(includePath) { return nil, false } entries, err := os.ReadDir(includePath) if err != nil { log.Errorf("Error reading include directory: %v", err) return nil, false } yamlFiles := filterYamlFilesFromEntries(entries) 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") } // mergeActionsWhenBothExist merges actions when both src and dest have actions. func mergeActionsWhenBothExist(srcActions interface{}, destActions interface{}, dest map[string]interface{}) { srcSlice, ok1 := srcActions.([]interface{}) destSlice, ok2 := destActions.([]interface{}) if ok1 && ok2 { dest["actions"] = append(destSlice, srcSlice...) } else { dest["actions"] = srcActions } } // mergeActionsFromSource merges actions from source into destination. func mergeActionsFromSource(srcActions interface{}, dest map[string]interface{}) { if destActions, ok := dest["actions"]; ok { mergeActionsWhenBothExist(srcActions, destActions, dest) } else { dest["actions"] = srcActions } } // mergeDashboardsWhenBothExist merges dashboards when both src and dest have dashboards. func mergeDashboardsWhenBothExist(srcDashboards interface{}, destDashboards interface{}, dest map[string]interface{}) { srcSlice, ok1 := srcDashboards.([]interface{}) destSlice, ok2 := destDashboards.([]interface{}) if ok1 && ok2 { dest["dashboards"] = append(destSlice, srcSlice...) } else { dest["dashboards"] = srcDashboards } } // mergeDashboardsFromSource merges dashboards from source into destination. func mergeDashboardsFromSource(srcDashboards interface{}, dest map[string]interface{}) { if destDashboards, ok := dest["dashboards"]; ok { mergeDashboardsWhenBothExist(srcDashboards, destDashboards, dest) } else { dest["dashboards"] = srcDashboards } } // mergeEntitiesWhenBothExist merges entities when both src and dest have entities. func mergeEntitiesWhenBothExist(srcEntities interface{}, destEntities interface{}, dest map[string]interface{}) { srcSlice, ok1 := srcEntities.([]interface{}) destSlice, ok2 := destEntities.([]interface{}) if ok1 && ok2 { dest["entities"] = append(destSlice, srcSlice...) } else { dest["entities"] = srcEntities } } // mergeEntitiesFromSource merges entities from source into destination. func mergeEntitiesFromSource(srcEntities interface{}, dest map[string]interface{}) { if destEntities, ok := dest["entities"]; ok { mergeEntitiesWhenBothExist(srcEntities, destEntities, dest) } else { dest["entities"] = srcEntities } } func mergeFunc(src map[string]interface{}, dest map[string]interface{}) error { if srcActions, ok := src["actions"]; ok { mergeActionsFromSource(srcActions, dest) } if srcDashboards, ok := src["dashboards"]; ok { mergeDashboardsFromSource(srcDashboards, dest) } if srcEntities, ok := src["entities"]; ok { mergeEntitiesFromSource(srcEntities, dest) } 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 }