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

View File

@@ -0,0 +1,178 @@
package collector
import (
"os"
"path/filepath"
"testing"
)
func TestCountGenerations(t *testing.T) {
dir := t.TempDir()
// Create some generation symlinks
for _, name := range []string{
"system-1-link",
"system-2-link",
"system-10-link",
"system", // current system link, should not be counted
"other-file", // unrelated file
"system-x-link", // malformed, should not be counted
} {
path := filepath.Join(dir, name)
if err := os.Symlink("/nix/store/dummy", path); err != nil {
t.Fatal(err)
}
}
count, err := countGenerations(dir)
if err != nil {
t.Fatal(err)
}
if count != 3 {
t.Errorf("expected 3 generations, got %d", count)
}
}
func TestGetCurrentGeneration(t *testing.T) {
dir := t.TempDir()
// Create system symlink pointing to a generation
if err := os.Symlink("system-42-link", filepath.Join(dir, "system")); err != nil {
t.Fatal(err)
}
gen, err := getCurrentGeneration(dir)
if err != nil {
t.Fatal(err)
}
if gen != 42 {
t.Errorf("expected generation 42, got %d", gen)
}
}
func TestGetBootedGeneration(t *testing.T) {
profileDir := t.TempDir()
bootedDir := t.TempDir()
storePath := "/nix/store/abc123-nixos-system"
// Create generation symlinks
if err := os.Symlink("/nix/store/other", filepath.Join(profileDir, "system-1-link")); err != nil {
t.Fatal(err)
}
if err := os.Symlink(storePath, filepath.Join(profileDir, "system-2-link")); err != nil {
t.Fatal(err)
}
if err := os.Symlink("/nix/store/another", filepath.Join(profileDir, "system-3-link")); err != nil {
t.Fatal(err)
}
// Create booted-system symlink
bootedSystemPath := filepath.Join(bootedDir, "booted-system")
if err := os.Symlink(storePath, bootedSystemPath); err != nil {
t.Fatal(err)
}
gen, err := getBootedGeneration(profileDir, bootedSystemPath)
if err != nil {
t.Fatal(err)
}
if gen != 2 {
t.Errorf("expected booted generation 2, got %d", gen)
}
}
func TestCheckConfigMismatch(t *testing.T) {
dir := t.TempDir()
currentPath := filepath.Join(dir, "current-system")
bootedPath := filepath.Join(dir, "booted-system")
// Same target = no mismatch
if err := os.Symlink("/nix/store/same", currentPath); err != nil {
t.Fatal(err)
}
if err := os.Symlink("/nix/store/same", bootedPath); err != nil {
t.Fatal(err)
}
mismatch, err := checkConfigMismatch(currentPath, bootedPath)
if err != nil {
t.Fatal(err)
}
if mismatch {
t.Error("expected no mismatch when targets are the same")
}
// Different targets = mismatch
if err := os.Remove(currentPath); err != nil {
t.Fatal(err)
}
if err := os.Symlink("/nix/store/different", currentPath); err != nil {
t.Fatal(err)
}
mismatch, err = checkConfigMismatch(currentPath, bootedPath)
if err != nil {
t.Fatal(err)
}
if !mismatch {
t.Error("expected mismatch when targets differ")
}
}
func TestGetGenerationAge(t *testing.T) {
dir := t.TempDir()
// Create system symlink
if err := os.Symlink("system-1-link", filepath.Join(dir, "system")); err != nil {
t.Fatal(err)
}
age, err := getGenerationAge(dir)
if err != nil {
t.Fatal(err)
}
// Age should be very small since we just created it
if age < 0 || age > 1 {
t.Errorf("expected age close to 0, got %f", age)
}
}
func TestGenerationPattern(t *testing.T) {
tests := []struct {
name string
match bool
genNum string
}{
{"system-1-link", true, "1"},
{"system-42-link", true, "42"},
{"system-123-link", true, "123"},
{"system", false, ""},
{"system-link", false, ""},
{"system--link", false, ""},
{"system-abc-link", false, ""},
{"other-1-link", false, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matches := generationPattern.FindStringSubmatch(tt.name)
if tt.match {
if matches == nil {
t.Errorf("expected %q to match", tt.name)
} else if matches[1] != tt.genNum {
t.Errorf("expected generation %q, got %q", tt.genNum, matches[1])
}
} else {
if matches != nil {
t.Errorf("expected %q not to match", tt.name)
}
}
})
}
}