diff --git a/cmd/karma/testdata/default_config_file.txt b/cmd/karma/testdata/default_config_file.txt new file mode 100644 index 000000000..e6d4c0333 --- /dev/null +++ b/cmd/karma/testdata/default_config_file.txt @@ -0,0 +1,14 @@ +# Load 'karma.yaml' from cwd by default +karma.bin-should-work --check-config +! stdout . +stderr 'msg="Reading configuration file karma.yaml"' +stderr 'msg="Configuration is valid"' +stderr 'msg="\[cwd\] Configured Alertmanager source at http://localhost:8080 \(proxied: true\, readonly: false\)"' +! stderr 'level=error' + +-- karma.yaml -- +alertmanager: + servers: + - name: cwd + uri: "http://localhost:8080" + proxy: true diff --git a/cmd/karma/testdata/invalid_config_alertmanager_timeout.txt b/cmd/karma/testdata/invalid_config_alertmanager_timeout.txt index 601aaa2be..f1c2ff118 100644 --- a/cmd/karma/testdata/invalid_config_alertmanager_timeout.txt +++ b/cmd/karma/testdata/invalid_config_alertmanager_timeout.txt @@ -1,7 +1,7 @@ # Raises an error if we pass alertmanager.interval value that doesn't parse karma.bin-should-fail-no-timestamp --log.format=text --log.config=false --log.level=error --config.file karma.yaml ! stdout . -stderr 'level=fatal msg="yaml: unmarshal errors:\\n line 2: cannot unmarshal !!str `abc123` into time.Duration"' +stderr 'level=fatal msg=".* invalid duration abc123"' -- karma.yaml -- alertmanager: diff --git a/cmd/karma/testdata/log_full_config_file.txt b/cmd/karma/testdata/log_full_config_file.txt index 8329bb74d..ee9f5677b 100644 --- a/cmd/karma/testdata/log_full_config_file.txt +++ b/cmd/karma/testdata/log_full_config_file.txt @@ -1,9 +1,9 @@ # Print out and compare logged config set via config file -karma.bin-should-work --config.file=karma.yaml --check-config +karma.bin-should-work --config.file=custom.yaml --check-config ! stdout . cmp stderr expected.stderr --- karma.yaml -- +-- custom.yaml -- alertmanager: interval: 10s servers: @@ -224,7 +224,7 @@ FLR1flnW2lx5o5csDzTpi+jgC6nu1zE0DWo1c5ZdpVO289POIpqh -----END RSA PRIVATE KEY----- -- expected.stderr -- -level=info msg="Reading configuration file karma.yaml" +level=info msg="Reading configuration file custom.yaml" level=info msg="Version: dev" level=info msg="Parsed configuration:" level=info msg="alertmanager:" diff --git a/cmd/karma/testdata/log_full_config_file_invalid_values.txt b/cmd/karma/testdata/log_full_config_file_invalid_values.txt index be70c984d..f9121037e 100644 --- a/cmd/karma/testdata/log_full_config_file_invalid_values.txt +++ b/cmd/karma/testdata/log_full_config_file_invalid_values.txt @@ -57,8 +57,8 @@ ui: collapseGroups: collapsedOanMobile -- expected.stderr -- -level=fatal msg="4 error(s) decoding:\n\n* '[2].Headers[0]' expected a map, got 'string'\n* cannot parse '[0].Proxy' as bool: strconv.ParseBool: parsing \"YEs\": invalid syntax\n* error decoding '[0].Timeout': time: invalid duration bbb\n* error decoding '[2].Timeout': time: invalid duration z" -level=fatal msg="Invalid ui.collapseGroups value 'collapsedOanMobile', allowed options: expanded, collapsed, collapsedOnMobile" -level=fatal msg="Invalid ui.theme value 'x', allowed options: light, dark, auto" -level=fatal msg="yaml: unmarshal errors:\n line 2: cannot unmarshal !!str `jjs88` into time.Duration\n line 6: cannot unmarshal !!str `bbb` into time.Duration\n line 7: cannot unmarshal !!str `YEs` into bool\n line 11: cannot unmarshal !!int `1` into bool\n line 14: cannot unmarshal !!str `z` into time.Duration\n line 16: cannot unmarshal !!int `0` into bool\n line 18: cannot unmarshal !!seq into map[string]string\n line 27: cannot unmarshal !!str `zzz` into bool\n line 33: cannot unmarshal !!str `z` into bool\n line 34: cannot unmarshal !!map into []string\n line 45: cannot unmarshal !!str `10sm` into time.Duration\n line 46: cannot unmarshal !!str `z` into bool\n line 47: cannot unmarshal !!str `yum` into bool\n line 49: cannot unmarshal !!str `abc4` into int\n line 50: cannot unmarshal !!str `5a` into int" -level=error msg="Unknown log level '123'" +level=fatal msg="Failed to unmarshal configuration: 12 error(s) decoding:\n\n* 'Alertmanager.Servers[2].Headers[0]' expected a map, got 'string'\n* cannot parse 'Alertmanager.Servers[0].Proxy' as bool: strconv.ParseBool: parsing \"YEs\": invalid syntax\n* cannot parse 'Annotations.Default.Hidden' as bool: strconv.ParseBool: parsing \"z\": invalid syntax\n* cannot parse 'UI.alertsPerGroup' as int: strconv.ParseInt: parsing \"5a\": invalid syntax\n* cannot parse 'UI.colorTitlebar' as bool: strconv.ParseBool: parsing \"yum\": invalid syntax\n* cannot parse 'UI.hideFiltersWhenIdle' as bool: strconv.ParseBool: parsing \"z\": invalid syntax\n* cannot parse 'UI.minimalGroupWidth' as int: strconv.ParseInt: parsing \"abc4\": invalid syntax\n* cannot parse 'alertAcknowledgement.Enabled' as bool: strconv.ParseBool: parsing \"zzz\": invalid syntax\n* error decoding 'Alertmanager.Interval': time: invalid duration jjs88\n* error decoding 'Alertmanager.Servers[0].Timeout': time: invalid duration bbb\n* error decoding 'Alertmanager.Servers[2].Timeout': time: invalid duration z\n* error decoding 'UI.Refresh': time: unknown unit sm in duration 10sm" +level=fatal msg="Invalid grid.sorting.order value '', allowed options: disabled, startsAt, label" +level=fatal msg="Invalid ui.collapseGroups value '', allowed options: expanded, collapsed, collapsedOnMobile" +level=fatal msg="Invalid ui.theme value '', allowed options: light, dark, auto" +level=error msg="Unknown log level ''" diff --git a/cmd/karma/testdata/missing_config_file.txt b/cmd/karma/testdata/missing_config_file.txt new file mode 100644 index 000000000..5437560ce --- /dev/null +++ b/cmd/karma/testdata/missing_config_file.txt @@ -0,0 +1,4 @@ +# Errors when config.file points to missing file +karma.bin-should-fail --config.file=404.yaml +! stdout . +stderr 'level=fatal msg="Failed to load configuration file \\"404.yaml\\": open 404.yaml: no such file or directory' diff --git a/cmd/karma/testdata/silenceform_populatefromheader_invalid_regex.txt b/cmd/karma/testdata/silenceform_populatefromheader_invalid_regex.txt new file mode 100644 index 000000000..d4d4b8f15 --- /dev/null +++ b/cmd/karma/testdata/silenceform_populatefromheader_invalid_regex.txt @@ -0,0 +1,15 @@ +# Raises an error if silence form populate from header config is using invalid regex rule +karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml +! stdout . +stderr 'msg="Invalid regex for silenceform.author.populate_from_header.value_re: error parsing regexp: invalid nested repetition operator: `\+\+`"' + +-- karma.yaml -- +alertmanager: + servers: + - name: default + uri: https://localhost:9093 +silenceForm: + author: + populate_from_header: + header: "CF-RAY" + value_re: ".++++" diff --git a/cmd/karma/testdata/silenceform_populatefromheader_missing_header.txt b/cmd/karma/testdata/silenceform_populatefromheader_missing_header.txt new file mode 100644 index 000000000..0b8c6eb76 --- /dev/null +++ b/cmd/karma/testdata/silenceform_populatefromheader_missing_header.txt @@ -0,0 +1,14 @@ +# Raises an error if silence form populate from header config is missing header name +karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml +! stdout . +stderr 'msg="silenceform.author.populate_from_header.header is required when silenceform.author.populate_from_header.value_re is set"' + +-- karma.yaml -- +alertmanager: + servers: + - name: default + uri: https://localhost:9093 +silenceForm: + author: + populate_from_header: + value_re: "^(.+)$" diff --git a/cmd/karma/testdata/silenceform_populatefromheader_missing_regex.txt b/cmd/karma/testdata/silenceform_populatefromheader_missing_regex.txt new file mode 100644 index 000000000..e9a03e2bb --- /dev/null +++ b/cmd/karma/testdata/silenceform_populatefromheader_missing_regex.txt @@ -0,0 +1,14 @@ +# Raises an error if silence form populate from header config is missing regex rule +karma.bin-should-fail --log.format=text --log.config=false --log.level=error --config.file=karma.yaml +! stdout . +stderr 'msg="silenceform.author.populate_from_header.value_re is required when silenceform.author.populate_from_header.header is set"' + +-- karma.yaml -- +alertmanager: + servers: + - name: default + uri: https://localhost:9093 +silenceForm: + author: + populate_from_header: + header: "CF-RAY" diff --git a/go.mod b/go.mod index 73465b883..a4faefe59 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,8 @@ require ( github.com/google/go-cmp v0.4.0 github.com/hansrodtang/randomcolor v0.0.0-20160512071917-d27108b3d7a5 github.com/jarcoal/httpmock v1.0.4 + github.com/knadh/koanf v0.8.0 + github.com/mitchellh/mapstructure v1.1.2 github.com/patrickmn/go-cache v2.1.1-0.20180815053127-5633e0862627+incompatible github.com/pmezard/go-difflib v1.0.0 github.com/prometheus/client_golang v1.4.1 @@ -31,7 +33,7 @@ require ( github.com/rogpeppe/go-internal v1.5.2 github.com/sirupsen/logrus v1.4.2 github.com/spf13/pflag v1.0.5 - github.com/spf13/viper v1.6.2 + github.com/spf13/viper v1.6.2 // indirect github.com/xlab/handysort v0.0.0-20150421192137-fb3537ed64a1 // indirect gopkg.in/go-playground/colors.v1 v1.2.0 gopkg.in/yaml.v2 v2.2.8 diff --git a/go.sum b/go.sum index 19a4943e9..e5b2cdb0d 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,7 @@ github.com/elazarl/go-bindata-assetfs v1.0.1-0.20180223160309-38087fe4dafb h1:Dn github.com/elazarl/go-bindata-assetfs v1.0.1-0.20180223160309-38087fe4dafb/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4= github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/getsentry/raven-go v0.2.0 h1:no+xWJRb5ZI7eE8TWgIq1jLulQiIoLG0IfYxv5JYMGs= @@ -184,6 +185,7 @@ github.com/go-toolsmith/strparse v1.0.0 h1:Vcw78DnpCAKlM20kSbAyO4mPfJn/lyYA4BJUD github.com/go-toolsmith/strparse v1.0.0/go.mod h1:YI2nUKP9YGZnL/L1/DLFBfixrcjslWct4wyljWhSRy8= github.com/go-toolsmith/typep v1.0.0 h1:zKymWyA1TRYvqYrYDrfEMZULyrhcnGY3x7LDKU2XQaA= github.com/go-toolsmith/typep v1.0.0/go.mod h1:JSQCQMUPdRlMZFswiq3TGpNp1GMktqkR2Ns5AIQkATU= +github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/gofrs/flock v0.0.0-20190320160742-5135e617513b h1:ekuhfTjngPhisSjOJ0QWKpPQE8/rbknHaes6WVJj5Hw= @@ -280,6 +282,8 @@ github.com/klauspost/compress v1.4.0/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0 github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/cpuid v0.0.0-20180405133222-e7e905edc00e/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= +github.com/knadh/koanf v0.8.0 h1:j+851fIYrSzmdCn4jbiwU247qwMDIhyWXRJs74ZCB+8= +github.com/knadh/koanf v0.8.0/go.mod h1:kVvmDbXnBtW49Czi4c1M+nnOWF0YSNZ8BaKvE/bCO1w= github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= @@ -345,6 +349,8 @@ github.com/patrickmn/go-cache v2.1.1-0.20180815053127-5633e0862627+incompatible/ github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pelletier/go-toml v1.4.0 h1:u3Z1r+oOXJIkxqw34zVhyPgjBsm6X2wn21NWs/HfSeg= +github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -371,6 +377,7 @@ github.com/prometheus/procfs v0.0.8 h1:+fpWZdT24pJBiqJdAwYBjPSk+5YmQzYNPYzQsdzLk github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/quasilyte/go-consistent v0.0.0-20190521200055-c6f3937de18c/go.mod h1:5STLWrekHfjyYwxBRVRXNOSewLJ3PWfDJd1VyTS21fI= +github.com/rhnvrm/simples3 v0.5.0/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/go-internal v1.3.0 h1:RR9dF3JtopPvtkroDZuVD7qquD0bnHlKSqaQhgwt8yk= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= @@ -512,6 +519,7 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82 h1:ywK/j/KkyTHcdyYSZNXGjMwgmDSfjglYZ3vStQ/gSCU= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/config/config.go b/internal/config/config.go index bf6dd8169..f3e9b48bc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,7 +3,6 @@ package config import ( "bufio" "bytes" - "io/ioutil" "os" "regexp" "strings" @@ -11,8 +10,15 @@ import ( "github.com/prymitive/karma/internal/slices" "github.com/prymitive/karma/internal/uri" + + "github.com/knadh/koanf" + yamlParser "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/confmap" + "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/providers/posflag" + "github.com/mitchellh/mapstructure" "github.com/spf13/pflag" - "github.com/spf13/viper" log "github.com/sirupsen/logrus" yaml "gopkg.in/yaml.v2" @@ -58,7 +64,7 @@ func SetupFlags(f *pflag.FlagSet) { "List of annotations to keep, all other annotations will be stripped") f.StringSlice("annotations.strip", []string{}, "List of annotations to ignore") - f.String("config.file", "", "Full path to the configuration file") + f.String("config.file", "", "Full path to the configuration file, 'karma.yaml' will be used if found in the current working directory") f.String("custom.css", "", "Path to a file with custom CSS to load") f.String("custom.js", "", "Path to a file with custom JavaScript to load") @@ -111,126 +117,132 @@ func SetupFlags(f *pflag.FlagSet) { f.String("ui.collapseGroups", "collapsedOnMobile", "Default state for alert groups") } -// ReadConfig will read all sources of configuration, merge all keys and -// populate global Config variable, it should be only called on startup -func (config *configSchema) Read(flags *pflag.FlagSet) string { - v := viper.New() - - err := v.BindPFlags(flags) - if err != nil { - log.Errorf("Failed to bind flag set to the configuration: %s", err) - } - - v.AutomaticEnv() - v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) - - // special envs - // HOST and PORT is used by gin - err = v.BindEnv("listen.address", "HOST") - if err != nil { - log.Errorf("Failed to bind listen.address config key to the HOST env variable: %s", err) - } - - err = v.BindEnv("listen.port", "PORT") - if err != nil { - log.Errorf("Failed to bind listen.port config key to the PORT env variable: %s", err) - } - - // raven-go expects this - err = v.BindEnv("sentry.private", "SENTRY_DSN") - if err != nil { - log.Errorf("Failed to bind sentry.private config key to the SENTRY_DSN env variable: %s", err) - } - - v.SetConfigType("yaml") - configFile := v.GetString("config.file") - // if config file is not set then try loading karma.yaml from current directory +func readConfigFile(k *koanf.Koanf, flags *pflag.FlagSet) string { + configFile, _ := flags.GetString("config.file") + // if config.file is not passed via flags then see if there's karma.yaml in + // current working directory if configFile == "" { - if _, err = os.Stat("karma.yaml"); !os.IsNotExist(err) { + if _, err := os.Stat("karma.yaml"); !os.IsNotExist(err) { configFile = "karma.yaml" } } if configFile != "" { - v.SetConfigFile(configFile) + if err := k.Load(file.Provider(configFile), yamlParser.Parser()); err != nil { + log.Fatalf("Failed to load configuration file %q: %v", configFile, err) + } + return configFile + } + return configFile +} + +func readEnvVariables(k *koanf.Koanf) { + customEnvs := map[string]string{ + "HOST": "listen.address", + "PORT": "listen.port", + "SENTRY_DSN": "sentry.private", + } + for env, key := range customEnvs { + if _, found := os.LookupEnv(env); found { + _ = k.Load(confmap.Provider(map[string]interface{}{ + key: os.Getenv(env), + }, "."), nil) + } } - err = v.ReadInConfig() - if v.ConfigFileUsed() != "" && err != nil { - log.Fatal(err) - } + _ = k.Load(env.Provider("", ".", func(s string) string { + switch s { + case "ALERTMANAGER_EXTERNAL_URI": + return "alertmanager.external_uri" + case "ALERTACKNOWLEDGEMENT_ENABLED": + return "alertAcknowledgement.enabled" + case "ALERTACKNOWLEDGEMENT_DURATION": + return "alertAcknowledgement.duration" + case "ALERTACKNOWLEDGEMENT_AUTHOR": + return "alertAcknowledgement.author" + case "ALERTACKNOWLEDGEMENT_COMMENTPREFIX": + return "alertAcknowledgement.commentPrefix" + case "SILENCEFORM_AUTHOR_POPULATE_FROM_HEADER_HEADER": + return "silenceForm.author.populate_from_header.header" + case "SILENCEFORM_AUTHOR_POPULATE_FROM_HEADER_VALUE_RE": + return "silenceForm.author.populate_from_header.value_re" + case "SILENCEFORM_STRIP_LABELS": + return "silenceForm.strip.labels" + case "UI_HIDEFILTERSWHENIDLE": + return "ui.hideFiltersWhenIdle" + case "UI_COLORTITLEBAR": + return "ui.colorTitlebar" + case "UI_MINIMALGROUPWIDTH": + return "ui.minimalGroupWidth" + case "UI_ALERTSPERGROUP": + return "ui.alertsPerGroup" + case "UI_COLLAPSEGROUPS": + return "ui.collapseGroups" + default: + return strings.Replace(strings.ToLower(s), "_", ".", -1) + } + }), nil) +} - config.Alertmanager.Servers = []AlertmanagerConfig{} - config.Alertmanager.Interval = v.GetDuration("alertmanager.interval") - config.AlertAcknowledgement.Enabled = v.GetBool("alertAcknowledgement.enabled") - config.AlertAcknowledgement.Author = v.GetString("alertAcknowledgement.author") - config.AlertAcknowledgement.CommentPrefix = v.GetString("alertAcknowledgement.commentPrefix") - config.AlertAcknowledgement.Duration = v.GetDuration("alertAcknowledgement.duration") - config.Annotations.Default.Hidden = v.GetBool("annotations.default.hidden") - config.Annotations.Hidden = v.GetStringSlice("annotations.hidden") - config.Annotations.Visible = v.GetStringSlice("annotations.visible") - config.Annotations.Keep = v.GetStringSlice("annotations.keep") - config.Annotations.Strip = v.GetStringSlice("annotations.strip") - config.Custom.CSS = v.GetString("custom.css") - config.Custom.JS = v.GetString("custom.js") - config.Debug = v.GetBool("debug") - config.Filters.Default = v.GetStringSlice("filters.default") - config.Grid.Sorting.Order = v.GetString("grid.sorting.order") - config.Grid.Sorting.Reverse = v.GetBool("grid.sorting.reverse") - config.Grid.Sorting.Label = v.GetString("grid.sorting.label") - config.Karma.Name = v.GetString("karma.name") - config.Labels.Color.Custom = CustomLabelColors{} - config.Labels.Color.Static = v.GetStringSlice("labels.color.static") - config.Labels.Color.Unique = v.GetStringSlice("labels.color.unique") - config.Labels.Keep = v.GetStringSlice("labels.keep") - config.Labels.Strip = v.GetStringSlice("labels.strip") - config.Listen.Address = v.GetString("listen.address") - config.Listen.Port = v.GetInt("listen.port") - config.Listen.Prefix = v.GetString("listen.prefix") - config.Log.Config = v.GetBool("log.config") - config.Log.Level = v.GetString("log.level") - config.Log.Format = v.GetString("log.format") - config.Log.Timestamp = v.GetBool("log.timestamp") - config.Receivers.Keep = v.GetStringSlice("receivers.keep") - config.Receivers.Strip = v.GetStringSlice("receivers.strip") - config.Sentry.Private = v.GetString("sentry.private") - config.Sentry.Public = v.GetString("sentry.public") - config.SilenceForm.Strip.Labels = v.GetStringSlice("silenceform.strip.labels") - config.SilenceForm.Author.PopulateFromHeader.Header = v.GetString("silenceform.author.populate_from_header.header") - config.SilenceForm.Author.PopulateFromHeader.ValueRegex = v.GetString("silenceform.author.populate_from_header.value_re") - config.UI.Refresh = v.GetDuration("ui.refresh") - config.UI.HideFiltersWhenIdle = v.GetBool("ui.hideFiltersWhenIdle") - config.UI.ColorTitlebar = v.GetBool("ui.colorTitlebar") - config.UI.Theme = v.GetString("ui.theme") - config.UI.MinimalGroupWidth = v.GetInt("ui.minimalGroupWidth") - config.UI.AlertsPerGroup = v.GetInt("ui.alertsPerGroup") - config.UI.CollapseGroups = v.GetString("ui.collapseGroups") +func readFlags(k *koanf.Koanf, flags *pflag.FlagSet) { + _ = k.Load(posflag.Provider(flags, ".", k), nil) +} + +// ReadConfig will read all sources of configuration, merge all keys and +// populate global Config variable, it should be only called on startup +// Order in which we read configuration: +// 1. CLI flags +// 2. Config file +// 3. Environment variables +func (config *configSchema) Read(flags *pflag.FlagSet) string { + k := koanf.New(".") + var configFileUsed string + + // 3. read all environemnt variables + readEnvVariables(k) + // 2. read config file + if cf := readConfigFile(k, flags); cf != "" { + configFileUsed = cf + } + // 1. read flags + readFlags(k, flags) + + dConf := mapstructure.DecoderConfig{ + Result: &config, + WeaklyTypedInput: true, + DecodeHook: mapstructure.ComposeDecodeHookFunc( + mapstructure.StringToSliceHookFunc(" "), + mapstructure.StringToTimeDurationHookFunc(), + ), + ZeroFields: true, + } + kConf := koanf.UnmarshalConf{ + Tag: "koanf", + FlatPaths: false, + DecoderConfig: &dConf, + } + err := k.UnmarshalWithConf("", &config, kConf) + if err != nil { + log.Fatalf("Failed to unmarshal configuration: %v", err) + } if config.SilenceForm.Author.PopulateFromHeader.ValueRegex != "" { _, err = regexp.Compile(config.SilenceForm.Author.PopulateFromHeader.ValueRegex) if err != nil { log.Fatalf("Invalid regex for silenceform.author.populate_from_header.value_re: %s", err.Error()) } + if config.SilenceForm.Author.PopulateFromHeader.Header == "" { + log.Fatalf("silenceform.author.populate_from_header.header is required when silenceform.author.populate_from_header.value_re is set") + } + } else if config.SilenceForm.Author.PopulateFromHeader.Header != "" { + log.Fatalf("silenceform.author.populate_from_header.value_re is required when silenceform.author.populate_from_header.header is set") } - err = v.UnmarshalKey("alertmanager.servers", &config.Alertmanager.Servers) - if err != nil { - log.Fatal(err) - } for i, s := range config.Alertmanager.Servers { if s.Timeout.Seconds() == 0 { - config.Alertmanager.Servers[i].Timeout = v.GetDuration("alertmanager.timeout") + config.Alertmanager.Servers[i].Timeout = config.Alertmanager.Timeout } } - err = v.UnmarshalKey("silences.comments.linkDetect.rules", &config.Silences.Comments.LinkDetect.Rules) - if err != nil { - log.Fatal(err) - } - - err = v.UnmarshalKey("labels.color.custom", &config.Labels.Color.Custom) - if err != nil { - log.Fatal(err) - } for labelName, customColors := range config.Labels.Color.Custom { for i, customColor := range customColors { if customColor.Value == "" && customColor.ValueRegex == "" { @@ -245,11 +257,6 @@ func (config *configSchema) Read(flags *pflag.FlagSet) string { } } - err = v.UnmarshalKey("grid.sorting.customValues.labels", &config.Grid.Sorting.CustomValues.Labels) - if err != nil { - log.Fatal(err) - } - if !slices.StringInSlice([]string{"disabled", "startsAt", "label"}, config.Grid.Sorting.Order) { log.Fatalf("Invalid grid.sorting.order value '%s', allowed options: disabled, startsAt, label", config.Grid.Sorting.Order) } @@ -262,43 +269,24 @@ func (config *configSchema) Read(flags *pflag.FlagSet) string { log.Fatalf("Invalid ui.theme value '%s', allowed options: light, dark, auto", config.UI.Theme) } - // FIXME workaround for https://github.com/prymitive/karma/issues/507 - // until https://github.com/spf13/viper/pull/635 is merged - // read in raw config file if it's used and override maps where keys are label - // names so we can't enforce parsed config key names - if v.ConfigFileUsed() != "" { - raw := configSchema{} - - var rawConfigFile []byte - rawConfigFile, err = ioutil.ReadFile(v.ConfigFileUsed()) - if err != nil { - log.Fatal(err) - } - - err = yaml.Unmarshal(rawConfigFile, &raw) - if err != nil { - log.Fatal(err) - } - - config.Grid.Sorting.CustomValues.Labels = raw.Grid.Sorting.CustomValues.Labels - } - // accept single Alertmanager server from flag/env if nothing is set yet - if len(config.Alertmanager.Servers) == 0 && v.GetString("alertmanager.uri") != "" { + if len(config.Alertmanager.Servers) == 0 && config.Alertmanager.URI != "" { config.Alertmanager.Servers = []AlertmanagerConfig{ { - Name: v.GetString("alertmanager.name"), - URI: v.GetString("alertmanager.uri"), - ExternalURI: v.GetString("alertmanager.external_uri"), - Timeout: v.GetDuration("alertmanager.timeout"), - Proxy: v.GetBool("alertmanager.proxy"), - ReadOnly: v.GetBool("alertmanager.readonly"), + Name: config.Alertmanager.Name, + URI: config.Alertmanager.URI, + ExternalURI: config.Alertmanager.ExternalURI, + Timeout: config.Alertmanager.Timeout, + Proxy: config.Alertmanager.Proxy, + ReadOnly: config.Alertmanager.ReadOnly, Headers: make(map[string]string), }, } } - return v.ConfigFileUsed() + Config = *config + + return configFileUsed } // LogValues will dump runtime config to logs @@ -328,11 +316,7 @@ func (config *configSchema) LogValues() { config.Sentry.Private = uri.SanitizeURI(config.Sentry.Private) } - out, err := yaml.Marshal(cfg) - if err != nil { - log.Error(err) - } - + out, _ := yaml.Marshal(cfg) log.Info("Parsed configuration:") scanner := bufio.NewScanner(bytes.NewReader(out)) for scanner.Scan() { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 762fc0aa5..b58aa8f8b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -209,6 +209,7 @@ func TestReadSimpleConfig(t *testing.T) { resetEnv() log.SetLevel(log.ErrorLevel) os.Setenv("ALERTMANAGER_URI", "http://localhost") + os.Setenv("ALERTMANAGER_EXTERNAL_URI", "http://localhost:9090") os.Setenv("ALERTMANAGER_NAME", "single") os.Setenv("ALERTMANAGER_TIMEOUT", "15s") os.Setenv("ALERTMANAGER_PROXY", "true") @@ -218,6 +219,12 @@ func TestReadSimpleConfig(t *testing.T) { t.Errorf("Expected 1 Alertmanager server, got %d", len(Config.Alertmanager.Servers)) } else { am := Config.Alertmanager.Servers[0] + if am.URI != "http://localhost" { + t.Errorf("Expect Alertmanager URI 'http://localhost' got '%s'", am.URI) + } + if am.ExternalURI != "http://localhost:9090" { + t.Errorf("Expect Alertmanager external_uri 'http://localhost:9090' got '%s'", am.ExternalURI) + } if am.Name != "single" { t.Errorf("Expect Alertmanager name 'single' got '%s'", am.Name) } @@ -227,6 +234,9 @@ func TestReadSimpleConfig(t *testing.T) { if Config.Alertmanager.Interval != time.Minute*3 { t.Errorf("Expect Alertmanager timeout '%v' got '%v'", time.Minute*3, Config.Alertmanager.Interval) } + if am.Proxy != true { + t.Errorf("Expect Alertmanager proxy 'true' got '%v'", am.Proxy) + } } } @@ -284,6 +294,9 @@ func TestInvalidSilenceFormRegex(t *testing.T) { mockConfigRead() + if Config.SilenceForm.Author.PopulateFromHeader.ValueRegex != ".****" { + t.Errorf("Config.SilenceForm.Author.PopulateFromHeader.ValueRegex value is %q", Config.SilenceForm.Author.PopulateFromHeader.ValueRegex) + } if !wasFatal { t.Error("Invalid silence form regex didn't cause log.Fatal()") } diff --git a/internal/config/models.go b/internal/config/models.go index c9ee7e58c..088dcf094 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -8,44 +8,50 @@ import ( type AlertmanagerConfig struct { Name string URI string - ExternalURI string `yaml:"external_uri" mapstructure:"external_uri"` + ExternalURI string `yaml:"external_uri" koanf:"external_uri"` Timeout time.Duration Proxy bool - ReadOnly bool `yaml:"readonly" mapstructure:"readonly"` + ReadOnly bool `yaml:"readonly"` TLS struct { CA string Cert string Key string - InsecureSkipVerify bool `yaml:"insecureSkipVerify" mapstructure:"insecureSkipVerify"` + InsecureSkipVerify bool `yaml:"insecureSkipVerify" koanf:"insecureSkipVerify"` } Headers map[string]string } type LinkDetectRules struct { - Regex string `yaml:"regex" mapstructure:"regex"` - URITemplate string `yaml:"uriTemplate" mapstructure:"uriTemplate"` + Regex string `yaml:"regex"` + URITemplate string `yaml:"uriTemplate" koanf:"uriTemplate"` } type CustomLabelColor struct { - Value string `yaml:"value" mapstructure:"value"` - ValueRegex string `yaml:"value_re" mapstructure:"value_re"` - CompiledRegex *regexp.Regexp `yaml:"-" mapstructure:"-"` - Color string `yaml:"color" mapstructure:"color"` + Value string `yaml:"value"` + ValueRegex string `yaml:"value_re" koanf:"value_re"` + CompiledRegex *regexp.Regexp `yaml:"-"` + Color string `yaml:"color"` } type CustomLabelColors map[string][]CustomLabelColor type configSchema struct { Alertmanager struct { - Interval time.Duration - Servers []AlertmanagerConfig + Interval time.Duration + Servers []AlertmanagerConfig + Name string `yaml:"-" koanf:"name"` + Timeout time.Duration `yaml:"-" koanf:"timeout"` + URI string `yaml:"-" koanf:"uri"` + ExternalURI string `yaml:"-" koanf:"external_uri"` + Proxy bool `yaml:"-" koanf:"proxy"` + ReadOnly bool `yaml:"-" koanf:"readonly"` } AlertAcknowledgement struct { Enabled bool Duration time.Duration Author string - CommentPrefix string `yaml:"commentPrefix" mapstructure:"commentPrefix"` - } `yaml:"alertAcknowledgement" mapstructure:"alertAcknowledgement"` + CommentPrefix string `yaml:"commentPrefix" koanf:"commentPrefix"` + } `yaml:"alertAcknowledgement" koanf:"alertAcknowledgement"` Annotations struct { Default struct { Hidden bool @@ -70,9 +76,9 @@ type configSchema struct { Label string CustomValues struct { Labels map[string]map[string]string - } `yaml:"customValues" mapstructure:"customValues"` + } `yaml:"customValues" koanf:"customValues"` } - } + } `yaml:"grid"` Karma struct { Name string } @@ -107,28 +113,28 @@ type configSchema struct { Silences struct { Comments struct { LinkDetect struct { - Rules []LinkDetectRules `yaml:"rules" mapstructure:"rules"` - } `yaml:"linkDetect" mapstructure:"linkDetect"` - } `yaml:"comments" mapstructure:"comments"` - } `yaml:"silences" mapstructure:"silences"` + Rules []LinkDetectRules `yaml:"rules"` + } `yaml:"linkDetect" koanf:"linkDetect"` + } `yaml:"comments"` + } `yaml:"silences"` SilenceForm struct { Author struct { PopulateFromHeader struct { - Header string `yaml:"header" mapstructure:"header"` - ValueRegex string `yaml:"value_re" mapstructure:"value_re"` - } `yaml:"populate_from_header" mapstructure:"populate_from_header"` - } `yaml:"author" mapstructure:"author"` + Header string `yaml:"header" koanf:"header"` + ValueRegex string `yaml:"value_re" koanf:"value_re"` + } `yaml:"populate_from_header" koanf:"populate_from_header"` + } `yaml:"author"` Strip struct { Labels []string } - } `yaml:"silenceForm" mapstructure:"silenceForm"` + } `yaml:"silenceForm" koanf:"silenceForm"` UI struct { Refresh time.Duration - HideFiltersWhenIdle bool `yaml:"hideFiltersWhenIdle" mapstructure:"hideFiltersWhenIdle"` - ColorTitlebar bool `yaml:"colorTitlebar" mapstructure:"colorTitlebar"` - Theme string `yaml:"theme" mapstructure:"theme"` - MinimalGroupWidth int `yaml:"minimalGroupWidth" mapstructure:"minimalGroupWidth"` - AlertsPerGroup int `yaml:"alertsPerGroup" mapstructure:"alertsPerGroup"` - CollapseGroups string `yaml:"collapseGroups" mapstructure:"collapseGroups"` + HideFiltersWhenIdle bool `yaml:"hideFiltersWhenIdle" koanf:"hideFiltersWhenIdle"` + ColorTitlebar bool `yaml:"colorTitlebar" koanf:"colorTitlebar"` + Theme string `yaml:"theme" koanf:"theme"` + MinimalGroupWidth int `yaml:"minimalGroupWidth" koanf:"minimalGroupWidth"` + AlertsPerGroup int `yaml:"alertsPerGroup" koanf:"alertsPerGroup"` + CollapseGroups string `yaml:"collapseGroups" koanf:"collapseGroups"` } } diff --git a/scripts/gocover.sh b/scripts/gocover.sh index c416b3333..6139327bd 100755 --- a/scripts/gocover.sh +++ b/scripts/gocover.sh @@ -8,7 +8,7 @@ for d in $(go list ./... | grep -vE 'prymitive/karma/internal/mapper/v017/(clien go test \ -coverprofile=profile.out \ -coverpkg=$(go list ./... | grep -vE 'prymitive/karma/internal/mapper/v017/(client|models)' | tr '\n' ',') \ - $d 2>&1 | grep -v 'warning: no packages being tested depend on matches for pattern' + $d 2>&1 | grep -v 'warning: no packages being tested depend on matches for pattern' | sed s/'of statements in .*'/''/g if [ -f profile.out ]; then cat profile.out >> coverage.txt rm profile.out