Files
karma/internal/config/config.go
2026-02-07 20:13:03 +00:00

544 lines
21 KiB
Go

package config
import (
"bufio"
"bytes"
"errors"
"fmt"
"os"
"regexp"
"slices"
"strings"
"time"
"github.com/prymitive/karma/internal/regex"
"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/rs/zerolog/log"
"github.com/spf13/pflag"
yaml "go.yaml.in/yaml/v3"
)
// Config will hold final configuration read from the file and flags
var Config *configSchema
func init() {
Config = &configSchema{}
}
// SetupFlags is used to attach configuration flags to the main flag set
func SetupFlags(f *pflag.FlagSet) {
f.Duration("alertmanager.interval", time.Minute,
"Interval for fetching data from Alertmanager servers")
f.String("alertmanager.name", "default",
"Name for the Alertmanager server (only used with simplified config)")
f.String("alertmanager.uri", "",
"Alertmanager server URI (only used with simplified config)")
f.String("alertmanager.external_uri", "",
"Alertmanager server URI used for web UI links (only used with simplified config)")
f.Duration("alertmanager.timeout", time.Second*40,
"Timeout for requests sent to the Alertmanager server (only used with simplified config)")
f.Bool("alertmanager.proxy", false,
"Proxy all client requests to Alertmanager via karma (only used with simplified config)")
f.Bool("alertmanager.readonly", false,
"Enable read-only mode that disable silence management (only used with simplified config)")
f.String("alertmanager.cors.credentials", "include", "CORS credentials policy for browser fetch requests")
f.String("alertmanager.tls.ca", "", "Path to CA certificate used to establish TLS connection to the "+
"Alertmanager server (only used with simplified config)")
f.String("alertmanager.tls.cert", "", "Path to a TLS client certificate file to use when establishing "+
"TLS connections to the Alertmanager server - requires alertmanager.tls.key to be set (only used with simplified config)")
f.String("alertmanager.tls.key", "", "Path to a TLS client key file to use when establishing "+
"TLS connections to the Alertmanager server - requires alertmanager.tls.key to be set (only used with simplified config)")
f.String("karma.name", "karma", "Name for the karma instance")
f.Bool("alertAcknowledgement.enabled", false, "Enable alert acknowledging")
f.Duration("alertAcknowledgement.duration", time.Minute*15, "Initial silence duration when acknowledging alerts with short lived silences")
f.String("alertAcknowledgement.author", "karma", "Default silence author when acknowledging alerts with short lived silences")
f.String("alertAcknowledgement.comment", "ACK! This alert was acknowledged using karma on %NOW%", "Comment used when acknowledging alerts with short lived silences")
f.String("authorization.acl.silences", "", "Path to silence ACL config file")
f.Bool(
"annotations.default.hidden", false,
"Hide all annotations by default unless explicitly listed in the 'visible' list")
f.StringSlice("annotations.hidden", []string{},
"List of annotations that are hidden by default")
f.StringSlice("annotations.visible", []string{},
"List of annotations that are visible by default")
f.StringSlice("annotations.keep", []string{},
"List of annotations to keep, all other annotations will be stripped")
f.StringSlice("annotations.strip", []string{}, "List of annotations to ignore")
f.StringSlice("annotations.actions", []string{}, "List of annotations that will be moved to the alert menu")
f.StringSlice("annotations.order", []string{}, "Preferred order of annotation names")
f.Bool("annotations.enableInsecureHTML", false, "Enable HTML strings in annotations to be parsed as HTML, enable at your own risk")
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")
f.Bool("debug", false, "Enable debug mode")
f.StringSlice("filters.default", []string{}, "List of default filters")
f.StringSlice("labels.order", []string{}, "Preferred order of label names")
f.StringSlice("labels.color.static", []string{},
"List of label names that should have the same (but distinct) color")
f.StringSlice("labels.color.unique", []string{},
"List of label names that should have unique color")
f.StringSlice("labels.keep", []string{},
"List of labels to keep, all other labels will be stripped")
f.StringSlice("labels.keep_re", []string{},
"List of regular expressions to keep matching labels, all other labels will be stripped")
f.StringSlice("labels.strip", []string{}, "List of labels to ignore")
f.StringSlice("labels.strip_re", []string{}, "List of regular expressions to ignore matching labels")
f.StringSlice("labels.valueOnly", []string{},
"List of label names for which only the name will be shown in the UI")
f.StringSlice("labels.valueOnly_re", []string{},
"List of regular expressions to show only the name of matching labels")
f.String("grid.sorting.order", "startsAt", "Default sort order for alert grid")
f.Bool("grid.sorting.reverse", true, "Reverse sort order")
f.String("grid.sorting.label", "alertname", "Label name to use when sorting alert grid by label")
f.StringSlice("grid.auto.ignore", []string{}, "List of label names not allowed for automatic multi-grid")
f.StringSlice("grid.auto.order", []string{}, "Order of preference for selecting label names for automatic multi-grid")
f.Int("grid.groupLimit", 40, "Default number of groups to show for each grid")
f.Bool("history.enabled", true, "Enable alert history queries")
f.Duration("history.timeout", time.Second*20, "Timeout for history queries against source Prometheus servers")
f.Int("history.workers", 30, "Number of history query workers to run")
f.Bool("log.config", false, "Log used configuration to log on startup")
f.String("log.level", "info",
"Log level, one of: debug, info, warning, error, fatal and panic")
f.String("log.format", "text",
"Log format, one of: text, json")
f.Bool("log.requests", false, "Enable request logging")
f.Bool("log.timestamp", false, "Add timestamps to all log messages")
f.StringSlice("receivers.keep", []string{},
"List of receivers to keep, all alerts with different receivers will be ignored")
f.StringSlice("receivers.keep_re", []string{},
"List of regular expressions to keep matching receivers, all other receivers will be ignored")
f.StringSlice("receivers.strip", []string{},
"List of receivers to not display alerts for")
f.StringSlice("receivers.strip_re", []string{},
"List of regular expressions to ignore matching receivers")
f.Duration("silences.expired", time.Minute*10, "Maximum age of expired silences to show on active alerts")
f.StringSlice("silenceForm.strip.labels", []string{}, "List of labels to ignore when auto-filling silence form from alerts")
f.StringSlice("silenceForm.defaultAlertmanagers", []string{}, "List of Alertmanager names to use as default when creating a new silence")
f.String("listen.address", "", "IP/Hostname to listen on")
f.Int("listen.port", 8080, "HTTP port to listen on")
f.String("listen.prefix", "/", "URL prefix")
f.String("listen.tls.cert", "", "TLS certificate path (enables HTTPS)")
f.String("listen.tls.key", "", "TLS key path (enables HTTPS)")
f.Duration("listen.timeout.read", time.Second*10, "HTTP request read timeout")
f.Duration("listen.timeout.write", time.Second*20, "HTTP response write timeout")
f.Duration("ui.refresh", time.Second*30, "UI refresh interval")
f.Bool("ui.hideFiltersWhenIdle", true, "Hide the filters bar when idle")
f.Bool("ui.colorTitlebar", false, "Color alert group titlebar based on alert state")
f.String("ui.theme", "auto", "Default theme, 'light', 'dark' or 'auto' (follow browser preference)")
f.Bool("ui.animations", true, "Enable UI animations")
f.Int("ui.minimalGroupWidth", 420, "Minimal width for each alert group on the grid")
f.Int("ui.alertsPerGroup", 5, "Default number of alerts to show for each alert group")
f.String("ui.collapseGroups", "collapsedOnMobile", "Default state for alert groups")
}
func validateConfigFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
cfg := configSchema{}
d := yaml.NewDecoder(f)
d.KnownFields(true)
err = d.Decode(&cfg)
if err != nil {
return err
}
return nil
}
func readConfigFile(k *koanf.Koanf, flags *pflag.FlagSet) (string, error) {
var configFile string
// 1. Load file from flags is set
configFile, _ = flags.GetString("config.file")
// 2. Fallback to CONFIG_FILE env if there's no flag value
if configFile == "" {
if v, found := os.LookupEnv("CONFIG_FILE"); found {
configFile = v
}
}
// 3 see if there's karma.yaml in current working directory
if configFile == "" {
if _, err := os.Stat("karma.yaml"); !os.IsNotExist(err) {
configFile = "karma.yaml"
}
}
if configFile != "" {
if err := k.Load(file.Provider(configFile), yamlParser.Parser()); err != nil {
return "", fmt.Errorf("failed to load configuration file %q: %w", configFile, err)
}
return configFile, nil
}
return configFile, nil
}
func readEnvVariables(k *koanf.Koanf) {
customEnvs := map[string]string{
"HOST": "listen.address",
"PORT": "listen.port",
}
for env, key := range customEnvs {
if _, found := os.LookupEnv(env); found {
_ = k.Load(confmap.Provider(map[string]any{
key: os.Getenv(env),
}, "."), nil)
}
}
_ = k.Load(env.Provider("", ".", func(s string) string {
switch s {
case "ALERTMANAGER_EXTERNAL_URI":
return "alertmanager.external_uri"
case "ALERTMANAGER_TLS_INSECURE_SKIP_VERIFY":
return "alertmanager.tls.insecureSkipVerify"
case "ALERTACKNOWLEDGEMENT_ENABLED":
return "alertAcknowledgement.enabled"
case "ALERTACKNOWLEDGEMENT_DURATION":
return "alertAcknowledgement.duration"
case "ALERTACKNOWLEDGEMENT_AUTHOR":
return "alertAcknowledgement.author"
case "ALERTACKNOWLEDGEMENT_COMMENT":
return "alertAcknowledgement.comment"
case "ANNOTATIONS_ENABLEINSECUREHTML":
return "annotations.enableInsecureHTML"
case "AUTHENTICATION_HEADER_VALUE_RE":
return "authentication.header.value_re"
case "GRID_GROUPLIMIT":
return "grid.groupLimit"
case "LABELS_KEEP_RE":
return "labels.keep_re"
case "LABELS_STRIP_RE":
return "labels.strip_re"
case "LABELS_VALUEONLY":
return "labels.valueOnly"
case "LABELS_VALUEONLY_RE":
return "labels.valueOnly_re"
case "SILENCEFORM_STRIP_LABELS":
return "silenceForm.strip.labels"
case "SILENCEFORM_DEFAULTALERTMANAGERS":
return "silenceForm.defaultAlertmanagers"
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.ReplaceAll(strings.ToLower(s), "_", ".")
}
}), nil)
}
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, error) {
k := koanf.New(".")
var configFileUsed string
// 3. read all environment variables
readEnvVariables(k)
// 2. read config file
cf, err := readConfigFile(k, flags)
if err != nil {
return "", err
}
if 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,
}
err = k.UnmarshalWithConf("", &config, koanf.UnmarshalConf{
Tag: "koanf",
FlatPaths: false,
DecoderConfig: &dConf,
})
if err != nil {
return "", fmt.Errorf("failed to unmarshal configuration: %w", err)
}
if configFileUsed != "" {
if err = validateConfigFile(configFileUsed); err != nil {
return "", fmt.Errorf("failed to parse configuration file %q: %w", configFileUsed, err)
}
}
if config.Authentication.Header.Name != "" && len(config.Authentication.BasicAuth.Users) > 0 {
return "", errors.New("both authentication.basicAuth.users and authentication.header.name is set, only one can be enabled")
}
if config.Authentication.Header.GroupValueSeparator == "" {
config.Authentication.Header.GroupValueSeparator = " "
}
if config.Authentication.Header.ValueRegex != "" {
_, err = regex.CompileAnchored(config.Authentication.Header.ValueRegex)
if err != nil {
return "", fmt.Errorf("invalid regex for authentication.header.value_re: %w", err)
}
if config.Authentication.Header.Name == "" {
return "", errors.New("authentication.header.name is required when authentication.header.value_re is set")
}
} else if config.Authentication.Header.Name != "" {
return "", errors.New("authentication.header.value_re is required when authentication.header.name is set")
}
if config.Authentication.Header.GroupValueRegex != "" {
_, err = regex.CompileAnchored(config.Authentication.Header.GroupValueRegex)
if err != nil {
return "", fmt.Errorf("invalid regex for authentication.header.group_value_re: %w", err)
}
if config.Authentication.Header.GroupName == "" {
return "", errors.New("authentication.header.group_name is required when authentication.header.group_value_re is set")
}
} else if config.Authentication.Header.GroupName != "" {
return "", errors.New("authentication.header.group_value_re is required when authentication.header.group_name is set")
}
for _, u := range config.Authentication.BasicAuth.Users {
if u.Username == "" || u.Password == "" {
return "", errors.New("authentication.basicAuth.users require both username and password to be set")
}
}
if config.Authentication.Header.Name != "" || len(config.Authentication.BasicAuth.Users) > 0 {
config.Authentication.Enabled = true
}
if !slices.Contains([]string{"omit", "include", "same-origin"}, config.Alertmanager.CORS.Credentials) {
return "", fmt.Errorf("invalid alertmanager.cors.credentials value '%s', allowed options: omit, inclue, same-origin", config.Alertmanager.CORS.Credentials)
}
for i, s := range config.Alertmanager.Servers {
if s.Name == "" {
config.Alertmanager.Servers[i].Name = "default"
}
if s.Timeout.Seconds() == 0 {
config.Alertmanager.Servers[i].Timeout = config.Alertmanager.Timeout
}
if s.CORS.Credentials == "" {
config.Alertmanager.Servers[i].CORS.Credentials = config.Alertmanager.CORS.Credentials
}
if !slices.Contains([]string{"omit", "include", "same-origin"}, config.Alertmanager.Servers[i].CORS.Credentials) {
return "", fmt.Errorf("invalid cors.credentials value '%s' for alertmanager '%s', allowed options: omit, inclue, same-origin", config.Alertmanager.Servers[i].CORS.Credentials, s.Name)
}
}
for _, authGroup := range config.Authorization.Groups {
if authGroup.Name == "" {
return "", errors.New("'name' is required for every authorization group")
}
if len(authGroup.Members) == 0 {
return "", errors.New("'members' is required for every authorization group")
}
}
config.Labels.CompiledKeepRegex = make([]*regexp.Regexp, len(config.Labels.KeepRegex))
for i, keepRegex := range config.Labels.KeepRegex {
config.Labels.CompiledKeepRegex[i], err = regex.CompileAnchored(keepRegex)
if err != nil {
return "", fmt.Errorf("keep regex rule '%s' is invalid: %w", keepRegex, err)
}
}
config.Labels.CompiledStripRegex = make([]*regexp.Regexp, len(config.Labels.StripRegex))
for i, stripRegex := range config.Labels.StripRegex {
config.Labels.CompiledStripRegex[i], err = regex.CompileAnchored(stripRegex)
if err != nil {
return "", fmt.Errorf("strip regex rule '%s' is invalid: %w", stripRegex, err)
}
}
config.Labels.CompiledValueOnlyRegex = make([]*regexp.Regexp, len(config.Labels.ValueOnlyRegex))
for i, valueOnlyRegex := range config.Labels.ValueOnlyRegex {
config.Labels.CompiledValueOnlyRegex[i], err = regex.CompileAnchored(valueOnlyRegex)
if err != nil {
return "", fmt.Errorf("valueOnly regex rule '%s' is invalid: %w", valueOnlyRegex, err)
}
}
for labelName, customColors := range config.Labels.Color.Custom {
for i, customColor := range customColors {
if customColor.Value == "" && customColor.ValueRegex == "" {
return "", fmt.Errorf("custom label color for '%s' is missing 'value' or 'value_re'", labelName)
}
if customColor.ValueRegex != "" {
config.Labels.Color.Custom[labelName][i].CompiledRegex, err = regex.CompileAnchored(customColor.ValueRegex)
if err != nil {
return "", fmt.Errorf("failed to parse custom color regex rule '%s' for '%s' label: %w", customColor.ValueRegex, labelName, err)
}
}
}
}
config.Receivers.CompiledKeepRegex = make([]*regexp.Regexp, len(config.Receivers.KeepRegex))
for i, keepRegex := range config.Receivers.KeepRegex {
config.Receivers.CompiledKeepRegex[i], err = regex.CompileAnchored(keepRegex)
if err != nil {
return "", fmt.Errorf("keep regex rule '%s' is invalid: %w", keepRegex, err)
}
}
config.Receivers.CompiledStripRegex = make([]*regexp.Regexp, len(config.Receivers.StripRegex))
for i, stripRegex := range config.Receivers.StripRegex {
config.Receivers.CompiledStripRegex[i], err = regex.CompileAnchored(stripRegex)
if err != nil {
return "", fmt.Errorf("strip regex rule '%s' is invalid: %w", stripRegex, err)
}
}
if !slices.Contains([]string{"disabled", "startsAt", "label"}, config.Grid.Sorting.Order) {
return "", fmt.Errorf("invalid grid.sorting.order value '%s', allowed options: disabled, startsAt, label", config.Grid.Sorting.Order)
}
if !slices.Contains([]string{"expanded", "collapsed", "collapsedOnMobile"}, config.UI.CollapseGroups) {
return "", fmt.Errorf("invalid ui.collapseGroups value '%s', allowed options: expanded, collapsed, collapsedOnMobile", config.UI.CollapseGroups)
}
if !slices.Contains([]string{"light", "dark", "auto"}, config.UI.Theme) {
return "", fmt.Errorf("invalid ui.theme value '%s', allowed options: light, dark, auto", config.UI.Theme)
}
if config.Listen.Prefix != "" && !strings.HasPrefix(config.Listen.Prefix, "/") {
return "", fmt.Errorf("listen.prefix must start with '/', got %q", config.Listen.Prefix)
}
if config.Listen.TLS.Cert != "" && config.Listen.TLS.Key == "" {
return "", errors.New("listen.tls.key must be set when listen.tls.cert is set")
}
if config.Listen.TLS.Key != "" && config.Listen.TLS.Cert == "" {
return "", errors.New("listen.tls.cert must be set when listen.tls.key is set")
}
if config.History.Workers < 1 {
return "", errors.New("history.workers must be >= 1")
}
for i := 0; i < len(config.History.Rewrite); i++ {
config.History.Rewrite[i].SourceRegex, err = regex.CompileAnchored(config.History.Rewrite[i].Source)
if err != nil {
return "", fmt.Errorf("history.rewrite source regex %q is invalid: %w", config.History.Rewrite[i].Source, err)
}
}
// accept single Alertmanager server from flag/env if nothing is set yet
if len(config.Alertmanager.Servers) == 0 && config.Alertmanager.URI != "" {
config.Alertmanager.Servers = []AlertmanagerConfig{
{
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),
CORS: config.Alertmanager.CORS,
TLS: config.Alertmanager.TLS,
},
}
}
Config = config
return configFileUsed, nil
}
// LogValues will dump runtime config to logs
func (config *configSchema) LogValues() {
// make a copy of our config so we can edit it
cfg := *config
auth := make([]AuthenticationUser, 0, len(cfg.Authentication.BasicAuth.Users))
for _, u := range cfg.Authentication.BasicAuth.Users {
uu := AuthenticationUser{
Username: u.Username,
Password: "***",
}
auth = append(auth, uu)
}
cfg.Authentication.BasicAuth.Users = auth
// replace passwords in Alertmanager URIs with 'xxx'
servers := make([]AlertmanagerConfig, 0, len(cfg.Alertmanager.Servers))
for _, s := range cfg.Alertmanager.Servers {
h := map[string]string{}
for key := range s.Headers {
h[key] = "***"
}
server := AlertmanagerConfig{
Cluster: s.Cluster,
Name: s.Name,
URI: uri.SanitizeURI(s.URI),
ExternalURI: uri.SanitizeURI(s.ExternalURI),
ProxyURL: s.ProxyURL,
Timeout: s.Timeout,
TLS: s.TLS,
Proxy: s.Proxy,
ReadOnly: s.ReadOnly,
Headers: h,
CORS: s.CORS,
Healthcheck: s.Healthcheck,
}
servers = append(servers, server)
}
cfg.Alertmanager.Servers = servers
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
enc.SetIndent(2)
_ = enc.Encode(cfg)
log.Info().Msg("Parsed configuration:")
scanner := bufio.NewScanner(&buf)
for scanner.Scan() {
log.Info().Msg(scanner.Text())
}
}