From 5b7d710f5a11707b7c76c04790d0304d6affee8b Mon Sep 17 00:00:00 2001 From: Fuwn Date: Thu, 22 Jan 2026 00:04:37 -0800 Subject: feat: Add config imports for monitor inheritance --- internal/config/config.go | 86 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 7542d3e..f97add5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "net/url" "os" + "path/filepath" "strings" "time" @@ -12,6 +13,7 @@ import ( // Config represents the root configuration structure type Config struct { + Imports []string `yaml:"imports,omitempty"` Site SiteConfig `yaml:"site"` Server ServerConfig `yaml:"server"` Storage StorageConfig `yaml:"storage"` @@ -238,6 +240,21 @@ func (d Duration) MarshalYAML() (interface{}, error) { // Load reads and parses a configuration file func Load(path string) (*Config, error) { + visited := make(map[string]bool) + return loadWithImports(path, visited) +} + +func loadWithImports(path string, visited map[string]bool) (*Config, error) { + absPath, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("failed to resolve path %q: %w", path, err) + } + + if visited[absPath] { + return nil, fmt.Errorf("circular import detected: %q", path) + } + visited[absPath] = true + data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read config file: %w", err) @@ -248,10 +265,15 @@ func Load(path string) (*Config, error) { return nil, fmt.Errorf("failed to parse config file: %w", err) } - // Apply defaults + if len(cfg.Imports) > 0 { + baseDir := filepath.Dir(absPath) + if err := cfg.processImports(baseDir, visited); err != nil { + return nil, err + } + } + cfg.applyDefaults() - // Validate configuration if err := cfg.validate(); err != nil { return nil, fmt.Errorf("invalid configuration: %w", err) } @@ -259,6 +281,66 @@ func Load(path string) (*Config, error) { return &cfg, nil } +func (c *Config) processImports(baseDir string, visited map[string]bool) error { + for _, importPath := range c.Imports { + fullPath := importPath + if !filepath.IsAbs(importPath) { + fullPath = filepath.Join(baseDir, importPath) + } + + imported, err := loadWithImports(fullPath, visited) + if err != nil { + return fmt.Errorf("failed to import %q: %w", importPath, err) + } + + c.mergeImported(imported) + } + return nil +} + +func (c *Config) mergeImported(imported *Config) { + groupIndex := make(map[string]int) + for i, g := range c.Groups { + groupIndex[g.Name] = i + } + + for _, importedGroup := range imported.Groups { + if idx, exists := groupIndex[importedGroup.Name]; exists { + c.Groups[idx] = mergeGroups(c.Groups[idx], importedGroup) + } else { + c.Groups = append(c.Groups, importedGroup) + groupIndex[importedGroup.Name] = len(c.Groups) - 1 + } + } + + incidentSet := make(map[string]bool) + for _, inc := range c.Incidents { + incidentSet[inc.Title] = true + } + for _, inc := range imported.Incidents { + if !incidentSet[inc.Title] { + c.Incidents = append(c.Incidents, inc) + incidentSet[inc.Title] = true + } + } +} + +func mergeGroups(local, imported GroupConfig) GroupConfig { + monitorIndex := make(map[string]int) + for i, m := range local.Monitors { + monitorIndex[m.Name] = i + } + + for _, importedMon := range imported.Monitors { + if _, exists := monitorIndex[importedMon.Name]; !exists { + local.Monitors = append(local.Monitors, importedMon) + monitorIndex[importedMon.Name] = len(local.Monitors) - 1 + } + } + + return local +} + // applyDefaults sets default values for missing configuration func (c *Config) applyDefaults() { if c.Site.Name == "" { -- cgit v1.2.3