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>
219 lines
5.5 KiB
Go
219 lines
5.5 KiB
Go
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
|
|
}
|