package collector import ( "log/slog" "os" "path/filepath" "regexp" "strconv" "time" "github.com/prometheus/client_golang/prometheus" ) const ( profileDir = "/nix/var/nix/profiles" currentSystemDir = "/run/current-system" bootedSystemDir = "/run/booted-system" ) var generationPattern = regexp.MustCompile(`^system-(\d+)-link$`) type GenerationCollector struct { generationCount *prometheus.Desc currentGen *prometheus.Desc bootedGen *prometheus.Desc generationAge *prometheus.Desc configMismatch *prometheus.Desc } func NewGenerationCollector() *GenerationCollector { return &GenerationCollector{ generationCount: prometheus.NewDesc( "nixos_generation_count", "Total number of system generations", nil, nil, ), currentGen: prometheus.NewDesc( "nixos_current_generation", "Currently active generation number", nil, nil, ), bootedGen: prometheus.NewDesc( "nixos_booted_generation", "Generation that was booted", nil, nil, ), generationAge: prometheus.NewDesc( "nixos_generation_age_seconds", "Age of current generation in seconds", nil, nil, ), configMismatch: prometheus.NewDesc( "nixos_config_mismatch", "1 if booted generation differs from current", nil, nil, ), } } func (c *GenerationCollector) Describe(ch chan<- *prometheus.Desc) { ch <- c.generationCount ch <- c.currentGen ch <- c.bootedGen ch <- c.generationAge ch <- c.configMismatch } func (c *GenerationCollector) Collect(ch chan<- prometheus.Metric) { c.collectGenerationCount(ch) c.collectCurrentGeneration(ch) c.collectBootedGeneration(ch) c.collectGenerationAge(ch) c.collectConfigMismatch(ch) } func (c *GenerationCollector) collectGenerationCount(ch chan<- prometheus.Metric) { count, err := countGenerations(profileDir) if err != nil { slog.Error("Failed to count generations", "error", err) return } ch <- prometheus.MustNewConstMetric(c.generationCount, prometheus.GaugeValue, float64(count)) } func (c *GenerationCollector) collectCurrentGeneration(ch chan<- prometheus.Metric) { gen, err := getCurrentGeneration(profileDir) if err != nil { slog.Error("Failed to get current generation", "error", err) return } ch <- prometheus.MustNewConstMetric(c.currentGen, prometheus.GaugeValue, float64(gen)) } func (c *GenerationCollector) collectBootedGeneration(ch chan<- prometheus.Metric) { gen, err := getBootedGeneration(profileDir, bootedSystemDir) if err != nil { slog.Error("Failed to get booted generation", "error", err) return } ch <- prometheus.MustNewConstMetric(c.bootedGen, prometheus.GaugeValue, float64(gen)) } func (c *GenerationCollector) collectGenerationAge(ch chan<- prometheus.Metric) { age, err := getGenerationAge(profileDir) if err != nil { slog.Error("Failed to get generation age", "error", err) return } ch <- prometheus.MustNewConstMetric(c.generationAge, prometheus.GaugeValue, age) } func (c *GenerationCollector) collectConfigMismatch(ch chan<- prometheus.Metric) { mismatch, err := checkConfigMismatch(currentSystemDir, bootedSystemDir) if err != nil { slog.Error("Failed to check config mismatch", "error", err) return } value := 0.0 if mismatch { value = 1.0 } ch <- prometheus.MustNewConstMetric(c.configMismatch, prometheus.GaugeValue, value) } // countGenerations counts system-*-link entries in the profile directory. func countGenerations(profileDir string) (int, error) { entries, err := os.ReadDir(profileDir) if err != nil { return 0, err } count := 0 for _, entry := range entries { if generationPattern.MatchString(entry.Name()) { count++ } } return count, nil } // getCurrentGeneration parses the generation number from the system symlink. func getCurrentGeneration(profileDir string) (int, error) { systemLink := filepath.Join(profileDir, "system") target, err := os.Readlink(systemLink) if err != nil { return 0, err } // Target is relative like "system-123-link" base := filepath.Base(target) matches := generationPattern.FindStringSubmatch(base) if matches == nil { return 0, nil } return strconv.Atoi(matches[1]) } // getBootedGeneration finds the generation that matches /run/booted-system. func getBootedGeneration(profileDir, bootedSystemDir string) (int, error) { bootedTarget, err := os.Readlink(bootedSystemDir) if err != nil { return 0, err } entries, err := os.ReadDir(profileDir) if err != nil { return 0, err } for _, entry := range entries { if !generationPattern.MatchString(entry.Name()) { continue } linkPath := filepath.Join(profileDir, entry.Name()) target, err := os.Readlink(linkPath) if err != nil { continue } if target == bootedTarget { matches := generationPattern.FindStringSubmatch(entry.Name()) if matches != nil { return strconv.Atoi(matches[1]) } } } return 0, nil } // getGenerationAge returns the age of the current system profile in seconds. func getGenerationAge(profileDir string) (float64, error) { systemLink := filepath.Join(profileDir, "system") info, err := os.Lstat(systemLink) if err != nil { return 0, err } age := time.Since(info.ModTime()).Seconds() return age, nil } // checkConfigMismatch compares /run/current-system and /run/booted-system targets. func checkConfigMismatch(currentSystemDir, bootedSystemDir string) (bool, error) { currentTarget, err := os.Readlink(currentSystemDir) if err != nil { return false, err } bootedTarget, err := os.Readlink(bootedSystemDir) if err != nil { return false, err } return currentTarget != bootedTarget, nil }