377 lines
11 KiB
Go
377 lines
11 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/OliveTin/OliveTin/internal/configissues"
|
|
"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")
|
|
|
|
configissues.BeginConfigLoad()
|
|
stampLoadedConfigSources(k, configPath)
|
|
loadIncludedConfigsFromDir(k, configPath)
|
|
|
|
if !unmarshalRoot(k, cfg) {
|
|
return
|
|
}
|
|
|
|
applyStampedSourceFiles(k, cfg)
|
|
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,
|
|
justificationDecodeHookFunc,
|
|
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()
|
|
applyPortEnvironmentOverride(cfg)
|
|
|
|
for _, l := range listeners {
|
|
l()
|
|
}
|
|
}
|
|
|
|
// applyPortEnvironmentOverride lets the PORT environment variable take precedence
|
|
// over the configured HTTP frontend port.
|
|
func applyPortEnvironmentOverride(cfg *Config) {
|
|
envPort := strings.TrimSpace(os.Getenv("PORT"))
|
|
if envPort == "" {
|
|
return
|
|
}
|
|
|
|
port, ok := parseEnvPort(envPort)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
host, ok := listenHostOrDefault(cfg.ListenAddressSingleHTTPFrontend)
|
|
if !ok {
|
|
log.WithFields(log.Fields{
|
|
"PORT": envPort,
|
|
"listenAddress": cfg.ListenAddressSingleHTTPFrontend,
|
|
}).Error("Ignoring PORT environment variable because listenAddressSingleHTTPFrontend is invalid")
|
|
return
|
|
}
|
|
|
|
cfg.ListenAddressSingleHTTPFrontend = net.JoinHostPort(host, strconv.Itoa(port))
|
|
|
|
log.WithFields(log.Fields{
|
|
"address": cfg.ListenAddressSingleHTTPFrontend,
|
|
}).Info("Using PORT environment variable for single HTTP frontend listen address")
|
|
}
|
|
|
|
func parseEnvPort(envPort string) (int, bool) {
|
|
port, err := strconv.Atoi(envPort)
|
|
if err != nil || port < 1 || port > 65535 {
|
|
log.WithFields(log.Fields{
|
|
"PORT": envPort,
|
|
"error": err,
|
|
}).Error("Ignoring invalid PORT environment variable")
|
|
return 0, false
|
|
}
|
|
|
|
return port, true
|
|
}
|
|
|
|
func listenHostOrDefault(listenAddress string) (string, bool) {
|
|
if strings.TrimSpace(listenAddress) == "" {
|
|
return "0.0.0.0", true
|
|
}
|
|
|
|
host, _, err := net.SplitHostPort(listenAddress)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
|
|
if host == "" {
|
|
return "0.0.0.0", true
|
|
}
|
|
|
|
return host, true
|
|
}
|
|
|
|
// 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(mergeFuncForSource(filePath))); 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 mergeFuncForSource(sourceFile string) func(src, dest map[string]interface{}) error {
|
|
return func(src map[string]interface{}, dest map[string]interface{}) error {
|
|
return mergeFunc(src, dest, sourceFile)
|
|
}
|
|
}
|
|
|
|
// mergeActionsWhenBothExist merges actions when both src and dest have actions.
|
|
func mergeActionsWhenBothExist(srcActions interface{}, destActions interface{}, dest map[string]interface{}, sourceFile string) {
|
|
stampSourceOnMaps(srcActions, sourceFile)
|
|
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{}, sourceFile string) {
|
|
if destActions, ok := dest["actions"]; ok {
|
|
mergeActionsWhenBothExist(srcActions, destActions, dest, sourceFile)
|
|
} else {
|
|
stampSourceOnMaps(srcActions, sourceFile)
|
|
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{}, sourceFile string) {
|
|
stampSourceOnMaps(srcEntities, sourceFile)
|
|
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{}, sourceFile string) {
|
|
if destEntities, ok := dest["entities"]; ok {
|
|
mergeEntitiesWhenBothExist(srcEntities, destEntities, dest, sourceFile)
|
|
} else {
|
|
stampSourceOnMaps(srcEntities, sourceFile)
|
|
dest["entities"] = srcEntities
|
|
}
|
|
}
|
|
|
|
func mergeFunc(src map[string]interface{}, dest map[string]interface{}, sourceFile string) error {
|
|
if srcActions, ok := src["actions"]; ok {
|
|
mergeActionsFromSource(srcActions, dest, sourceFile)
|
|
}
|
|
|
|
if srcDashboards, ok := src["dashboards"]; ok {
|
|
mergeDashboardsFromSource(srcDashboards, dest)
|
|
}
|
|
|
|
if srcEntities, ok := src["entities"]; ok {
|
|
mergeEntitiesFromSource(srcEntities, dest, sourceFile)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
var envRegex = regexp.MustCompile(`\${{ *?(\S+) *?}}`)
|
|
|
|
func justificationDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error) {
|
|
if to.Kind() != reflect.String || from.Kind() != reflect.Bool {
|
|
return data, nil
|
|
}
|
|
|
|
if data.(bool) {
|
|
return JustificationRequiredNoTemplate, nil
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
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 {
|
|
configissues.ReportSticky(configissues.Issue{
|
|
Severity: configissues.SeverityWarning,
|
|
Code: configissues.CodeEnvUnset,
|
|
Message: fmt.Sprintf("Config file references unset environment variable %q", key),
|
|
Source: key,
|
|
})
|
|
}
|
|
return val
|
|
})
|
|
log.Debugf("Environment variable interpolation result: %q -> %q", input, output)
|
|
return output, nil
|
|
}
|