3 Commits
Author SHA1 Message Date
lostakj 462eb4f80c Extended ZFS states
Build Docker image on push / docker (push) Successful in 23s
Build and push Docker image on tag / docker (push) Successful in 26s
2026-08-17 22:25:19 +02:00
lostakj ba88e5997a Added ZFS state metrics
Build Docker image on push / docker (push) Successful in 23s
Build and push Docker image on tag / docker (push) Successful in 23s
2026-08-17 22:08:57 +02:00
lostakj f8f9e0c852 Updated subscription date
Build Docker image on push / docker (push) Successful in 22s
Build and push Docker image on tag / docker (push) Successful in 22s
2026-08-17 21:50:23 +02:00
10 changed files with 427 additions and 10 deletions
+15 -1
View File
@@ -4,7 +4,7 @@ Proxmox Virtual Environment Prometheus metrics exporter.
## Overview ## Overview
PVE Exporter is a tool that collects metrics from a Proxmox Virtual Environment cluster and exposes them for Prometheus to scrape. This exporter supports gathering metrics for cluster state, LXC containers, QEMU virtual machines, physical disks, node storage, node status, node subscription details, and software-defined networking (SDN). PVE Exporter is a tool that collects metrics from a Proxmox Virtual Environment cluster and exposes them for Prometheus to scrape. This exporter supports gathering metrics for cluster state, LXC containers, QEMU virtual machines, physical disks, ZFS pools, node storage, node status, node subscription details, and software-defined networking (SDN).
## Features ## Features
@@ -52,6 +52,7 @@ metrics:
ltc: true # Enable collection of LXC container metrics. ltc: true # Enable collection of LXC container metrics.
qemu: true # Enable collection of QEMU virtual machine metrics. qemu: true # Enable collection of QEMU virtual machine metrics.
disk: true # Enable collection of physical disk metrics. disk: true # Enable collection of physical disk metrics.
zfs: true # Enable collection of ZFS pool metrics.
storage: true # Enable collection of node storage metrics. storage: true # Enable collection of node storage metrics.
nodeStatus: true # Enable collection of node status metrics. nodeStatus: true # Enable collection of node status metrics.
subscription: true # Enable collection of node subscription details. subscription: true # Enable collection of node subscription details.
@@ -61,6 +62,19 @@ metrics:
The cluster state collector exposes `pve_cluster_mode`, where `1` means that The cluster state collector exposes `pve_cluster_mode`, where `1` means that
Proxmox VE is configured as a cluster and `0` means that it is a standalone node. Proxmox VE is configured as a cluster and `0` means that it is a standalone node.
The `pve_node_subscription_status` metric uses `0` when no subscription is
configured, `1` for an active subscription, and `2` for an expired or otherwise
unusable subscription.
The ZFS collector discovers all pools on every node and exports their recursive
topology with `cluster`, `node`, `pool`, `component`, `path`, and `leaf` labels:
- `pve_node_zfs_state` (`0=UNKNOWN`, `1=ONLINE`, `2=DEGRADED`, `3=FAULTED`,
`4=OFFLINE`, `5=REMOVED`, `6=UNAVAIL`)
- `pve_node_zfs_read_errors`
- `pve_node_zfs_write_errors`
- `pve_node_zfs_checksum_errors`
## Build ## Build
To build the Docker image for PVE Exporter, use the following command: To build the Docker image for PVE Exporter, use the following command:
+2
View File
@@ -35,6 +35,8 @@ proxmox:
qemu: true qemu: true
# Enable collection of physical disk metrics. # Enable collection of physical disk metrics.
disk: true disk: true
# Enable collection of ZFS pool metrics.
zfs: true
# Enable collection of node storage metrics. # Enable collection of node storage metrics.
storage: true storage: true
# Enable collection of node status metrics. # Enable collection of node status metrics.
+1
View File
@@ -27,6 +27,7 @@ type PveMetricsConfiguration struct {
LXC bool `yaml:"lxc"` // Enables LXC container metrics collection. LXC bool `yaml:"lxc"` // Enables LXC container metrics collection.
QEMU bool `yaml:"qemu"` // Enable QEMU virtual machine metrics collection. QEMU bool `yaml:"qemu"` // Enable QEMU virtual machine metrics collection.
Disk bool `yaml:"disk"` // Enable physical disk metrics collection. Disk bool `yaml:"disk"` // Enable physical disk metrics collection.
ZFS bool `yaml:"zfs"` // Enable node ZFS pool metrics collection.
Storage bool `yaml:"storage"` // Enable node storage metrics collection. Storage bool `yaml:"storage"` // Enable node storage metrics collection.
NodeStatus bool `yaml:"nodeStatus"` // Enable node status metrics collection. NodeStatus bool `yaml:"nodeStatus"` // Enable node status metrics collection.
Subscription bool `yaml:"subscription"` // Enable node subscription detail collection. Subscription bool `yaml:"subscription"` // Enable node subscription detail collection.
+5
View File
@@ -55,6 +55,11 @@ func NewPveMetricsManager(apiClient *proxmox.PveApiClient, conf *configuration.P
c.RegisterCollector(NewPveNodeDiskCollector(apiClient, c.registry)) c.RegisterCollector(NewPveNodeDiskCollector(apiClient, c.registry))
} }
// Node ZFS pool collector.
if metricsCf.ZFS {
c.RegisterCollector(NewPveNodeZfsCollector(apiClient, c.registry))
}
// Node SDN collector. // Node SDN collector.
if metricsCf.SDN { if metricsCf.SDN {
c.RegisterCollector(NewPveSdnCollector(apiClient, c.registry)) c.RegisterCollector(NewPveSdnCollector(apiClient, c.registry))
+2 -2
View File
@@ -39,7 +39,7 @@ func NewPveSubscriptionCollector(apiClient *proxmox.PveApiClient, registry *TTLR
c.status = NewTTLGaugeVec( c.status = NewTTLGaugeVec(
prometheus.GaugeOpts{ prometheus.GaugeOpts{
Name: "pve_node_subscription_status", Name: "pve_node_subscription_status",
Help: "Node subscription status.", Help: "Node subscription status (0 = not found, 1 = active, 2 = expired or otherwise unusable).",
}, },
[]string{"cluster", "node"}, []string{"cluster", "node"},
5*time.Minute, 5*time.Minute,
@@ -111,7 +111,7 @@ func (c *PveSubscriptionCollector) CollectMetrics() error {
c.info.With(subsLabels).Set(1) c.info.With(subsLabels).Set(1)
// Subscription state. // Subscription state.
c.status.With(labels).Set(subscription.GetActiveNumeric()) c.status.With(labels).Set(subscription.GetStatusNumeric())
// Subscription sockets count. // Subscription sockets count.
c.sockets.With(labels).Set(float64(subscription.Sockets)) c.sockets.With(labels).Set(float64(subscription.Sockets))
+162
View File
@@ -0,0 +1,162 @@
package metrics
import (
"strconv"
"strings"
"time"
"github.com/prometheus/client_golang/prometheus"
"lostak.dev/pve-exporter/proxmox"
)
// PveNodeZfsCollector collects ZFS pool and component health metrics.
type PveNodeZfsCollector struct {
apiClient *proxmox.PveApiClient
registry *TTLRegistry
state *TTLGaugeVec
readErrors *TTLGaugeVec
writeErrors *TTLGaugeVec
checksumErrors *TTLGaugeVec
}
// zfsMetricComponent is a flattened entry from the recursive ZFS topology.
type zfsMetricComponent struct {
proxmox.PveZfsComponent
Path string
}
var zfsStateValues = map[string]float64{
"ONLINE": 1,
"DEGRADED": 2,
"FAULTED": 3,
"OFFLINE": 4,
"REMOVED": 5,
"UNAVAIL": 6,
}
// NewPveNodeZfsCollector creates a ZFS metrics collector.
func NewPveNodeZfsCollector(apiClient *proxmox.PveApiClient, registry *TTLRegistry) *PveNodeZfsCollector {
c := PveNodeZfsCollector{apiClient: apiClient, registry: registry}
componentLabelNames := []string{"cluster", "node", "pool", "component", "path", "leaf"}
c.state = NewTTLGaugeVec(
prometheus.GaugeOpts{
Name: "pve_node_zfs_state",
Help: "ZFS pool component state (0 = UNKNOWN, 1 = ONLINE, 2 = DEGRADED, 3 = FAULTED, 4 = OFFLINE, 5 = REMOVED, 6 = UNAVAIL).",
},
componentLabelNames,
5*time.Minute,
)
c.registry.Register(c.state)
c.readErrors = NewTTLGaugeVec(
prometheus.GaugeOpts{
Name: "pve_node_zfs_read_errors",
Help: "ZFS pool component read error count.",
},
componentLabelNames,
5*time.Minute,
)
c.registry.Register(c.readErrors)
c.writeErrors = NewTTLGaugeVec(
prometheus.GaugeOpts{
Name: "pve_node_zfs_write_errors",
Help: "ZFS pool component write error count.",
},
componentLabelNames,
5*time.Minute,
)
c.registry.Register(c.writeErrors)
c.checksumErrors = NewTTLGaugeVec(
prometheus.GaugeOpts{
Name: "pve_node_zfs_checksum_errors",
Help: "ZFS pool component checksum error count.",
},
componentLabelNames,
5*time.Minute,
)
c.registry.Register(c.checksumErrors)
return &c
}
// CollectMetrics implements PveMetricsCollector.
func (c *PveNodeZfsCollector) CollectMetrics() error {
cluster, err := c.apiClient.GetClusterStatus()
if err != nil {
return err
}
for _, node := range cluster.NodeStatuses {
pools, err := c.apiClient.GetNodeZfsPools(node.Name)
if err != nil {
return err
}
for _, pool := range *pools {
status, err := c.apiClient.GetNodeZfsPoolStatus(node.Name, pool.Name)
if err != nil {
return err
}
for _, component := range flattenZfsComponents(status) {
labels := prometheus.Labels{
"cluster": cluster.GetClusterName(),
"node": node.Name,
"pool": pool.Name,
"component": component.Name,
"path": component.Path,
"leaf": strconv.FormatBool(component.Leaf == 1),
}
if component.State != "" {
c.state.With(labels).Set(zfsStateNumeric(component.State))
}
if component.Read != nil {
c.readErrors.With(labels).Set(float64(*component.Read))
}
if component.Write != nil {
c.writeErrors.With(labels).Set(float64(*component.Write))
}
if component.Checksum != nil {
c.checksumErrors.With(labels).Set(float64(*component.Checksum))
}
}
}
}
return nil
}
// GetName implements PveMetricsCollector.
func (c *PveNodeZfsCollector) GetName() string {
return "Node ZFS"
}
func flattenZfsComponents(status *proxmox.PveZfsPoolStatus) []zfsMetricComponent {
components := make([]zfsMetricComponent, 0)
appendZfsComponent(&components, status.PveZfsComponent, nil)
return components
}
func appendZfsComponent(components *[]zfsMetricComponent, component proxmox.PveZfsComponent, parents []string) {
pathParts := append(append([]string{}, parents...), component.Name)
*components = append(*components, zfsMetricComponent{
PveZfsComponent: component,
Path: strings.Join(pathParts, " > "),
})
for _, child := range component.Children {
appendZfsComponent(components, child, pathParts)
}
}
func zfsStateNumeric(state string) float64 {
if value, ok := zfsStateValues[strings.ToUpper(state)]; ok {
return value
}
return 0
}
+78
View File
@@ -0,0 +1,78 @@
package metrics
import (
"testing"
"lostak.dev/pve-exporter/proxmox"
)
func TestFlattenZfsComponents(t *testing.T) {
zero := uint64(0)
status := &proxmox.PveZfsPoolStatus{
PveZfsComponent: proxmox.PveZfsComponent{
Name: "hdd",
State: "ONLINE",
Children: []proxmox.PveZfsComponent{
{
Name: "hdd",
Children: []proxmox.PveZfsComponent{
{
Name: "raidz1-0",
State: "ONLINE",
Read: &zero,
Write: &zero,
Checksum: &zero,
Children: []proxmox.PveZfsComponent{
{
Name: "/dev/disk/by-id/disk-1-part1",
State: "ONLINE",
Leaf: 1,
Read: &zero,
Write: &zero,
Checksum: &zero,
},
},
},
},
},
},
},
}
components := flattenZfsComponents(status)
if len(components) != 4 {
t.Fatalf("flattenZfsComponents() returned %d components, want 4", len(components))
}
leaf := components[3]
wantPath := "hdd > hdd > raidz1-0 > /dev/disk/by-id/disk-1-part1"
if leaf.Path != wantPath {
t.Fatalf("leaf path = %q, want %q", leaf.Path, wantPath)
}
if leaf.Leaf != 1 || leaf.Read == nil || leaf.Write == nil || leaf.Checksum == nil {
t.Fatal("leaf component lost its type or error counters")
}
}
func TestZfsStateNumeric(t *testing.T) {
tests := []struct {
state string
want float64
}{
{state: "UNKNOWN", want: 0},
{state: "ONLINE", want: 1},
{state: "DEGRADED", want: 2},
{state: "FAULTED", want: 3},
{state: "OFFLINE", want: 4},
{state: "REMOVED", want: 5},
{state: "UNAVAIL", want: 6},
}
for _, tt := range tests {
t.Run(tt.state, func(t *testing.T) {
if got := zfsStateNumeric(tt.state); got != tt.want {
t.Fatalf("zfsStateNumeric(%q) = %v, want %v", tt.state, got, tt.want)
}
})
}
}
+45 -6
View File
@@ -223,6 +223,40 @@ type PveDisk struct {
Used string `json:"used,omitempty"` // How the drive is used (optional field). Used string `json:"used,omitempty"` // How the drive is used (optional field).
} }
// PveZfsPool represents summary information about a ZFS pool.
type PveZfsPool struct {
Name string `json:"name"` // Pool name.
Size uint64 `json:"size"` // Total pool size in bytes.
Alloc uint64 `json:"alloc"` // Allocated pool space in bytes.
Free uint64 `json:"free"` // Free pool space in bytes.
Frag uint64 `json:"frag"` // Pool fragmentation percentage.
Dedup float64 `json:"dedup"` // Pool deduplication ratio.
Health string `json:"health"` // Pool health state.
}
// PveZfsComponent represents a component in the recursive ZFS pool topology.
// Error counters are pointers because section nodes such as "cache" do not
// always contain read, write, or checksum values in the PVE API response.
type PveZfsComponent struct {
Name string `json:"name"` // Component, vdev, or device name.
State string `json:"state"` // Component state, for example ONLINE.
Leaf uint8 `json:"leaf"` // Whether this component is a leaf device.
Read *uint64 `json:"read"` // Read error count when available.
Write *uint64 `json:"write"` // Write error count when available.
Checksum *uint64 `json:"cksum"` // Checksum error count when available.
Message string `json:"msg"` // Optional component status message.
Children []PveZfsComponent `json:"children"` // Nested ZFS components.
}
// PveZfsPoolStatus represents the detailed status and topology of a ZFS pool.
type PveZfsPoolStatus struct {
PveZfsComponent
Status string `json:"status"` // Human-readable pool status.
Action string `json:"action"` // Recommended recovery action.
Scan string `json:"scan"` // Last or current scrub information.
Errors string `json:"errors"` // Human-readable pool errors.
}
// PVE node time. // PVE node time.
type PveNodeTime struct { type PveNodeTime struct {
Time uint64 `json:"time"` // Unix timestamp in UTC. Time uint64 `json:"time"` // Unix timestamp in UTC.
@@ -430,13 +464,18 @@ func (r *PveSdnResource) GetStatusNumeric() float64 {
return 0 return 0
} }
// GetActiveNumeric returns the numeric state of a subscription. // GetStatusNumeric returns the numeric state of a subscription.
// Returns 1 if the subscription status is "active", otherwise returns 0. // A missing subscription is 0, an active subscription is 1, and an expired
func (r *PveSubscription) GetActiveNumeric() float64 { // or otherwise unusable subscription is 2.
if r.Status == "active" { func (r *PveSubscription) GetStatusNumeric() float64 {
return 1 switch r.Status {
} case "notfound":
return 0 return 0
case "active":
return 1
default:
return 2
}
} }
// GetSmartPassedState returns the numeric health state of a disk. // GetSmartPassedState returns the numeric health state of a disk.
+85 -1
View File
@@ -1,6 +1,9 @@
package proxmox package proxmox
import "testing" import (
"encoding/json"
"testing"
)
func TestPveClusterStatusGetClusterModeNumeric(t *testing.T) { func TestPveClusterStatusGetClusterModeNumeric(t *testing.T) {
tests := []struct { tests := []struct {
@@ -28,3 +31,84 @@ func TestPveClusterStatusGetClusterModeNumeric(t *testing.T) {
}) })
} }
} }
func TestPveSubscriptionGetStatusNumeric(t *testing.T) {
tests := []struct {
status string
want float64
}{
{status: "notfound", want: 0},
{status: "active", want: 1},
{status: "expired", want: 2},
{status: "invalid", want: 2},
{status: "suspended", want: 2},
{status: "new", want: 2},
{status: "unknown", want: 2},
}
for _, tt := range tests {
t.Run(tt.status, func(t *testing.T) {
subscription := PveSubscription{Status: tt.status}
if got := subscription.GetStatusNumeric(); got != tt.want {
t.Fatalf("GetStatusNumeric() = %v, want %v", got, tt.want)
}
})
}
}
func TestPveZfsPoolStatusUnmarshal(t *testing.T) {
payload := []byte(`{
"errors": "No known data errors",
"state": "ONLINE",
"leaf": 0,
"name": "hdd",
"children": [
{
"name": "hdd",
"state": "ONLINE",
"read": 0,
"write": 0,
"cksum": 0,
"children": [
{
"name": "/dev/disk/by-id/disk-1-part1",
"state": "ONLINE",
"leaf": 1,
"read": 0,
"write": 0,
"cksum": 0
}
]
},
{
"name": "cache",
"leaf": 0
}
]
}`)
var status PveZfsPoolStatus
if err := json.Unmarshal(payload, &status); err != nil {
t.Fatalf("unable to unmarshal ZFS pool status: %v", err)
}
if status.Name != "hdd" || status.State != "ONLINE" || status.Errors != "No known data errors" {
t.Fatalf("unexpected pool status: %+v", status)
}
if len(status.Children) != 2 || len(status.Children[0].Children) != 1 {
t.Fatalf("unexpected ZFS topology: %+v", status.Children)
}
leaf := status.Children[0].Children[0]
if leaf.Leaf != 1 || leaf.Read == nil || leaf.Write == nil || leaf.Checksum == nil {
t.Fatal("leaf component is missing its type or error counters")
}
if *leaf.Read != 0 || *leaf.Write != 0 || *leaf.Checksum != 0 {
t.Fatalf("unexpected leaf error counters: read=%d write=%d checksum=%d", *leaf.Read, *leaf.Write, *leaf.Checksum)
}
cache := status.Children[1]
if cache.Read != nil || cache.Write != nil || cache.Checksum != nil {
t.Fatal("missing cache counters must stay absent instead of becoming zero metrics")
}
}
+32
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/url"
"time" "time"
"github.com/mitchellh/mapstructure" "github.com/mitchellh/mapstructure"
@@ -212,6 +213,37 @@ func (instance *PveApiClient) GetNodeDisksList(node string) (*[]PveDisk, error)
return &disks, nil return &disks, nil
} }
// Get ZFS pools configured on a PVE node.
func (instance *PveApiClient) GetNodeZfsPools(node string) (*[]PveZfsPool, error) {
res, err := instance.apiClient.PerformRequest("GET", "/nodes/"+node+"/disks/zfs", nil)
if err != nil {
return nil, err
}
var pools []PveZfsPool
if err := json.Unmarshal(res.Data, &pools); err != nil {
return nil, err
}
return &pools, nil
}
// Get detailed status and topology of a ZFS pool on a PVE node.
func (instance *PveApiClient) GetNodeZfsPoolStatus(node string, pool string) (*PveZfsPoolStatus, error) {
path := "/nodes/" + node + "/disks/zfs/" + url.PathEscape(pool)
res, err := instance.apiClient.PerformRequest("GET", path, nil)
if err != nil {
return nil, err
}
var status PveZfsPoolStatus
if err := json.Unmarshal(res.Data, &status); err != nil {
return nil, err
}
return &status, nil
}
// Get PVE node time info. // Get PVE node time info.
func (instance *PveApiClient) GetNodeTime(node string) (*PveNodeTime, error) { func (instance *PveApiClient) GetNodeTime(node string) (*PveNodeTime, error) {
res, err := instance.apiClient.PerformRequest("GET", "/nodes/"+node+"/time", nil) res, err := instance.apiClient.PerformRequest("GET", "/nodes/"+node+"/time", nil)