From 04eba77ac028033b6dfed604eb1b5664b46acc77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Sat, 7 Feb 2026 00:55:23 +0100 Subject: [PATCH] fix: use nixos-version --json for configuration revision - Use `nixos-version --json` command instead of reading files directly - Add nixpkgs_rev and nixos_version labels to nixos_flake_info metric - Show "unknown" for current_rev when system.configurationRevision not set - Document configurationRevision setup in README Co-Authored-By: Claude Opus 4.5 --- README.md | 36 +++++++++++++++++++++------ collector/flake.go | 55 ++++++++++++++++++++++++++++------------- collector/flake_test.go | 39 +++++++++++++++++++++++------ main.go | 2 +- 4 files changed, 99 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 0099014..3cc7ffb 100644 --- a/README.md +++ b/README.md @@ -80,9 +80,35 @@ services.prometheus.exporters.nixos = { |--------|------|--------|-------------| | `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_info` | Gauge | `current_rev`, `remote_rev` | Info gauge with current and remote flake revisions | +| `nixos_flake_info` | Gauge | `current_rev`, `remote_rev`, `nixpkgs_rev`, `nixos_version` | Info gauge with system version details | | `nixos_flake_revision_behind` | Gauge | | 1 if current system revision differs from remote latest | +### Setting Configuration Revision + +For `current_rev` to show the flake's git revision, you must set `system.configurationRevision` in your flake: + +```nix +{ + outputs = { self, nixpkgs, ... }: { + nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { + modules = [ + { + system.configurationRevision = self.rev or self.dirtyRev or "dirty"; + } + ./configuration.nix + ]; + }; + }; +} +``` + +This sets the revision to: +- `self.rev` - Git commit hash (only available when tree is clean and committed) +- `self.dirtyRev` - Dirty revision (e.g., `abc1234-dirty`) when uncommitted changes exist +- `"dirty"` - Fallback when neither is available + +Without this setting, `current_rev` will be `"unknown"` and `nixos_flake_revision_behind` will always be 0. + ## Example Prometheus Alerts ```yaml @@ -130,13 +156,9 @@ groups: ## Known Limitations -- The `nixos_flake_info` and `nixos_flake_revision_behind` metrics rely 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` +- Flake input metrics (`nixos_flake_input_age_seconds`, `nixos_flake_input_info`) reflect the remote flake state, not the currently deployed system. If the deployed system is behind, these will show newer data than what's actually deployed. - 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. +- The `nixos_flake_revision_behind` metric requires `system.configurationRevision` to be set. Without it, the metric will always be 0 since there's no local revision to compare against. ## License diff --git a/collector/flake.go b/collector/flake.go index c249aa8..5e477bd 100644 --- a/collector/flake.go +++ b/collector/flake.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "log/slog" - "os" "os/exec" "strings" "sync" @@ -14,7 +13,12 @@ import ( "github.com/prometheus/client_golang/prometheus" ) -const configRevisionPath = "/run/current-system/configuration-revision" +// nixosVersionInfo holds the parsed output of nixos-version --json +type nixosVersionInfo struct { + ConfigurationRevision string `json:"configurationRevision"` + NixosVersion string `json:"nixosVersion"` + NixpkgsRevision string `json:"nixpkgsRevision"` +} type FlakeCollector struct { flakeURL string @@ -74,7 +78,7 @@ func NewFlakeCollector(flakeURL string, checkInterval time.Duration) *FlakeColle flakeInfo: prometheus.NewDesc( "nixos_flake_info", "Info gauge with current and remote flake revisions", - []string{"current_rev", "remote_rev"}, nil, + []string{"current_rev", "remote_rev", "nixpkgs_rev", "nixos_version"}, nil, ), revisionBehind: prometheus.NewDesc( "nixos_flake_revision_behind", @@ -164,22 +168,35 @@ func (c *FlakeCollector) collectInputMetrics(ch chan<- prometheus.Metric, data * } func (c *FlakeCollector) collectRevisionBehind(ch chan<- prometheus.Metric, data *flakeMetadata) { - currentRev, err := getCurrentSystemRevision() + versionInfo, err := getNixosVersionInfo() if err != nil { - slog.Error("Failed to get current system revision", "error", err) + slog.Error("Failed to get NixOS version info", "error", err) return } + currentRev := versionInfo.ConfigurationRevision + if currentRev == "" { + currentRev = "unknown" + } else if len(currentRev) > 7 { + currentRev = currentRev[:7] + } + remoteRev := data.Revision if len(remoteRev) > 7 { remoteRev = remoteRev[:7] } + nixpkgsRev := versionInfo.NixpkgsRevision + if len(nixpkgsRev) > 7 { + nixpkgsRev = nixpkgsRev[:7] + } + // Emit flake info metric with revisions - ch <- prometheus.MustNewConstMetric(c.flakeInfo, prometheus.GaugeValue, 1, currentRev, remoteRev) + ch <- prometheus.MustNewConstMetric(c.flakeInfo, prometheus.GaugeValue, 1, + currentRev, remoteRev, nixpkgsRev, versionInfo.NixosVersion) behind := 0.0 - if currentRev != "" && data.Revision != "" { + if currentRev != "unknown" && data.Revision != "" { if currentRev != remoteRev && !strings.HasPrefix(data.Revision, currentRev) { behind = 1.0 } @@ -209,19 +226,23 @@ func fetchFlakeMetadata(flakeURL string) (*flakeMetadata, error) { return &data, nil } -func getCurrentSystemRevision() (string, error) { - data, err := os.ReadFile(configRevisionPath) +func getNixosVersionInfo() (*nixosVersionInfo, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "nixos-version", "--json") + output, err := cmd.Output() if err != nil { - if os.IsNotExist(err) { - // configuration-revision doesn't exist; user hasn't set system.configurationRevision - return "", nil + if exitErr, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("nixos-version failed: %s", strings.TrimSpace(string(exitErr.Stderr))) } - return "", err + return nil, fmt.Errorf("nixos-version failed: %w", err) } - rev := strings.TrimSpace(string(data)) - if len(rev) > 7 { - rev = rev[:7] + var info nixosVersionInfo + if err := json.Unmarshal(output, &info); err != nil { + return nil, fmt.Errorf("failed to parse nixos-version output: %w", err) } - return rev, nil + + return &info, nil } diff --git a/collector/flake_test.go b/collector/flake_test.go index ceb97d0..2112e85 100644 --- a/collector/flake_test.go +++ b/collector/flake_test.go @@ -2,23 +2,46 @@ package collector import ( "encoding/json" - "os" + "os/exec" "testing" ) -func TestGetCurrentSystemRevision(t *testing.T) { - // Skip if not on NixOS with system.configurationRevision set - if _, err := os.Stat(configRevisionPath); os.IsNotExist(err) { - t.Skip("not running on NixOS with system.configurationRevision set") +func TestGetNixosVersionInfo(t *testing.T) { + // Skip if nixos-version command is not available + if _, err := exec.LookPath("nixos-version"); err != nil { + t.Skip("nixos-version command not available") } - rev, err := getCurrentSystemRevision() + info, err := getNixosVersionInfo() if err != nil { t.Fatal(err) } - // Just check it returns something reasonable - t.Logf("current system revision: %s", rev) + t.Logf("NixOS version info: configurationRevision=%s, nixosVersion=%s, nixpkgsRevision=%s", + info.ConfigurationRevision, info.NixosVersion, info.NixpkgsRevision) +} + +func TestNixosVersionInfoUnmarshal(t *testing.T) { + jsonData := `{ + "configurationRevision": "138aeb9e450890efd7ca3be6159b1eb22256c2e3", + "nixosVersion": "25.11.20260203.e576e3c", + "nixpkgsRevision": "e576e3c9cf9bad747afcddd9e34f51d18c855b4e" + }` + + var info nixosVersionInfo + if err := json.Unmarshal([]byte(jsonData), &info); err != nil { + t.Fatal(err) + } + + if info.ConfigurationRevision != "138aeb9e450890efd7ca3be6159b1eb22256c2e3" { + t.Errorf("expected configurationRevision 138aeb9e450890efd7ca3be6159b1eb22256c2e3, got %s", info.ConfigurationRevision) + } + if info.NixosVersion != "25.11.20260203.e576e3c" { + t.Errorf("expected nixosVersion 25.11.20260203.e576e3c, got %s", info.NixosVersion) + } + if info.NixpkgsRevision != "e576e3c9cf9bad747afcddd9e34f51d18c855b4e" { + t.Errorf("expected nixpkgsRevision e576e3c9cf9bad747afcddd9e34f51d18c855b4e, got %s", info.NixpkgsRevision) + } } func TestFlakeLocksUnmarshal(t *testing.T) { diff --git a/main.go b/main.go index eac8a4b..b1915bf 100644 --- a/main.go +++ b/main.go @@ -15,7 +15,7 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" ) -const version = "0.2.1" +const version = "0.2.2" func main() { cfg, err := config.Parse()