diff --git a/cmd/promxy/config.yaml b/cmd/promxy/config.yaml index bf002d6e6..154804489 100644 --- a/cmd/promxy/config.yaml +++ b/cmd/promxy/config.yaml @@ -29,6 +29,29 @@ remote_write: ### Promxy configuration ## promxy: + # Alert template configuration for customizable GeneratorURL + alert_templates: + # Default template used when no rules match + default: "grafana_default" + + # Named inline templates for different alert destinations + named: + # Grafana dashboard integration + grafana_default: "https://grafana.example.com/alerting/groups?alertname={{.AlertName|urlquery}}&severity={{.Labels.severity|urlquery}}" + + # Custom monitoring dashboard + monitoring_dashboard: "https://monitoring.example.com/alerts/{{.AlertName}}?instance={{.Labels.instance|urlpath}}&job={{.Labels.job|urlpath}}" + + # Template selection rules (evaluated top-to-bottom, first match wins) + rules: + - match_labels: + severity: "critical" + template: "grafana_default" + + - match_labels: + component: "infrastructure" + template: "monitoring_dashboard" + server_groups: # All upstream prometheus service discovery mechanisms are supported with the same # markup, all defined in https://github.com/prometheus/prometheus/blob/master/discovery/config/config.go#L33 diff --git a/cmd/promxy/main.go b/cmd/promxy/main.go index 2407f2fd7..4255731bc 100644 --- a/cmd/promxy/main.go +++ b/cmd/promxy/main.go @@ -46,6 +46,7 @@ import ( "k8s.io/klog" "github.com/jacksontj/promxy/pkg/alertbackfill" + "github.com/jacksontj/promxy/pkg/alerttemplate" proxyconfig "github.com/jacksontj/promxy/pkg/config" "github.com/jacksontj/promxy/pkg/logging" "github.com/jacksontj/promxy/pkg/middleware" @@ -108,6 +109,8 @@ type cliOpts struct { ForGracePeriod time.Duration `long:"rules.alert.for-grace-period" description:"Minimum duration between alert and restored for state. This is maintained only for alerts with configured for time greater than grace period." default:"10m"` ResendDelay time.Duration `long:"rules.alert.resend-delay" description:"Minimum amount of time to wait before resending an alert to Alertmanager." default:"1m"` AlertBackfill bool `long:"rules.alertbackfill" description:"Enable promxy to recalculate alert state on startup when the downstream datastore doesn't have an ALERTS_FOR_STATE"` + GeneratorURLTemplate string `long:"rules.alert.generator-url-template" description:"Go template for alert GeneratorURL. Overrides config file template"` + TemplateDirectory string `long:"rules.alert.template-dir" description:"Directory containing GeneratorURL template files (.tmpl extension)"` ShutdownDelay time.Duration `long:"http.shutdown-delay" description:"time to wait before shutting down the http server, this allows for a grace period for upstreams (e.g. LoadBalancers) to discover the new stopping status through healthchecks" default:"10s"` ShutdownTimeout time.Duration `long:"http.shutdown-timeout" description:"max time to wait for a graceful shutdown of the HTTP server" default:"60s"` @@ -318,11 +321,19 @@ func main() { } else { ruleQueryable = proxyStorage } + + // Create alert configuration + alertCfg := &alertConfig{ + templateManager: alerttemplate.NewTemplateManager(), + cliTemplate: opts.GeneratorURLTemplate, + cliTemplateDir: opts.TemplateDirectory, + } + ruleManager := rules.NewManager(&rules.ManagerOptions{ Context: ctx, // base context for all background tasks ExternalURL: externalUrl, // URL listed as URL for "who fired this alert" QueryFunc: rules.EngineQueryFunc(engine, proxyStorage), - NotifyFunc: sendAlerts(notifierManager, externalUrl.String()), + NotifyFunc: sendAlerts(notifierManager, externalUrl.String(), alertCfg), Appendable: proxyStorage, Queryable: ruleQueryable, Logger: logger, @@ -338,6 +349,11 @@ func main() { go ruleManager.Run() + // Add promxy-specific alert configuration reloadable + reloadables = append(reloadables, &alertConfigReloadable{ + alertCfg: alertCfg, + }) + reloadables = append(reloadables, proxyconfig.WrapPromReloadable(&proxyconfig.ApplyConfigFunc{func(cfg *config.Config) error { // Get all rule files matching the configuration oaths. var files []string @@ -538,10 +554,110 @@ func main() { } } +// alertConfig holds the configuration for alert processing +type alertConfig struct { + templateManager *alerttemplate.TemplateManager + cliTemplate string + cliTemplateDir string + currentTemplate string // Current effective template after config reload + templateRules []alerttemplate.TemplateRule // Template selection rules + defaultTemplate string +} + +// getEffectiveTemplate returns the effective template considering CLI overrides +func (ac *alertConfig) getEffectiveTemplate(configTemplate string) string { + if ac.cliTemplate != "" { + return ac.cliTemplate + } + return configTemplate +} + +// getEffectiveTemplateDir returns the effective template directory considering CLI overrides +func (ac *alertConfig) getEffectiveTemplateDir(configDir string) string { + if ac.cliTemplateDir != "" { + return ac.cliTemplateDir + } + return configDir +} + +// alertConfigReloadable implements the Reloadable interface for alert configuration +type alertConfigReloadable struct { + alertCfg *alertConfig +} + +// ApplyConfig applies the new configuration to the alert config +func (acr *alertConfigReloadable) ApplyConfig(cfg *proxyconfig.Config) error { + alertTemplates := cfg.PromxyConfig.AlertTemplates + + // Update current effective template + acr.alertCfg.currentTemplate = acr.alertCfg.getEffectiveTemplate(alertTemplates.Default) + + // Update template rules and default template + acr.alertCfg.templateRules = alertTemplates.Rules + acr.alertCfg.defaultTemplate = acr.alertCfg.getEffectiveTemplate(alertTemplates.Default) + + // Load templates from directory with error resilience + templateDir := acr.alertCfg.getEffectiveTemplateDir(alertTemplates.Directory) + if templateDir != "" { + if err := acr.alertCfg.templateManager.LoadFromDirectory(templateDir); err != nil { + logrus.Warnf("Failed to load templates from directory %s: %v", templateDir, err) + // Continue with existing templates - don't fail the entire config reload + } + } + + // Load inline templates with error resilience + if len(alertTemplates.Named) > 0 { + if err := acr.alertCfg.templateManager.LoadInlineTemplates(alertTemplates.Named); err != nil { + logrus.Warnf("Failed to load inline templates: %v", err) + // Continue with existing templates - don't fail the entire config reload + } + } + + return nil +} + +// generateAlertURL generates the appropriate URL for an alert with fallback handling +func generateAlertURL(alertCfg *alertConfig, alert *rules.Alert, expr, externalURL string) string { + var effectiveTemplate string + + // If CLI template is set, it overrides everything + if alertCfg.cliTemplate != "" { + effectiveTemplate = alertCfg.cliTemplate + } else { + // Use rule-based template selection + effectiveTemplate = alerttemplate.SelectTemplate( + alertCfg.templateRules, + alertCfg.defaultTemplate, + alertCfg.templateManager, + alert, + ) + } + + // If no template configured, use default Prometheus URL + if effectiveTemplate == "" { + return externalURL + strutil.TableLinkForExpression(expr) + } + + templateURL, err := alerttemplate.ExecuteGeneratorURLTemplate(effectiveTemplate, alert, expr, externalURL) + if err != nil { + logrus.Warnf("Failed to execute GeneratorURL template for alert %s: %v, falling back to default URL", + alert.Labels.Get("alertname"), err) + return externalURL + strutil.TableLinkForExpression(expr) + } + + return templateURL +} + + + // sendAlerts implements the rules.NotifyFunc for a Notifier. // It filters any non-firing alerts from the input. -func sendAlerts(n *notifier.Manager, externalURL string) rules.NotifyFunc { +func sendAlerts(n *notifier.Manager, externalURL string, alertCfg *alertConfig) rules.NotifyFunc { return func(ctx context.Context, expr string, alerts ...*rules.Alert) { + if len(alerts) == 0 { + return + } + var res []*notifier.Alert for _, alert := range alerts { @@ -549,11 +665,15 @@ func sendAlerts(n *notifier.Manager, externalURL string) rules.NotifyFunc { if alert.State == rules.StatePending { continue } + + // Generate the URL with proper error handling and fallback + generatorURL := generateAlertURL(alertCfg, alert, expr, externalURL) + a := ¬ifier.Alert{ StartsAt: alert.FiredAt, Labels: alert.Labels, Annotations: alert.Annotations, - GeneratorURL: externalURL + strutil.TableLinkForExpression(expr), + GeneratorURL: generatorURL, } if !alert.ResolvedAt.IsZero() { a.EndsAt = alert.ResolvedAt @@ -561,7 +681,7 @@ func sendAlerts(n *notifier.Manager, externalURL string) rules.NotifyFunc { res = append(res, a) } - if len(alerts) > 0 { + if len(res) > 0 { n.Send(res...) } } diff --git a/docs/configurable-generator-url.md b/docs/configurable-generator-url.md new file mode 100644 index 000000000..c8d306244 --- /dev/null +++ b/docs/configurable-generator-url.md @@ -0,0 +1,195 @@ +# Configurable Generator URL Templates + +## Overview + +By default, Promxy generates alert URLs that link back to the Prometheus expression browser. With configurable generator URL templates, you can customize these URLs to point to external systems like Grafana dashboards, custom monitoring tools, or any other relevant destination. + +## Configuration + +### Basic Configuration + +Add alert template configuration to your Promxy configuration file: + +```yaml +promxy: + alert_templates: + default: "https://grafana.example.com/alerting/groups?alertname={{.AlertName|urlquery}}" +``` + +### Advanced Configuration Examples + +```yaml +promxy: + alert_templates: + # Default template used when no rules match + default: "{{.ExternalURL}}/graph?g0.expr={{.Expr|urlquery}}&g0.tab=1" + + # Directory containing template files (*.tmpl) + directory: "/etc/promxy/templates" + + # Named inline templates + named: + grafana: "https://grafana.example.com/d/alerts?alertname={{.AlertName|urlquery}}&severity={{.Labels.severity|urlquery}}" + pagerduty: "https://pagerduty.example.com/incidents/new?title={{.AlertName|urlquery}}&description={{.Annotations.summary|urlquery}}" + custom_dashboard: "https://monitoring.example.com/alerts/{{.AlertName}}?instance={{.Labels.instance|urlpath}}" + + # Template selection rules (evaluated top-to-bottom) + rules: + # Critical alerts go to PagerDuty + - match_labels: + severity: "critical" + template: "pagerduty" + + # Frontend team alerts go to Grafana + - match_labels: + team: "frontend" + template: "grafana" + + # Infrastructure alerts use custom dashboard + - match_labels: + component: "infrastructure" + template: "custom_dashboard" + + # Inline template for specific alert + - match_labels: + alertname: "DatabaseDown" + template: "https://db-dashboard.example.com/status?db={{.Labels.database|urlquery}}" +``` + +#### Template Directory Structure + +When using `directory: "/etc/promxy/templates"`, organize templates as: + +``` +/etc/promxy/templates/ +├── grafana.tmpl +├── pagerduty.tmpl +├── custom.tmpl +└── teams/ + ├── frontend.tmpl + └── backend.tmpl +``` + +Template files are referenced by their relative path without the `.tmpl` extension: +- `grafana.tmpl` → referenced as `"grafana"` +- `teams/frontend.tmpl` → referenced as `"teams.frontend"` + +#### Environment-Specific Configuration + +**Development Environment:** +```yaml +promxy: + alert_templates: + default: "http://localhost:3000/alerts?alertname={{.AlertName|urlquery}}" +``` + +**Production Environment:** +```yaml +promxy: + alert_templates: + default: "https://grafana.prod.example.com/alerting/groups" + named: + oncall: "https://pagerduty.example.com/incidents/new?title={{.AlertName|urlquery}}" + rules: + - match_labels: + severity: "critical" + template: "oncall" +``` + +### CLI Override + +You can override the configuration file template using CLI flags: + +```bash +# Override default template +promxy --rules.alert.generator-url-template="https://custom.example.com/alert/{{.AlertName}}" + +# Override template directory +promxy --rules.alert.template-dir="/custom/templates" + +# Both overrides +promxy \ + --rules.alert.generator-url-template="https://emergency.example.com/alert/{{.AlertName}}" \ + --rules.alert.template-dir="/emergency/templates" +``` + +CLI flags take precedence over configuration file settings. + +## Template Syntax + +Templates use Go's `text/template` syntax with alert data and URL encoding functions. + +### Template Data Structure + +```go +type TemplateData struct { + ExternalURL string // Base URL: "http://prometheus.example.com:9090" + Expr string // PromQL expression: "up{job=\"web\"} < 1" + Labels map[string]string // Alert labels: {"alertname": "HighCPU", "severity": "critical"} + Annotations map[string]string // Alert annotations: {"summary": "High CPU detected"} + AlertName string // Shortcut for .Labels.alertname +} +``` + +### Template Functions + +| Function | Purpose | Example Usage | Result | +|----------|---------|---------------|--------| +| `urlquery` | Encode for query parameters | `{{.AlertName\|urlquery}}` | `High+CPU` | +| `urlpath` | Encode for URL paths | `{{.Labels.instance\|urlpath}}` | `server%3A8080` | + + +## Behavior + +### Template Execution +- Templates are executed for each firing alert (pending alerts are not sent to Alertmanager) +- Template execution happens during the alert sending process +- All alert labels and annotations are available to the template + +### Error Handling +- If template parsing fails, an error is logged and the default URL is used +- If template execution fails, an error is logged and the default URL is used +- The system continues to function normally even with template errors + +### Fallback Behavior +When no template is configured or template execution fails, Promxy falls back to the default Prometheus-style URL: +``` +http://prometheus.example.com:9090/graph?g0.expr=up%7Bjob%3D%22web%22%7D&g0.tab=1 +``` + +### Configuration Reloading +- Template changes are applied when the configuration is reloaded (SIGHUP) +- CLI overrides remain in effect after configuration reloads +- No restart is required for template changes + +## Configuration Validation + +Promxy validates alert template configuration at startup and during configuration reloads. The following validation rules apply: + +### Template Directory Validation + +- Directory must exist and be readable +- Directory must contain at least one `.tmpl` file (warning if empty) +- All `.tmpl` files must be readable and contain valid template syntax +- Template files are validated for Go template syntax + +### Template Name Validation + +- Template names cannot be empty +- Template names cannot contain path separators (`/` or `\`) +- Template names cannot start with a dot (`.`) +- Template names must be unique within inline templates + +### Template Content Validation + +- Template content cannot be empty +- Templates must parse successfully using Go's `text/template` package + +### Template Rule Validation + +- Each rule must have at least one `match_labels` entry +- Label keys and values in `match_labels` cannot be empty +- Template references in rules must either: + - Reference a valid named template, or + - Contain valid inline template content +- Rules are evaluated in the order they appear in configuration diff --git a/pkg/alerttemplate/template.go b/pkg/alerttemplate/template.go new file mode 100644 index 000000000..83320243d --- /dev/null +++ b/pkg/alerttemplate/template.go @@ -0,0 +1,388 @@ +package alerttemplate + +import ( + "bytes" + "fmt" + "io/fs" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "text/template" + + "github.com/prometheus/prometheus/rules" + "github.com/sirupsen/logrus" +) + +// TemplateData represents the data available to GeneratorURL templates +type TemplateData struct { + ExternalURL string `json:"external_url"` // Base URL of the Prometheus instance (e.g., "http://prometheus.example.com:9090") + Expr string `json:"expr"` // PromQL expression that triggered the alert + Labels map[string]string `json:"labels"` // All alert labels + Annotations map[string]string `json:"annotations"` // All alert annotations + AlertName string `json:"alert_name"` // Value of the "alertname" label +} + +// templateFuncs provides template functions for URL encoding +var templateFuncs = template.FuncMap{ + "urlquery": url.QueryEscape, + "urlpath": url.PathEscape, +} + +// TemplateManager manages template loading and caching from directories and inline templates +type TemplateManager struct { + mu sync.RWMutex + templates map[string]string // template name -> template content (merged from inline and directory) + inlineTemplates map[string]string // inline templates from configuration + directory string // template directory path +} + +// NewTemplateManager creates a new template manager +func NewTemplateManager() *TemplateManager { + return &TemplateManager{ + templates: make(map[string]string), + inlineTemplates: make(map[string]string), + } +} + +// LoadInlineTemplates loads inline templates from configuration +func (tm *TemplateManager) LoadInlineTemplates(inlineTemplates map[string]string) error { + tm.mu.Lock() + defer tm.mu.Unlock() + + // Clear existing inline templates + tm.inlineTemplates = make(map[string]string) + + // Validate and load inline templates + for name, content := range inlineTemplates { + if err := tm.validateTemplateName(name); err != nil { + logrus.Errorf("Invalid inline template name '%s': %v", name, err) + continue + } + + if err := tm.validateInlineTemplateContent(name, content); err != nil { + logrus.Errorf("Invalid inline template content for '%s': %v", name, err) + continue + } + + tm.inlineTemplates[name] = content + logrus.Infof("Loaded inline template '%s'", name) + } + + // Rebuild merged templates + tm.rebuildTemplates() + + logrus.Infof("Loaded %d inline templates", len(tm.inlineTemplates)) + return nil +} + +// LoadFromDirectory loads templates from the specified directory +func (tm *TemplateManager) LoadFromDirectory(directory string) error { + tm.mu.Lock() + defer tm.mu.Unlock() + + tm.directory = directory + + // If no directory specified, just rebuild with inline templates + if directory == "" { + tm.rebuildTemplates() + return nil + } + + // Check if directory exists + if _, err := os.Stat(directory); os.IsNotExist(err) { + logrus.Warnf("Template directory does not exist: %s", directory) + tm.rebuildTemplates() + return nil + } + + // Load directory templates + directoryTemplates := make(map[string]string) + + // Walk through the directory and load template files + err := filepath.WalkDir(directory, func(path string, d fs.DirEntry, err error) error { + if err != nil { + logrus.Errorf("Error accessing path %s: %v", path, err) + return nil // Continue processing other files + } + + // Skip directories + if d.IsDir() { + return nil + } + + // Only process .tmpl files + if !strings.HasSuffix(path, ".tmpl") { + return nil + } + + // Read template file + content, err := os.ReadFile(path) + if err != nil { + logrus.Errorf("Failed to read template file %s: %v", path, err) + return nil // Continue processing other files + } + + // Generate template name from file path (relative to directory, without .tmpl extension) + relPath, err := filepath.Rel(directory, path) + if err != nil { + logrus.Errorf("Failed to get relative path for %s: %v", path, err) + return nil + } + + templateName := strings.TrimSuffix(relPath, ".tmpl") + // Replace path separators with dots for template names + templateName = strings.ReplaceAll(templateName, string(filepath.Separator), ".") + + // Validate template content + if err := tm.validateTemplateContent(templateName, string(content)); err != nil { + logrus.Errorf("Invalid template in file %s: %v", path, err) + return nil // Continue processing other files + } + + directoryTemplates[templateName] = string(content) + logrus.Infof("Loaded template '%s' from %s", templateName, path) + + return nil + }) + + if err != nil { + return fmt.Errorf("failed to walk template directory %s: %w", directory, err) + } + + // Rebuild merged templates with directory templates taking precedence + tm.rebuildTemplatesWithDirectory(directoryTemplates) + + logrus.Infof("Loaded %d templates from directory %s", len(directoryTemplates), directory) + return nil +} + +// rebuildTemplates rebuilds the merged template map from inline templates only +func (tm *TemplateManager) rebuildTemplates() { + tm.templates = make(map[string]string) + + // Start with inline templates + for name, content := range tm.inlineTemplates { + tm.templates[name] = content + } +} + +// rebuildTemplatesWithDirectory rebuilds the merged template map with directory templates taking precedence +func (tm *TemplateManager) rebuildTemplatesWithDirectory(directoryTemplates map[string]string) { + tm.templates = make(map[string]string) + + // Start with inline templates + for name, content := range tm.inlineTemplates { + tm.templates[name] = content + } + + // Directory templates override inline templates + for name, content := range directoryTemplates { + if _, exists := tm.inlineTemplates[name]; exists { + logrus.Infof("Directory template '%s' overrides inline template", name) + } + tm.templates[name] = content + } +} + +// validateTemplateName validates a template name +func (tm *TemplateManager) validateTemplateName(name string) error { + if name == "" { + return fmt.Errorf("template name cannot be empty") + } + + if strings.ContainsAny(name, "/\\") { + return fmt.Errorf("template name cannot contain path separators") + } + + if strings.HasPrefix(name, ".") { + return fmt.Errorf("template name cannot start with a dot") + } + + return nil +} + +// validateTemplateContent validates template content by attempting to parse it +func (tm *TemplateManager) validateTemplateContent(name, content string) error { + _, err := template.New(name).Funcs(templateFuncs).Parse(content) + if err != nil { + return fmt.Errorf("template parsing failed: %w", err) + } + + return nil +} + +// validateInlineTemplateContent validates inline template content (stricter validation) +func (tm *TemplateManager) validateInlineTemplateContent(name, content string) error { + if content == "" { + return fmt.Errorf("template content cannot be empty") + } + + return tm.validateTemplateContent(name, content) +} + +// GetTemplate returns the template content by name +func (tm *TemplateManager) GetTemplate(name string) (string, bool) { + tm.mu.RLock() + defer tm.mu.RUnlock() + + template, exists := tm.templates[name] + return template, exists +} + +// ListTemplates returns all available template names +func (tm *TemplateManager) ListTemplates() []string { + tm.mu.RLock() + defer tm.mu.RUnlock() + + names := make([]string, 0, len(tm.templates)) + for name := range tm.templates { + names = append(names, name) + } + return names +} + +// GetDirectory returns the current template directory +func (tm *TemplateManager) GetDirectory() string { + tm.mu.RLock() + defer tm.mu.RUnlock() + return tm.directory +} + +// GetInlineTemplates returns a copy of the inline templates map +func (tm *TemplateManager) GetInlineTemplates() map[string]string { + tm.mu.RLock() + defer tm.mu.RUnlock() + + result := make(map[string]string) + for name, content := range tm.inlineTemplates { + result[name] = content + } + return result +} + +// HasInlineTemplate checks if an inline template exists +func (tm *TemplateManager) HasInlineTemplate(name string) bool { + tm.mu.RLock() + defer tm.mu.RUnlock() + + _, exists := tm.inlineTemplates[name] + return exists +} + +// ExecuteGeneratorURLTemplate executes a Go template for generating alert URLs +func ExecuteGeneratorURLTemplate(templateStr string, alert *rules.Alert, expr, externalURL string) (string, error) { + if templateStr == "" { + // Return empty string to indicate no template was provided + // Caller should handle fallback logic + return "", nil + } + + if alert == nil { + return "", fmt.Errorf("alert cannot be nil") + } + + tmpl, err := template.New("generator").Funcs(templateFuncs).Parse(templateStr) + if err != nil { + return "", fmt.Errorf("failed to parse template: %w", err) + } + + data := TemplateData{ + ExternalURL: externalURL, + Expr: expr, + Labels: alert.Labels.Map(), + Annotations: alert.Annotations.Map(), + AlertName: alert.Labels.Get("alertname"), + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("failed to execute template: %w", err) + } + + return buf.String(), nil +} + +// ExecuteTemplateByName executes a template by name from the template manager +func ExecuteTemplateByName(tm *TemplateManager, templateName string, alert *rules.Alert, expr, externalURL string) (string, error) { + if tm == nil { + return "", fmt.Errorf("template manager cannot be nil") + } + + templateStr, exists := tm.GetTemplate(templateName) + if !exists { + return "", fmt.Errorf("template '%s' not found", templateName) + } + + return ExecuteGeneratorURLTemplate(templateStr, alert, expr, externalURL) +} + +// TemplateRule defines conditions for selecting specific templates +type TemplateRule struct { + // Label selectors to match alerts + MatchLabels map[string]string `yaml:"match_labels"` + + // Template to use for matching alerts (can be template content or template name) + Template string `yaml:"template"` +} + +// SelectTemplate selects the appropriate template for an alert based on rules +// Returns the template content to use, or empty string if no template should be used +func SelectTemplate(rules []TemplateRule, defaultTemplate string, tm *TemplateManager, alert *rules.Alert) string { + if alert == nil { + return defaultTemplate + } + + alertLabels := alert.Labels.Map() + + // Evaluate rules in order (top-to-bottom matching) + for _, rule := range rules { + if matchesRule(rule, alertLabels) { + // Check if the template is a named template or inline content + if tm != nil { + if namedTemplate, exists := tm.GetTemplate(rule.Template); exists { + return namedTemplate + } + } + + // Treat as inline template content + return rule.Template + } + } + + // No rules matched, use default template + if defaultTemplate != "" { + // Check if default template is a named template + if tm != nil { + if namedTemplate, exists := tm.GetTemplate(defaultTemplate); exists { + return namedTemplate + } + } + + // Treat as inline template content + return defaultTemplate + } + + return "" +} + +// matchesRule checks if an alert's labels match a template rule's match criteria +func matchesRule(rule TemplateRule, alertLabels map[string]string) bool { + if len(rule.MatchLabels) == 0 { + return false + } + + // All match labels must be present and have matching values + for key, expectedValue := range rule.MatchLabels { + actualValue, exists := alertLabels[key] + if !exists || actualValue != expectedValue { + return false + } + } + + return true +} + + + diff --git a/pkg/alerttemplate/template_test.go b/pkg/alerttemplate/template_test.go new file mode 100644 index 000000000..16c21d53f --- /dev/null +++ b/pkg/alerttemplate/template_test.go @@ -0,0 +1,768 @@ +package alerttemplate + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/prometheus/prometheus/model/labels" + "github.com/prometheus/prometheus/notifier" + "github.com/prometheus/prometheus/rules" + "github.com/prometheus/prometheus/util/strutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExecuteGeneratorURLTemplate(t *testing.T) { + alert := &rules.Alert{ + Labels: labels.FromMap(map[string]string{ + "alertname": "HighCPU", + "severity": "critical", + "instance": "server1.example.com:9100", + "job": "node-exporter", + }), + Annotations: labels.FromMap(map[string]string{ + "summary": "High CPU usage detected", + }), + } + + tests := []struct { + name string + template string + expr string + externalURL string + expected string + expectError bool + }{ + { + name: "empty template returns empty string", + template: "", + expr: "up", + externalURL: "http://localhost:9090", + expected: "", + expectError: false, + }, + { + name: "simple template with alert name", + template: "https://grafana.example.com/alerting/groups?alertname={{.AlertName}}", + expr: "up", + externalURL: "http://localhost:9090", + expected: "https://grafana.example.com/alerting/groups?alertname=HighCPU", + expectError: false, + }, + { + name: "template with URL encoding", + template: "https://grafana.example.com/alerting/groups?alertname={{.AlertName|urlquery}}", + expr: "up", + externalURL: "http://localhost:9090", + expected: "https://grafana.example.com/alerting/groups?alertname=HighCPU", + expectError: false, + }, + { + name: "template with labels", + template: "https://grafana.example.com/alerting/groups?severity={{.Labels.severity}}", + expr: "up", + externalURL: "http://localhost:9090", + expected: "https://grafana.example.com/alerting/groups?severity=critical", + expectError: false, + }, + { + name: "template with annotations", + template: "https://grafana.example.com/alerting/groups?summary={{.Annotations.summary|urlquery}}", + expr: "up", + externalURL: "http://localhost:9090", + expected: "https://grafana.example.com/alerting/groups?summary=High+CPU+usage+detected", + expectError: false, + }, + { + name: "link back to Prometheus graph", + template: "{{.ExternalURL}}/graph?g0.expr={{.Expr|urlquery}}&g0.tab=1", + expr: "up{job=\"node-exporter\"}", + externalURL: "http://prometheus.example.com:9090", + expected: "http://prometheus.example.com:9090/graph?g0.expr=up%7Bjob%3D%22node-exporter%22%7D&g0.tab=1", + expectError: false, + }, + { + name: "link to Prometheus alerts page", + template: "{{.ExternalURL}}/alerts?search={{.AlertName|urlquery}}", + expr: "up", + externalURL: "http://prometheus.example.com:9090", + expected: "http://prometheus.example.com:9090/alerts?search=HighCPU", + expectError: false, + }, + { + name: "link to external Grafana with Prometheus datasource", + template: "https://grafana.example.com/explore?left=%7B%22datasource%22:%22{{.ExternalURL|urlquery}}%22,%22queries%22:%5B%7B%22expr%22:%22{{.Expr|urlquery}}%22%7D%5D%7D", + expr: "up{job=\"prometheus\"}", + externalURL: "http://prometheus.example.com:9090", + expected: "https://grafana.example.com/explore?left=%7B%22datasource%22:%22http%3A%2F%2Fprometheus.example.com%3A9090%22,%22queries%22:%5B%7B%22expr%22:%22up%7Bjob%3D%22prometheus%22%7D%22%7D%5D%7D", + expectError: false, + }, + + { + name: "Grafana dashboard with variables", + template: "https://grafana.example.com/d/node-exporter/node-exporter?var-instance={{.Labels.instance}}&var-job={{.Labels.job}}", + expr: "up", + externalURL: "http://prometheus.example.com:9090", + expected: "https://grafana.example.com/d/node-exporter/node-exporter?var-instance=server1.example.com:9100&var-job=node-exporter", + expectError: false, + }, + { + name: "Alertmanager silence with URL encoding", + template: "https://alertmanager.example.com/#/silences/new?filter=%7Balertname%3D%22{{.AlertName}}%22%2Cinstance%3D%22{{.Labels.instance|urlquery}}%22%7D", + expr: "up", + externalURL: "http://prometheus.example.com:9090", + expected: "https://alertmanager.example.com/#/silences/new?filter=%7Balertname%3D%22HighCPU%22%2Cinstance%3D%22server1.example.com%3A9100%22%7D", + expectError: false, + }, + { + name: "malformed template should error", + template: "{{.InvalidField", + expr: "up", + externalURL: "http://localhost:9090", + expected: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ExecuteGeneratorURLTemplate(tt.template, alert, tt.expr, tt.externalURL) + + if tt.expectError { + if err == nil { + t.Errorf("expected error but got none") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestTemplateFunctions(t *testing.T) { + alert := &rules.Alert{ + Labels: labels.FromMap(map[string]string{ + "alertname": "High CPU Usage", + "instance": "server1.example.com:9100", + "service": "web/api", + }), + } + + tests := []struct { + name string + template string + expected string + }{ + { + name: "urlquery function", + template: "{{.AlertName|urlquery}}", + expected: "High+CPU+Usage", + }, + { + name: "urlpath function", + template: "{{.Labels.instance|urlpath}}", + expected: "server1.example.com:9100", + }, + { + name: "urlquery with special characters", + template: "{{.Labels.service|urlquery}}", + expected: "web%2Fapi", + }, + { + name: "urlpath with special characters", + template: "{{.Labels.service|urlpath}}", + expected: "web%2Fapi", + }, + { + name: "multiple template functions", + template: "https://example.com/{{.AlertName|urlpath}}?instance={{.Labels.instance|urlquery}}", + expected: "https://example.com/High%20CPU%20Usage?instance=server1.example.com%3A9100", + }, + { + name: "nested template functions", + template: "{{.Labels.service|urlquery|urlpath}}", + expected: "web%252Fapi", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ExecuteGeneratorURLTemplate(tt.template, alert, "up", "http://localhost:9090") + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestTemplateErrorHandling(t *testing.T) { + alert := &rules.Alert{ + Labels: labels.FromMap(map[string]string{ + "alertname": "TestAlert", + }), + } + + tests := []struct { + name string + template string + expectError bool + errorMsg string + }{ + { + name: "malformed template - unclosed action", + template: "{{.AlertName", + expectError: true, + errorMsg: "failed to parse template", + }, + { + name: "malformed template - invalid syntax", + template: "{{.AlertName}}{{", + expectError: true, + errorMsg: "failed to parse template", + }, + { + name: "template with invalid field access", + template: "{{.NonExistentField}}", + expectError: true, + errorMsg: "failed to execute template", + }, + { + name: "template with invalid function", + template: "{{.AlertName|nonexistentfunc}}", + expectError: true, + errorMsg: "failed to parse template", + }, + { + name: "template with nil map access", + template: "{{.Labels.nonexistent}}", + expectError: false, // This should not error, just return + }, + { + name: "complex template with partial errors", + template: "{{.AlertName}}-{{.Labels.nonexistent}}-{{.Labels.alertname}}", + expectError: false, + errorMsg: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ExecuteGeneratorURLTemplate(tt.template, alert, "up", "http://localhost:9090") + + if tt.expectError { + if err == nil { + t.Errorf("expected error but got none") + return + } + if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("expected error to contain %q, got %q", tt.errorMsg, err.Error()) + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + // For non-error cases, just verify we got some result + if tt.template == "{{.Labels.nonexistent}}" && result != "" { + t.Errorf("expected '' for nonexistent label, got %q", result) + } + }) + } +} + +func TestTemplateDataStructure(t *testing.T) { + alert := &rules.Alert{ + Labels: labels.FromMap(map[string]string{ + "alertname": "TestAlert", + "severity": "critical", + "instance": "server1:9100", + }), + Annotations: labels.FromMap(map[string]string{ + "summary": "Test summary", + "description": "Test description", + }), + } + + tests := []struct { + name string + template string + expected string + }{ + { + name: "access ExternalURL", + template: "{{.ExternalURL}}", + expected: "http://prometheus.example.com:9090", + }, + { + name: "access Expr", + template: "{{.Expr}}", + expected: "up{job=\"test\"}", + }, + { + name: "access AlertName", + template: "{{.AlertName}}", + expected: "TestAlert", + }, + { + name: "access Labels map", + template: "{{.Labels.severity}}", + expected: "critical", + }, + { + name: "access Annotations map", + template: "{{.Annotations.summary}}", + expected: "Test summary", + }, + { + name: "access all fields", + template: "{{.ExternalURL}}/graph?g0.expr={{.Expr|urlquery}}&alertname={{.AlertName}}&severity={{.Labels.severity}}", + expected: "http://prometheus.example.com:9090/graph?g0.expr=up%7Bjob%3D%22test%22%7D&alertname=TestAlert&severity=critical", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ExecuteGeneratorURLTemplate(tt.template, alert, "up{job=\"test\"}", "http://prometheus.example.com:9090") + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestTemplateWithEmptyData(t *testing.T) { + // Test with alert that has minimal data + alert := &rules.Alert{ + Labels: labels.FromMap(map[string]string{}), + Annotations: labels.FromMap(map[string]string{}), + } + + tests := []struct { + name string + template string + expected string + }{ + { + name: "empty AlertName", + template: "{{.AlertName}}", + expected: "", + }, + { + name: "empty Labels access", + template: "{{.Labels.severity}}", + expected: "", + }, + { + name: "empty Annotations access", + template: "{{.Annotations.summary}}", + expected: "", + }, + { + name: "template with default values", + template: "{{if .AlertName}}{{.AlertName}}{{else}}unknown{{end}}", + expected: "unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ExecuteGeneratorURLTemplate(tt.template, alert, "up", "http://localhost:9090") + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if result != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestTemplateWithNilAlert(t *testing.T) { + _, err := ExecuteGeneratorURLTemplate("{{.AlertName}}", nil, "up", "http://localhost:9090") + if err == nil { + t.Error("expected error when passing nil alert") + } + + expectedMsg := "alert cannot be nil" + if !strings.Contains(err.Error(), expectedMsg) { + t.Errorf("expected error to contain %q, got %q", expectedMsg, err.Error()) + } +} + +// TestTemplateManager tests template loading and management +func TestTemplateManager(t *testing.T) { + t.Run("directory loading", func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "template_test_*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create test files including nested directories and non-template files + testFiles := map[string]string{ + "grafana.tmpl": "https://grafana.example.com/alert/{{.AlertName}}", + "subdir/custom.tmpl": "https://custom.example.com/{{.Labels.severity}}", + "invalid.tmpl": "https://example.com/{{.InvalidSyntax", // Invalid template + "readme.txt": "This is not a template", // Non-template file + } + + for filePath, content := range testFiles { + fullPath := filepath.Join(tempDir, filePath) + dir := filepath.Dir(fullPath) + if dir != tempDir { + err := os.MkdirAll(dir, 0755) + require.NoError(t, err) + } + err := os.WriteFile(fullPath, []byte(content), 0644) + require.NoError(t, err) + } + + tm := NewTemplateManager() + err = tm.LoadFromDirectory(tempDir) + require.NoError(t, err) + + // Should load 2 valid templates (grafana and subdir.custom) + names := tm.ListTemplates() + assert.Len(t, names, 2) + assert.Contains(t, names, "grafana") + assert.Contains(t, names, "subdir.custom") + + // Verify template content + content, exists := tm.GetTemplate("grafana") + assert.True(t, exists) + assert.Equal(t, "https://grafana.example.com/alert/{{.AlertName}}", content) + }) + + t.Run("inline template validation", func(t *testing.T) { + tm := NewTemplateManager() + + // Test valid templates + validTemplates := map[string]string{ + "valid1": "https://example.com/{{.AlertName}}", + "valid2": "https://example.com/{{.Labels.severity}}", + } + err := tm.LoadInlineTemplates(validTemplates) + assert.NoError(t, err) + assert.Len(t, tm.GetInlineTemplates(), 2) + + // Test invalid templates (should be skipped) + invalidTemplates := map[string]string{ + "": "https://example.com/empty-name", // Empty name + "path/name": "https://example.com/{{.AlertName}}", // Path separator + ".hidden": "https://example.com/{{.AlertName}}", // Starts with dot + "empty": "", // Empty content + "malformed": "https://example.com/{{.AlertName", // Malformed template + } + err = tm.LoadInlineTemplates(invalidTemplates) + assert.NoError(t, err) + assert.Len(t, tm.GetInlineTemplates(), 0) // All should be rejected + }) + + t.Run("directory override behavior", func(t *testing.T) { + tempDir, err := os.MkdirTemp("", "template_override_test_*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Create directory template + err = os.WriteFile(filepath.Join(tempDir, "shared.tmpl"), + []byte("https://directory.example.com/{{.AlertName}}"), 0644) + require.NoError(t, err) + + tm := NewTemplateManager() + + // Load inline template first + inlineTemplates := map[string]string{ + "shared": "https://inline.example.com/{{.AlertName}}", + "inline_only": "https://inline-only.example.com/{{.AlertName}}", + } + err = tm.LoadInlineTemplates(inlineTemplates) + require.NoError(t, err) + + // Load directory templates (should override inline) + err = tm.LoadFromDirectory(tempDir) + require.NoError(t, err) + + // Verify directory template overrides inline template + content, exists := tm.GetTemplate("shared") + assert.True(t, exists) + assert.Equal(t, "https://directory.example.com/{{.AlertName}}", content) + + // Verify inline-only template still exists + content, exists = tm.GetTemplate("inline_only") + assert.True(t, exists) + assert.Equal(t, "https://inline-only.example.com/{{.AlertName}}", content) + }) + + t.Run("edge cases", func(t *testing.T) { + tm := NewTemplateManager() + + // Empty path should not error + err := tm.LoadFromDirectory("") + assert.NoError(t, err) + assert.Len(t, tm.ListTemplates(), 0) + + // Non-existent directory should not error + err = tm.LoadFromDirectory("/non/existent/directory") + assert.NoError(t, err) + assert.Len(t, tm.ListTemplates(), 0) + }) +} + +// TestTemplateSelection tests rule-based template selection +func TestTemplateSelection(t *testing.T) { + // Create a template manager with named templates + tm := NewTemplateManager() + namedTemplates := map[string]string{ + "grafana": "https://grafana.example.com/alerting/groups?alertname={{.AlertName|urlquery}}", + "pagerduty": "https://pagerduty.example.com/incidents/{{.Labels.incident_id}}", + } + err := tm.LoadInlineTemplates(namedTemplates) + require.NoError(t, err) + + tests := []struct { + name string + rules []TemplateRule + defaultTemplate string + alertLabels map[string]string + expected string + }{ + { + name: "no rules, use default named template", + rules: []TemplateRule{}, + defaultTemplate: "grafana", + alertLabels: map[string]string{"alertname": "TestAlert"}, + expected: "https://grafana.example.com/alerting/groups?alertname={{.AlertName|urlquery}}", + }, + { + name: "single matching rule with inline template", + rules: []TemplateRule{ + { + MatchLabels: map[string]string{"severity": "critical"}, + Template: "https://critical.example.com/alert/{{.AlertName}}", + }, + }, + defaultTemplate: "grafana", + alertLabels: map[string]string{"alertname": "CriticalAlert", "severity": "critical"}, + expected: "https://critical.example.com/alert/{{.AlertName}}", + }, + { + name: "multiple rules, first match wins", + rules: []TemplateRule{ + { + MatchLabels: map[string]string{"severity": "critical"}, + Template: "https://critical.example.com/alert/{{.AlertName}}", + }, + { + MatchLabels: map[string]string{"alertname": "CriticalAlert"}, + Template: "https://alertname.example.com/alert/{{.AlertName}}", + }, + }, + defaultTemplate: "grafana", + alertLabels: map[string]string{"alertname": "CriticalAlert", "severity": "critical"}, + expected: "https://critical.example.com/alert/{{.AlertName}}", + }, + { + name: "multiple match labels, all must match", + rules: []TemplateRule{ + { + MatchLabels: map[string]string{"severity": "critical", "team": "frontend"}, + Template: "https://frontend-critical.example.com/alert/{{.AlertName}}", + }, + }, + defaultTemplate: "grafana", + alertLabels: map[string]string{"alertname": "Alert", "severity": "critical", "team": "frontend"}, + expected: "https://frontend-critical.example.com/alert/{{.AlertName}}", + }, + { + name: "partial match fails, use default", + rules: []TemplateRule{ + { + MatchLabels: map[string]string{"severity": "critical", "team": "frontend"}, + Template: "https://frontend-critical.example.com/alert/{{.AlertName}}", + }, + }, + defaultTemplate: "grafana", + alertLabels: map[string]string{"alertname": "Alert", "severity": "critical", "team": "backend"}, + expected: "https://grafana.example.com/alerting/groups?alertname={{.AlertName|urlquery}}", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + alert := &rules.Alert{ + Labels: labels.FromMap(tt.alertLabels), + } + result := SelectTemplate(tt.rules, tt.defaultTemplate, tm, alert) + assert.Equal(t, tt.expected, result) + }) + } + + // Test nil alert + result := SelectTemplate([]TemplateRule{}, "grafana", tm, nil) + assert.Equal(t, "grafana", result) +} + +// TestEndToEndIntegration tests complete workflow from template selection to alert processing +func TestEndToEndIntegration(t *testing.T) { + externalURL := "http://prometheus.example.com:9090" + expr := "up{instance=\"web-server-01\"}" + + alert := &rules.Alert{ + Labels: labels.FromMap(map[string]string{ + "alertname": "HighMemoryUsage", + "instance": "web-server-01:9100", + "severity": "critical", + }), + State: rules.StateFiring, + FiredAt: time.Now(), + } + + t.Run("template manager with rule selection", func(t *testing.T) { + // Create template manager with inline and directory templates + tm := NewTemplateManager() + + // Add inline templates first + namedTemplates := map[string]string{ + "pagerduty": "https://pagerduty.example.com/incidents/{{.Labels.incident_id}}", + } + err := tm.LoadInlineTemplates(namedTemplates) + require.NoError(t, err) + + // Create directory template (will override inline if same name) + tempDir, err := os.MkdirTemp("", "integration_test_*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + err = os.WriteFile(filepath.Join(tempDir, "grafana.tmpl"), + []byte("https://grafana.example.com/alert/{{.AlertName}}"), 0644) + require.NoError(t, err) + + err = tm.LoadFromDirectory(tempDir) + require.NoError(t, err) + + // Test rule-based selection + rules := []TemplateRule{ + { + MatchLabels: map[string]string{"severity": "critical"}, + Template: "grafana", // References directory template + }, + } + + selectedTemplate := SelectTemplate(rules, "pagerduty", tm, alert) + assert.Equal(t, "https://grafana.example.com/alert/{{.AlertName}}", selectedTemplate) + + // Execute the selected template + result, err := ExecuteGeneratorURLTemplate(selectedTemplate, alert, expr, externalURL) + assert.NoError(t, err) + assert.Equal(t, "https://grafana.example.com/alert/HighMemoryUsage", result) + }) + + t.Run("alert processing with fallback", func(t *testing.T) { + mockNotifier := &mockNotifierManager{} + alertCfg := &mockAlertConfig{} + + // Test successful template execution + alertCfg.setGeneratorURLTemplate("https://grafana.example.com/alert/{{.AlertName}}", "") + sendAlertsFunc := createSendAlertsFunc(mockNotifier, externalURL, alertCfg) + + sendAlertsFunc(context.Background(), expr, alert) + + require.Len(t, mockNotifier.sentAlerts, 1) + assert.Equal(t, "https://grafana.example.com/alert/HighMemoryUsage", mockNotifier.sentAlerts[0].GeneratorURL) + + // Test fallback on template error + mockNotifier.sentAlerts = nil + alertCfg.setGeneratorURLTemplate("{{.InvalidField}}", "") + sendAlertsFunc(context.Background(), expr, alert) + + require.Len(t, mockNotifier.sentAlerts, 1) + expectedFallbackURL := externalURL + strutil.TableLinkForExpression(expr) + assert.Equal(t, expectedFallbackURL, mockNotifier.sentAlerts[0].GeneratorURL) + }) +} + +// Mock types for testing alert processing workflow +type mockAlertConfig struct { + generatorURLTemplate string +} + +func (ac *mockAlertConfig) setGeneratorURLTemplate(configTemplate, cliTemplate string) { + if cliTemplate != "" { + ac.generatorURLTemplate = cliTemplate + } else { + ac.generatorURLTemplate = configTemplate + } +} + +type mockNotifierManager struct { + sentAlerts []*notifier.Alert +} + +func (m *mockNotifierManager) Send(alerts ...*notifier.Alert) { + m.sentAlerts = append(m.sentAlerts, alerts...) +} + +func createSendAlertsFunc(nm *mockNotifierManager, externalURL string, alertCfg *mockAlertConfig) rules.NotifyFunc { + return func(ctx context.Context, expr string, alerts ...*rules.Alert) { + var res []*notifier.Alert + + for _, alert := range alerts { + if alert.State == rules.StatePending { + continue + } + + var generatorURL string + if alertCfg.generatorURLTemplate != "" { + templateURL, err := ExecuteGeneratorURLTemplate(alertCfg.generatorURLTemplate, alert, expr, externalURL) + if err != nil { + generatorURL = externalURL + strutil.TableLinkForExpression(expr) + } else { + generatorURL = templateURL + } + } else { + generatorURL = externalURL + strutil.TableLinkForExpression(expr) + } + + a := ¬ifier.Alert{ + StartsAt: alert.FiredAt, + Labels: alert.Labels, + Annotations: alert.Annotations, + GeneratorURL: generatorURL, + } + if !alert.ResolvedAt.IsZero() { + a.EndsAt = alert.ResolvedAt + } + res = append(res, a) + } + + if len(res) > 0 { + nm.Send(res...) + } + } +} + diff --git a/pkg/config/config.go b/pkg/config/config.go index 1f01928a7..50b5740be 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -8,6 +8,7 @@ import ( "github.com/prometheus/prometheus/config" yaml "gopkg.in/yaml.v2" + "github.com/jacksontj/promxy/pkg/alerttemplate" "github.com/jacksontj/promxy/pkg/servergroup" ) @@ -60,4 +61,21 @@ func (c *Config) String() string { type PromxyConfig struct { // Config for each of the server groups promxy is configured to aggregate ServerGroups []*servergroup.Config `yaml:"server_groups"` + + // Alert template configuration + AlertTemplates AlertTemplateConfig `yaml:"alert_templates,omitempty"` +} + +type AlertTemplateConfig struct { + // Default template for all alerts + Default string `yaml:"default,omitempty"` + + // Directory containing template files + Directory string `yaml:"directory,omitempty"` + + // Named inline templates + Named map[string]string `yaml:"named,omitempty"` + + // Template selection rules for different alert types + Rules []alerttemplate.TemplateRule `yaml:"rules,omitempty"` } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index f934def9b..ee504a40c 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -2,22 +2,25 @@ package proxyconfig import ( "os" + "path/filepath" "testing" + + "github.com/jacksontj/promxy/pkg/alerttemplate" ) func TestConfigFromFile(t *testing.T) { file, err := os.CreateTemp(os.TempDir(), "") if err != nil { - t.Errorf("Could not create temp file:") + t.Errorf("Could not create temp file: %v", err) } + defer os.Remove(file.Name()) - fileContents := ` -tls_server_config: + fileContents := `tls_server_config: cert_file: "server.crt" - key_file : "server.key" - client_auth_type : "VerifyClientCertIfGiven" - client_ca_file : "tls-ca-chain.pem" -` + key_file: "server.key" + client_auth_type: "VerifyClientCertIfGiven" + client_ca_file: "tls-ca-chain.pem"` + file.Write([]byte(fileContents)) configFilePath := file.Name() @@ -27,15 +30,262 @@ tls_server_config: } if cfg.WebConfig.TLSCertPath != "server.crt" { - t.Errorf("Invalid TLSKeypath. Expected 'server.crt', Got '%s'", cfg.WebConfig.TLSCertPath) + t.Errorf("Invalid TLSCertPath. Expected 'server.crt', Got '%s'", cfg.WebConfig.TLSCertPath) } + if cfg.WebConfig.TLSKeyPath != "server.key" { - t.Errorf("Invalid TLSCertPath. Expected 'server.key', Got '%s'", cfg.WebConfig.TLSKeyPath) + t.Errorf("Invalid TLSKeyPath. Expected 'server.key', Got '%s'", cfg.WebConfig.TLSKeyPath) } + if cfg.WebConfig.ClientAuth != "VerifyClientCertIfGiven" { t.Errorf("Invalid ClientAuth. Expected 'VerifyClientCertIfGiven', Got '%s'", cfg.WebConfig.ClientAuth) } + if cfg.WebConfig.ClientCAs != "tls-ca-chain.pem" { t.Errorf("Invalid ClientCAs. Expected 'tls-ca-chain.pem', Got '%s'", cfg.WebConfig.ClientCAs) } } + +func TestConfigFromFile_AlertTemplates(t *testing.T) { + tests := []struct { + name string + configContent string + expectedDefault string + expectedDirectory string + expectedNamed map[string]string + expectedRules []alerttemplate.TemplateRule + expectError bool + }{ + { + name: "config with default template", + configContent: ` +promxy: + alert_templates: + default: "https://grafana.example.com/alerting/groups?alertname={{.AlertName|urlquery}}" +`, + expectedDefault: "https://grafana.example.com/alerting/groups?alertname={{.AlertName|urlquery}}", + expectedDirectory: "", + expectedNamed: nil, + expectedRules: nil, + expectError: false, + }, + { + name: "config with template directory", + configContent: ` +promxy: + alert_templates: + directory: "/etc/promxy/templates" +`, + expectedDefault: "", + expectedDirectory: "/etc/promxy/templates", + expectedNamed: nil, + expectedRules: nil, + expectError: false, + }, + { + name: "config with named templates", + configContent: ` +promxy: + alert_templates: + named: + grafana: "https://grafana.example.com/alerting/groups?alertname={{.AlertName|urlquery}}" + pagerduty: "https://pagerduty.example.com/incidents/{{.Labels.incident_id}}" +`, + expectedDefault: "", + expectedDirectory: "", + expectedNamed: map[string]string{ + "grafana": "https://grafana.example.com/alerting/groups?alertname={{.AlertName|urlquery}}", + "pagerduty": "https://pagerduty.example.com/incidents/{{.Labels.incident_id}}", + }, + expectedRules: nil, + expectError: false, + }, + { + name: "config with template rules", + configContent: ` +promxy: + alert_templates: + default: "https://prometheus.example.com/graph?g0.expr={{.Expr|urlquery}}" + rules: + - match_labels: + severity: "critical" + template: "https://pagerduty.example.com/incidents/{{.AlertName}}" + - match_labels: + team: "frontend" + template: "https://grafana.example.com/d/frontend?alertname={{.AlertName|urlquery}}" +`, + expectedDefault: "https://prometheus.example.com/graph?g0.expr={{.Expr|urlquery}}", + expectedDirectory: "", + expectedNamed: nil, + expectedRules: []alerttemplate.TemplateRule{ + { + MatchLabels: map[string]string{"severity": "critical"}, + Template: "https://pagerduty.example.com/incidents/{{.AlertName}}", + }, + { + MatchLabels: map[string]string{"team": "frontend"}, + Template: "https://grafana.example.com/d/frontend?alertname={{.AlertName|urlquery}}", + }, + }, + expectError: false, + }, + { + name: "config with all template features", + configContent: ` +promxy: + alert_templates: + default: "https://prometheus.example.com/graph?g0.expr={{.Expr|urlquery}}" + directory: "/etc/promxy/templates" + named: + grafana: "https://grafana.example.com/alerting/groups?alertname={{.AlertName|urlquery}}" + pagerduty: "https://pagerduty.example.com/incidents/{{.Labels.incident_id}}" + rules: + - match_labels: + severity: "critical" + template: "pagerduty" +`, + expectedDefault: "https://prometheus.example.com/graph?g0.expr={{.Expr|urlquery}}", + expectedDirectory: "/etc/promxy/templates", + expectedNamed: map[string]string{ + "grafana": "https://grafana.example.com/alerting/groups?alertname={{.AlertName|urlquery}}", + "pagerduty": "https://pagerduty.example.com/incidents/{{.Labels.incident_id}}", + }, + expectedRules: []alerttemplate.TemplateRule{ + { + MatchLabels: map[string]string{"severity": "critical"}, + Template: "pagerduty", + }, + }, + expectError: false, + }, + { + name: "empty alert templates config", + configContent: ` +promxy: + alert_templates: {} +`, + expectedDefault: "", + expectedDirectory: "", + expectedNamed: nil, + expectedRules: nil, + expectError: false, + }, + { + name: "invalid YAML", + configContent: ` +promxy: + alert_templates: + default: "test" + invalid_yaml_syntax +`, + expectedDefault: "", + expectedDirectory: "", + expectedNamed: nil, + expectedRules: nil, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create temporary config file + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "config.yaml") + + err := os.WriteFile(configFile, []byte(tt.configContent), 0644) + if err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + // Load config + cfg, err := ConfigFromFile(configFile) + + if tt.expectError { + if err == nil { + t.Error("expected error but got none") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + alertTemplates := cfg.PromxyConfig.AlertTemplates + + if alertTemplates.Default != tt.expectedDefault { + t.Errorf("expected default template %q, got %q", tt.expectedDefault, alertTemplates.Default) + } + + if alertTemplates.Directory != tt.expectedDirectory { + t.Errorf("expected template directory %q, got %q", tt.expectedDirectory, alertTemplates.Directory) + } + + // Check named templates + if tt.expectedNamed == nil { + if alertTemplates.Named != nil && len(alertTemplates.Named) > 0 { + t.Errorf("expected no named templates, got %v", alertTemplates.Named) + } + } else { + if alertTemplates.Named == nil { + t.Error("expected named templates but got nil") + return + } + + if len(alertTemplates.Named) != len(tt.expectedNamed) { + t.Errorf("expected %d named templates, got %d", len(tt.expectedNamed), len(alertTemplates.Named)) + } + + for name, expectedContent := range tt.expectedNamed { + if actualContent, exists := alertTemplates.Named[name]; !exists { + t.Errorf("expected named template %q not found", name) + } else if actualContent != expectedContent { + t.Errorf("named template %q: expected %q, got %q", name, expectedContent, actualContent) + } + } + } + + // Check template rules + if tt.expectedRules == nil { + if alertTemplates.Rules != nil && len(alertTemplates.Rules) > 0 { + t.Errorf("expected no template rules, got %v", alertTemplates.Rules) + } + } else { + if alertTemplates.Rules == nil { + t.Error("expected template rules but got nil") + return + } + + if len(alertTemplates.Rules) != len(tt.expectedRules) { + t.Errorf("expected %d template rules, got %d", len(tt.expectedRules), len(alertTemplates.Rules)) + } + + for i, expectedRule := range tt.expectedRules { + if i >= len(alertTemplates.Rules) { + t.Errorf("expected rule %d not found", i) + continue + } + + actualRule := alertTemplates.Rules[i] + + if actualRule.Template != expectedRule.Template { + t.Errorf("rule %d template: expected %q, got %q", i, expectedRule.Template, actualRule.Template) + } + + if len(actualRule.MatchLabels) != len(expectedRule.MatchLabels) { + t.Errorf("rule %d match labels count: expected %d, got %d", i, len(expectedRule.MatchLabels), len(actualRule.MatchLabels)) + } + + for key, expectedValue := range expectedRule.MatchLabels { + if actualValue, exists := actualRule.MatchLabels[key]; !exists { + t.Errorf("rule %d match label %q not found", i, key) + } else if actualValue != expectedValue { + t.Errorf("rule %d match label %q: expected %q, got %q", i, key, expectedValue, actualValue) + } + } + } + } + }) + } +}