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:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
result
|
||||
90
CLAUDE.md
Normal file
90
CLAUDE.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
nixos-exporter is a Prometheus exporter for NixOS-specific metrics. It exposes system state information that standard exporters don't cover: generation management, flake input freshness, and upgrade status.
|
||||
|
||||
**Status**: Implementation complete.
|
||||
|
||||
## Build Commands
|
||||
|
||||
Run commands through the Nix development shell using `nix develop -c`:
|
||||
|
||||
```bash
|
||||
# Build
|
||||
nix develop -c go build ./...
|
||||
|
||||
# Run tests
|
||||
nix develop -c go test ./...
|
||||
|
||||
# Run single test
|
||||
nix develop -c go test -run TestName ./path/to/package
|
||||
|
||||
# Lint
|
||||
nix develop -c golangci-lint run
|
||||
|
||||
# Vulnerability check
|
||||
nix develop -c govulncheck ./...
|
||||
|
||||
# Test Nix build
|
||||
nix build
|
||||
|
||||
# Run the binary (prefer this over go build + running binary)
|
||||
# To pass arguments, use -- before them: nix run .#default -- --help
|
||||
nix run .#default
|
||||
```
|
||||
|
||||
## Testing Procedures
|
||||
|
||||
Before committing, run the following checks:
|
||||
|
||||
1. `nix develop -c go test ./...` - Unit tests
|
||||
2. `nix develop -c golangci-lint run` - Linting
|
||||
3. `nix develop -c govulncheck ./...` - Vulnerability scanning
|
||||
4. `nix build` - Verify nix build works
|
||||
|
||||
## Architecture
|
||||
|
||||
Single binary with pluggable collectors:
|
||||
|
||||
```
|
||||
nixos-exporter/
|
||||
├── main.go # Entry point, HTTP server on :9971
|
||||
├── collector/
|
||||
│ ├── generation.go # Core metrics (count, current, booted, age, mismatch)
|
||||
│ └── flake.go # Flake input freshness metrics (optional)
|
||||
└── config/
|
||||
└── config.go # YAML + CLI flag handling
|
||||
```
|
||||
|
||||
### Collectors
|
||||
|
||||
**Generation collector** (always enabled): Reads symlinks in `/nix/var/nix/profiles/` and `/run/current-system` to determine generation state.
|
||||
|
||||
**Flake collector** (optional): Parses `flake.lock` JSON to extract `lastModified` timestamps per input.
|
||||
|
||||
### Configuration
|
||||
|
||||
Supports YAML config file, CLI flags (`--listen`, `--collector.flake`, `--flake.lock-path`), and NixOS module integration via `services.prometheus.exporters.nixos`.
|
||||
|
||||
## Commit Message Format
|
||||
|
||||
Use conventional commit format:
|
||||
|
||||
```
|
||||
feat: add new feature
|
||||
fix: fix a bug
|
||||
docs: update documentation
|
||||
refactor: refactor code without changing behavior
|
||||
test: add or update tests
|
||||
chore: maintenance tasks
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- Go chosen for mature Prometheus client library and static binary output
|
||||
- Port 9971 allocated for this exporter
|
||||
- Follows nixpkgs `services.prometheus.exporters.*` module pattern
|
||||
- No root required - only reads symlinks and files
|
||||
142
README.md
Normal file
142
README.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# nixos-exporter
|
||||
|
||||
A Prometheus exporter for NixOS-specific metrics. Exposes system state information that standard exporters don't cover: generation management, flake input freshness, and upgrade status.
|
||||
|
||||
## Installation
|
||||
|
||||
### As a flake
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs.nixos-exporter.url = "git+https://git.t-juice.club/torjus/nixos-exporter";
|
||||
|
||||
outputs = { self, nixpkgs, nixos-exporter, ... }: {
|
||||
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
||||
modules = [
|
||||
nixos-exporter.nixosModules.default
|
||||
{
|
||||
services.prometheus.exporters.nixos = {
|
||||
enable = true;
|
||||
flake = {
|
||||
enable = true;
|
||||
url = "github:myuser/myconfig";
|
||||
};
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Manual
|
||||
|
||||
```bash
|
||||
nix build
|
||||
./result/bin/nixos-exporter --listen=:9971
|
||||
```
|
||||
|
||||
## CLI Flags
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--listen` | `:9971` | Address to listen on |
|
||||
| `--collector.flake` | `false` | Enable flake collector |
|
||||
| `--flake.url` | | Flake URL for revision comparison (required if flake collector enabled) |
|
||||
| `--flake.check-interval` | `1h` | Interval between remote flake checks |
|
||||
|
||||
## NixOS Module Options
|
||||
|
||||
```nix
|
||||
services.prometheus.exporters.nixos = {
|
||||
enable = true;
|
||||
port = 9971;
|
||||
listenAddress = "0.0.0.0";
|
||||
openFirewall = false;
|
||||
|
||||
flake = {
|
||||
enable = false;
|
||||
url = ""; # Required if flake.enable = true
|
||||
checkInterval = "1h";
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Metrics
|
||||
|
||||
### Generation Metrics (always enabled)
|
||||
|
||||
| Metric | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `nixos_generation_count` | Gauge | Total number of system generations |
|
||||
| `nixos_current_generation` | Gauge | Currently active generation number |
|
||||
| `nixos_booted_generation` | Gauge | Generation that was booted |
|
||||
| `nixos_generation_age_seconds` | Gauge | Age of current generation in seconds |
|
||||
| `nixos_config_mismatch` | Gauge | 1 if booted generation differs from current |
|
||||
|
||||
### Flake Metrics (optional)
|
||||
|
||||
| Metric | Type | Labels | Description |
|
||||
|--------|------|--------|-------------|
|
||||
| `nixos_flake_input_age_seconds` | Gauge | `input` | Age of flake input in seconds |
|
||||
| `nixos_flake_input_info` | Gauge | `input`, `rev`, `type` | Info gauge with revision and type labels |
|
||||
| `nixos_flake_revision_behind` | Gauge | | 1 if current system revision differs from remote latest |
|
||||
|
||||
## Example Prometheus Alerts
|
||||
|
||||
```yaml
|
||||
groups:
|
||||
- name: nixos
|
||||
rules:
|
||||
- alert: NixOSConfigStale
|
||||
expr: nixos_generation_age_seconds > 7 * 24 * 3600
|
||||
for: 1h
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "NixOS config on {{ $labels.instance }} is over 7 days old"
|
||||
|
||||
- alert: NixOSRebootRequired
|
||||
expr: nixos_config_mismatch == 1
|
||||
for: 24h
|
||||
labels:
|
||||
severity: info
|
||||
annotations:
|
||||
summary: "{{ $labels.instance }} needs reboot to apply config"
|
||||
|
||||
- alert: NixpkgsInputStale
|
||||
expr: nixos_flake_input_age_seconds{input="nixpkgs"} > 30 * 24 * 3600
|
||||
for: 1d
|
||||
labels:
|
||||
severity: info
|
||||
annotations:
|
||||
summary: "nixpkgs input on {{ $labels.instance }} is over 30 days old"
|
||||
|
||||
- alert: NixOSRevisionBehind
|
||||
expr: nixos_flake_revision_behind == 1
|
||||
for: 1h
|
||||
labels:
|
||||
severity: info
|
||||
annotations:
|
||||
summary: "{{ $labels.instance }} is behind remote flake revision"
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- The `/metrics` endpoint exposes system state and revision information. Only expose it on internal networks.
|
||||
- Runs as non-root user; only reads symlinks and files that are world-readable.
|
||||
- When using the flake collector, the exporter executes `nix flake metadata` to fetch remote data.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- The `nixos_flake_revision_behind` metric relies on parsing the git hash from `/run/current-system/nixos-version`. The format of this file varies depending on NixOS configuration:
|
||||
- Standard format: `25.11.20260203.e576e3c`
|
||||
- Custom format: `1994-294a625`
|
||||
|
||||
If your system uses a non-standard format that doesn't end with a git hash, the revision comparison may not work correctly.
|
||||
|
||||
- Flake input ages reflect the remote flake state. If the deployed system is behind, these will show newer timestamps than what's actually deployed.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
216
collector/flake.go
Normal file
216
collector/flake.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
const nixosVersionPath = "/run/current-system/nixos-version"
|
||||
|
||||
// revisionPattern extracts the git hash from nixos-version.
|
||||
// Formats: "25.11.20260203.e576e3c" or "1994-294a625"
|
||||
var revisionPattern = regexp.MustCompile(`[.-]([a-f0-9]{7,40})$`)
|
||||
|
||||
type FlakeCollector struct {
|
||||
flakeURL string
|
||||
checkInterval time.Duration
|
||||
|
||||
inputAge *prometheus.Desc
|
||||
inputInfo *prometheus.Desc
|
||||
revisionBehind *prometheus.Desc
|
||||
|
||||
mu sync.RWMutex
|
||||
cachedData *flakeMetadata
|
||||
lastFetch time.Time
|
||||
fetchError error
|
||||
}
|
||||
|
||||
type flakeMetadata struct {
|
||||
Revision string `json:"revision"`
|
||||
Locks flakeLocks `json:"locks"`
|
||||
}
|
||||
|
||||
type flakeLocks struct {
|
||||
Nodes map[string]flakeLockNode `json:"nodes"`
|
||||
Root string `json:"root"`
|
||||
}
|
||||
|
||||
type flakeLockNode struct {
|
||||
Inputs map[string]interface{} `json:"inputs,omitempty"`
|
||||
Locked *lockedInfo `json:"locked,omitempty"`
|
||||
Original *originalInfo `json:"original,omitempty"`
|
||||
}
|
||||
|
||||
type lockedInfo struct {
|
||||
LastModified int64 `json:"lastModified"`
|
||||
Rev string `json:"rev"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type originalInfo struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func NewFlakeCollector(flakeURL string, checkInterval time.Duration) *FlakeCollector {
|
||||
return &FlakeCollector{
|
||||
flakeURL: flakeURL,
|
||||
checkInterval: checkInterval,
|
||||
inputAge: prometheus.NewDesc(
|
||||
"nixos_flake_input_age_seconds",
|
||||
"Age of flake input in seconds",
|
||||
[]string{"input"}, nil,
|
||||
),
|
||||
inputInfo: prometheus.NewDesc(
|
||||
"nixos_flake_input_info",
|
||||
"Info gauge with revision and type labels",
|
||||
[]string{"input", "rev", "type"}, nil,
|
||||
),
|
||||
revisionBehind: prometheus.NewDesc(
|
||||
"nixos_flake_revision_behind",
|
||||
"1 if current system revision differs from remote latest, 0 if match",
|
||||
nil, nil,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *FlakeCollector) Describe(ch chan<- *prometheus.Desc) {
|
||||
ch <- c.inputAge
|
||||
ch <- c.inputInfo
|
||||
ch <- c.revisionBehind
|
||||
}
|
||||
|
||||
func (c *FlakeCollector) Collect(ch chan<- prometheus.Metric) {
|
||||
data, err := c.getFlakeData()
|
||||
if err != nil {
|
||||
slog.Error("Failed to get flake data", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.collectInputMetrics(ch, data)
|
||||
c.collectRevisionBehind(ch, data)
|
||||
}
|
||||
|
||||
func (c *FlakeCollector) getFlakeData() (*flakeMetadata, error) {
|
||||
c.mu.RLock()
|
||||
if c.cachedData != nil && time.Since(c.lastFetch) < c.checkInterval {
|
||||
data := c.cachedData
|
||||
c.mu.RUnlock()
|
||||
return data, nil
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if c.cachedData != nil && time.Since(c.lastFetch) < c.checkInterval {
|
||||
return c.cachedData, nil
|
||||
}
|
||||
|
||||
data, err := fetchFlakeMetadata(c.flakeURL)
|
||||
if err != nil {
|
||||
c.fetchError = err
|
||||
// Return cached data if available, even if stale
|
||||
if c.cachedData != nil {
|
||||
slog.Warn("Using stale flake data due to fetch error", "error", err)
|
||||
return c.cachedData, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.cachedData = data
|
||||
c.lastFetch = time.Now()
|
||||
c.fetchError = nil
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (c *FlakeCollector) collectInputMetrics(ch chan<- prometheus.Metric, data *flakeMetadata) {
|
||||
now := time.Now().Unix()
|
||||
|
||||
for name, node := range data.Locks.Nodes {
|
||||
// Skip the root node
|
||||
if name == "root" {
|
||||
continue
|
||||
}
|
||||
|
||||
if node.Locked == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Input age
|
||||
age := float64(now - node.Locked.LastModified)
|
||||
ch <- prometheus.MustNewConstMetric(c.inputAge, prometheus.GaugeValue, age, name)
|
||||
|
||||
// Input info
|
||||
rev := node.Locked.Rev
|
||||
if len(rev) > 7 {
|
||||
rev = rev[:7]
|
||||
}
|
||||
inputType := node.Locked.Type
|
||||
ch <- prometheus.MustNewConstMetric(c.inputInfo, prometheus.GaugeValue, 1, name, rev, inputType)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *FlakeCollector) collectRevisionBehind(ch chan<- prometheus.Metric, data *flakeMetadata) {
|
||||
currentRev, err := getCurrentSystemRevision()
|
||||
if err != nil {
|
||||
slog.Error("Failed to get current system revision", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
behind := 0.0
|
||||
if currentRev != "" && data.Revision != "" {
|
||||
// Compare short hashes
|
||||
remoteShort := data.Revision
|
||||
if len(remoteShort) > 7 {
|
||||
remoteShort = remoteShort[:7]
|
||||
}
|
||||
if currentRev != remoteShort && !strings.HasPrefix(data.Revision, currentRev) {
|
||||
behind = 1.0
|
||||
}
|
||||
}
|
||||
|
||||
ch <- prometheus.MustNewConstMetric(c.revisionBehind, prometheus.GaugeValue, behind)
|
||||
}
|
||||
|
||||
func fetchFlakeMetadata(flakeURL string) (*flakeMetadata, error) {
|
||||
cmd := exec.Command("nix", "flake", "metadata", "--json", flakeURL)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data flakeMetadata
|
||||
if err := json.Unmarshal(output, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
func getCurrentSystemRevision() (string, error) {
|
||||
data, err := os.ReadFile(nixosVersionPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
version := strings.TrimSpace(string(data))
|
||||
matches := revisionPattern.FindStringSubmatch(version)
|
||||
if matches == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
rev := matches[1]
|
||||
if len(rev) > 7 {
|
||||
rev = rev[:7]
|
||||
}
|
||||
return rev, nil
|
||||
}
|
||||
151
collector/flake_test.go
Normal file
151
collector/flake_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package collector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRevisionPattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
version string
|
||||
wantRev string
|
||||
}{
|
||||
{"25.11.20260203.e576e3c", "e576e3c"},
|
||||
{"1994-294a625", "294a625"},
|
||||
{"25.05.20250101.abcdef1234567890", "abcdef1234567890"},
|
||||
{"no-revision-here", ""},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.version, func(t *testing.T) {
|
||||
matches := revisionPattern.FindStringSubmatch(tt.version)
|
||||
var got string
|
||||
if matches != nil {
|
||||
got = matches[1]
|
||||
}
|
||||
if got != tt.wantRev {
|
||||
t.Errorf("revisionPattern.FindStringSubmatch(%q) = %q, want %q", tt.version, got, tt.wantRev)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCurrentSystemRevision(t *testing.T) {
|
||||
// Skip if not on NixOS
|
||||
if _, err := os.Stat(nixosVersionPath); os.IsNotExist(err) {
|
||||
t.Skip("not running on NixOS")
|
||||
}
|
||||
|
||||
rev, err := getCurrentSystemRevision()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Just check it returns something reasonable
|
||||
t.Logf("current system revision: %s", rev)
|
||||
}
|
||||
|
||||
func TestGetCurrentSystemRevisionFromFile(t *testing.T) {
|
||||
// Create a temp file to simulate /run/current-system/nixos-version
|
||||
dir := t.TempDir()
|
||||
versionPath := filepath.Join(dir, "nixos-version")
|
||||
|
||||
tests := []struct {
|
||||
content string
|
||||
wantRev string
|
||||
}{
|
||||
{"25.11.20260203.e576e3c\n", "e576e3c"},
|
||||
{"1994-294a625\n", "294a625"},
|
||||
{"25.05.20250101.abcdef1234567890\n", "abcdef1"},
|
||||
{"no-hash", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.content, func(t *testing.T) {
|
||||
if err := os.WriteFile(versionPath, []byte(tt.content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// We can't easily test the actual function without modifying the constant,
|
||||
// so we test the pattern extraction logic directly
|
||||
version := tt.content
|
||||
if len(version) > 0 && version[len(version)-1] == '\n' {
|
||||
version = version[:len(version)-1]
|
||||
}
|
||||
|
||||
matches := revisionPattern.FindStringSubmatch(version)
|
||||
var rev string
|
||||
if matches != nil {
|
||||
rev = matches[1]
|
||||
if len(rev) > 7 {
|
||||
rev = rev[:7]
|
||||
}
|
||||
}
|
||||
|
||||
if rev != tt.wantRev {
|
||||
t.Errorf("got revision %q, want %q", rev, tt.wantRev)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlakeLocksUnmarshal(t *testing.T) {
|
||||
jsonData := `{
|
||||
"revision": "abc1234567890",
|
||||
"locks": {
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1700000000,
|
||||
"rev": "def4567890123",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"home-manager": {
|
||||
"locked": {
|
||||
"lastModified": 1699000000,
|
||||
"rev": "ghi7890123456",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs",
|
||||
"home-manager": "home-manager"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root"
|
||||
}
|
||||
}`
|
||||
|
||||
var data flakeMetadata
|
||||
if err := json.Unmarshal([]byte(jsonData), &data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if data.Revision != "abc1234567890" {
|
||||
t.Errorf("expected revision abc1234567890, got %s", data.Revision)
|
||||
}
|
||||
|
||||
if len(data.Locks.Nodes) != 3 {
|
||||
t.Errorf("expected 3 nodes, got %d", len(data.Locks.Nodes))
|
||||
}
|
||||
|
||||
nixpkgs := data.Locks.Nodes["nixpkgs"]
|
||||
if nixpkgs.Locked == nil {
|
||||
t.Fatal("expected nixpkgs to have locked info")
|
||||
}
|
||||
if nixpkgs.Locked.LastModified != 1700000000 {
|
||||
t.Errorf("expected lastModified 1700000000, got %d", nixpkgs.Locked.LastModified)
|
||||
}
|
||||
if nixpkgs.Locked.Rev != "def4567890123" {
|
||||
t.Errorf("expected rev def4567890123, got %s", nixpkgs.Locked.Rev)
|
||||
}
|
||||
if nixpkgs.Locked.Type != "github" {
|
||||
t.Errorf("expected type github, got %s", nixpkgs.Locked.Type)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
178
collector/generation_test.go
Normal file
178
collector/generation_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
31
config/config.go
Normal file
31
config/config.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ListenAddr string
|
||||
FlakeCollector bool
|
||||
FlakeURL string
|
||||
FlakeCheckInterval time.Duration
|
||||
}
|
||||
|
||||
func Parse() (*Config, error) {
|
||||
cfg := &Config{}
|
||||
|
||||
flag.StringVar(&cfg.ListenAddr, "listen", ":9971", "Address to listen on")
|
||||
flag.BoolVar(&cfg.FlakeCollector, "collector.flake", false, "Enable flake collector")
|
||||
flag.StringVar(&cfg.FlakeURL, "flake.url", "", "Flake URL for revision comparison (required if flake collector enabled)")
|
||||
flag.DurationVar(&cfg.FlakeCheckInterval, "flake.check-interval", time.Hour, "Interval between remote flake checks")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if cfg.FlakeCollector && cfg.FlakeURL == "" {
|
||||
return nil, fmt.Errorf("--flake.url is required when --collector.flake is enabled")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1770197578,
|
||||
"narHash": "sha256-AYqlWrX09+HvGs8zM6ebZ1pwUqjkfpnv8mewYwAo+iM=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "00c21e4c93d963c50d4c0c89bfa84ed6e0694df2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
53
flake.nix
Normal file
53
flake.nix
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
description = "Prometheus exporter for NixOS-specific metrics";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs }:
|
||||
let
|
||||
supportedSystems = [ "x86_64-linux" "aarch64-linux" ];
|
||||
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
|
||||
in
|
||||
{
|
||||
packages = forAllSystems (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in
|
||||
{
|
||||
default = pkgs.buildGoModule {
|
||||
pname = "nixos-exporter";
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
vendorHash = "sha256-NnvB20rORPS5QF5enbb5KpWaKZ70ybSgfd7wjk21/Cg=";
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "Prometheus exporter for NixOS-specific metrics";
|
||||
homepage = "https://git.t-juice.club/torjus/nixos-exporter";
|
||||
license = licenses.mit;
|
||||
maintainers = [ ];
|
||||
platforms = platforms.linux;
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
devShells = forAllSystems (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
go
|
||||
gopls
|
||||
golangci-lint
|
||||
govulncheck
|
||||
delve
|
||||
];
|
||||
};
|
||||
});
|
||||
|
||||
nixosModules.default = import ./module.nix { inherit self; };
|
||||
};
|
||||
}
|
||||
17
go.mod
Normal file
17
go.mod
Normal file
@@ -0,0 +1,17 @@
|
||||
module git.t-juice.club/torjus/nixos-exporter
|
||||
|
||||
go 1.23
|
||||
|
||||
require github.com/prometheus/client_golang v1.20.5
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.55.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
)
|
||||
24
go.sum
Normal file
24
go.sum
Normal file
@@ -0,0 +1,24 @@
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
75
main.go
Normal file
75
main.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.t-juice.club/torjus/nixos-exporter/collector"
|
||||
"git.t-juice.club/torjus/nixos-exporter/config"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Parse()
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Register generation collector
|
||||
genCollector := collector.NewGenerationCollector()
|
||||
prometheus.MustRegister(genCollector)
|
||||
slog.Info("Registered generation collector")
|
||||
|
||||
// Register flake collector if enabled
|
||||
if cfg.FlakeCollector {
|
||||
flakeCollector := collector.NewFlakeCollector(cfg.FlakeURL, cfg.FlakeCheckInterval)
|
||||
prometheus.MustRegister(flakeCollector)
|
||||
slog.Info("Registered flake collector", "url", cfg.FlakeURL, "check_interval", cfg.FlakeCheckInterval)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/metrics", promhttp.Handler())
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`<html>
|
||||
<head><title>NixOS Exporter</title></head>
|
||||
<body>
|
||||
<h1>NixOS Exporter</h1>
|
||||
<p><a href="/metrics">Metrics</a></p>
|
||||
</body>
|
||||
</html>`))
|
||||
})
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
// Handle shutdown gracefully
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
slog.Info("Starting server", "addr", cfg.ListenAddr)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("Server error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
slog.Info("Shutting down server")
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
slog.Error("Failed to shutdown server", "error", err)
|
||||
}
|
||||
}
|
||||
122
module.nix
Normal file
122
module.nix
Normal file
@@ -0,0 +1,122 @@
|
||||
{ self }:
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.services.prometheus.exporters.nixos;
|
||||
in
|
||||
{
|
||||
options.services.prometheus.exporters.nixos = {
|
||||
enable = lib.mkEnableOption "NixOS Prometheus exporter";
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 9971;
|
||||
description = "Port to listen on.";
|
||||
};
|
||||
|
||||
listenAddress = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "0.0.0.0";
|
||||
description = "Address to listen on.";
|
||||
};
|
||||
|
||||
flake = {
|
||||
enable = lib.mkEnableOption "flake collector";
|
||||
|
||||
url = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = ''
|
||||
Flake URL for revision comparison.
|
||||
Required if flake collector is enabled.
|
||||
'';
|
||||
};
|
||||
|
||||
checkInterval = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "1h";
|
||||
description = "Interval between remote flake checks.";
|
||||
};
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Open the firewall for the exporter port.";
|
||||
};
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "nixos-exporter";
|
||||
description = "User to run the exporter as.";
|
||||
};
|
||||
|
||||
group = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "nixos-exporter";
|
||||
description = "Group to run the exporter as.";
|
||||
};
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = self.packages.${pkgs.system}.default;
|
||||
description = "The nixos-exporter package to use.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
assertions = [
|
||||
{
|
||||
assertion = cfg.flake.enable -> cfg.flake.url != "";
|
||||
message = "services.prometheus.exporters.nixos.flake.url must be set when flake collector is enabled";
|
||||
}
|
||||
];
|
||||
|
||||
users.users.${cfg.user} = {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
description = "NixOS exporter user";
|
||||
};
|
||||
|
||||
users.groups.${cfg.group} = { };
|
||||
|
||||
systemd.services.prometheus-nixos-exporter = {
|
||||
description = "Prometheus NixOS Exporter";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" ];
|
||||
|
||||
serviceConfig = {
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
ExecStart = lib.concatStringsSep " " ([
|
||||
"${cfg.package}/bin/nixos-exporter"
|
||||
"--listen=${cfg.listenAddress}:${toString cfg.port}"
|
||||
] ++ lib.optionals cfg.flake.enable [
|
||||
"--collector.flake"
|
||||
"--flake.url=${cfg.flake.url}"
|
||||
"--flake.check-interval=${cfg.flake.checkInterval}"
|
||||
]);
|
||||
Restart = "on-failure";
|
||||
RestartSec = "5s";
|
||||
|
||||
# Hardening
|
||||
NoNewPrivileges = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
PrivateTmp = true;
|
||||
PrivateDevices = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectControlGroups = true;
|
||||
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
|
||||
RestrictNamespaces = true;
|
||||
RestrictRealtime = true;
|
||||
RestrictSUIDSGID = true;
|
||||
MemoryDenyWriteExecute = true;
|
||||
LockPersonality = true;
|
||||
};
|
||||
};
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user