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:
218
collector/generation.go
Normal file
218
collector/generation.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user