feat: implement nixos-exporter

Prometheus exporter for NixOS-specific metrics including:
- Generation collector: count, current, booted, age, config mismatch
- Flake collector: input age, input info, revision behind

Includes NixOS module, flake packaging, and documentation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-06 22:50:14 +01:00
commit f637da487c
14 changed files with 1345 additions and 0 deletions

218
collector/generation.go Normal file
View File

@@ -0,0 +1,218 @@
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
}