6 Commits
Author SHA1 Message Date
lostakj 2d634470d9 Updated docs
Build Docker image on push / docker (push) Successful in 21s
Build and push Docker image on tag / docker (push) Successful in 22s
2026-08-18 00:52:04 +02:00
lostakj 072f5bc7eb Added latest tag to tag build
Build Docker image on push / docker (push) Successful in 25s
2026-08-18 00:45:03 +02:00
lostakj 6e3bb0ff7b Added host URL normalization
Build Docker image on push / docker (push) Successful in 22s
Build and push Docker image on tag / docker (push) Successful in 23s
2026-08-17 23:04:28 +02:00
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
14 changed files with 533 additions and 20 deletions
+2 -1
View File
@@ -38,5 +38,6 @@ jobs:
docker buildx build \
--platform linux/amd64 \
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
-t "${IMAGE_NAME}:latest" \
--push \
.
.
+27 -9
View File
@@ -4,7 +4,7 @@ Proxmox Virtual Environment Prometheus metrics exporter.
## 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
@@ -52,6 +52,7 @@ metrics:
ltc: true # Enable collection of LXC container metrics.
qemu: true # Enable collection of QEMU virtual machine 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.
nodeStatus: true # Enable collection of node status metrics.
subscription: true # Enable collection of node subscription details.
@@ -61,26 +62,43 @@ metrics:
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.
## Build
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`
## Pull the Latest Image
The latest published version can be downloaded from the Gitea container registry:
```sh
docker pull gitea.lostak.dev/lostakj/pve-exporter:latest
```
## Build Locally
To build the Docker image for PVE Exporter, use the following command:
```sh
docker build --rm -t gitea.lostak.dev/lostakj/pve-exporter:version .
docker build --rm -t gitea.lostak.dev/lostakj/pve-exporter:latest .
```
Replace `version` with the appropriate version tag you want to use.
## Run
To run the Docker image, use the following command:
To run the downloaded or locally built image, use the following command:
```sh
docker run --rm -d -p 9090:9090 --name pve-exporter gitea.lostak.dev/lostakj/pve-exporter:version
docker run --rm -d -p 9090:9090 --name pve-exporter gitea.lostak.dev/lostakj/pve-exporter:latest
```
Replace `version` with the appropriate version tag you used during the build.
## Usage
Once the Docker container is running, the exporter will be available on port `9090`. You can configure Prometheus to scrape metrics from the exporter by adding a new scrape configuration to your Prometheus configuration file.
+3 -1
View File
@@ -35,6 +35,8 @@ proxmox:
qemu: true
# Enable collection of physical disk metrics.
disk: true
# Enable collection of ZFS pool metrics.
zfs: true
# Enable collection of node storage metrics.
storage: true
# Enable collection of node status metrics.
@@ -42,4 +44,4 @@ proxmox:
# Enable collection of node subscription details.
subscription: true
# Enable collection of software-defined network (SDN) metrics.
sdn: true
sdn: true
+5
View File
@@ -27,6 +27,7 @@ type PveMetricsConfiguration struct {
LXC bool `yaml:"lxc"` // Enables LXC container metrics collection.
QEMU bool `yaml:"qemu"` // Enable QEMU virtual machine 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.
NodeStatus bool `yaml:"nodeStatus"` // Enable node status metrics collection.
Subscription bool `yaml:"subscription"` // Enable node subscription detail collection.
@@ -62,6 +63,10 @@ func (c *Configuration) Validate() error {
if !(u.Scheme == "http" || u.Scheme == "https") {
return fmt.Errorf("PVE host '%s' must be protocol type of HTTP or HTTPS.", host)
}
if u.Hostname() == "" {
return fmt.Errorf("PVE host '%s' must contain an IP address or DNS name.", host)
}
}
// Validate PVE token
+32
View File
@@ -0,0 +1,32 @@
package configuration
import "testing"
func TestConfigurationValidateAcceptsIPAndDNSPveHosts(t *testing.T) {
tests := []struct {
name string
host string
}{
{name: "IP address", host: "https://192.168.0.10:8006"},
{name: "DNS name", host: "https://pve.example.com:8006"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
configuration := Configuration{
Host: "0.0.0.0",
PVE: PveConfiguration{
Hosts: []string{tt.host},
Token: PveTokenConfiguration{
TokenId: "token",
Secret: "secret",
},
},
}
if err := configuration.Validate(); err != nil {
t.Fatalf("Validate() rejected %s host %q: %v", tt.name, tt.host, err)
}
})
}
}
+5
View File
@@ -55,6 +55,11 @@ func NewPveMetricsManager(apiClient *proxmox.PveApiClient, conf *configuration.P
c.RegisterCollector(NewPveNodeDiskCollector(apiClient, c.registry))
}
// Node ZFS pool collector.
if metricsCf.ZFS {
c.RegisterCollector(NewPveNodeZfsCollector(apiClient, c.registry))
}
// Node SDN collector.
if metricsCf.SDN {
c.RegisterCollector(NewPveSdnCollector(apiClient, c.registry))
+2 -2
View File
@@ -39,7 +39,7 @@ func NewPveSubscriptionCollector(apiClient *proxmox.PveApiClient, registry *TTLR
c.status = NewTTLGaugeVec(
prometheus.GaugeOpts{
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"},
5*time.Minute,
@@ -111,7 +111,7 @@ func (c *PveSubscriptionCollector) CollectMetrics() error {
c.info.With(subsLabels).Set(1)
// Subscription state.
c.status.With(labels).Set(subscription.GetActiveNumeric())
c.status.With(labels).Set(subscription.GetStatusNumeric())
// Subscription sockets count.
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)
}
})
}
}
+7 -1
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
@@ -55,7 +56,7 @@ func NewApiClient(endpoints []string, tokenId string, secret string, checkInterv
// Prepare API endpoints.
for _, endpoint := range endpoints {
apiEndpoint := ApiEndpoint{
host: endpoint,
host: normalizeApiHost(endpoint),
alive: false,
}
instance.endpoints = append(instance.endpoints, &apiEndpoint)
@@ -86,6 +87,11 @@ func NewApiClient(endpoints []string, tokenId string, secret string, checkInterv
return &instance
}
// normalizeApiHost ensures API paths can be appended to either IP- or DNS-based hosts.
func normalizeApiHost(host string) string {
return strings.TrimRight(strings.TrimSpace(host), "/") + "/"
}
// Check endpoint liveness state.
func (instance *ApiClient) checkEndpointsLiveness() {
// We want to make sure other routines won't make any requests until we have checked for alive connections.
+49
View File
@@ -0,0 +1,49 @@
package proxmox
import (
"testing"
"time"
)
func TestNewApiClientNormalizesEndpointHosts(t *testing.T) {
tests := []struct {
name string
host string
want string
}{
{
name: "IP without trailing slash",
host: "https://192.168.0.10:8006",
want: "https://192.168.0.10:8006/",
},
{
name: "IP with trailing slash",
host: "https://192.168.0.10:8006/",
want: "https://192.168.0.10:8006/",
},
{
name: "DNS without trailing slash",
host: "https://pve.example.com:8006",
want: "https://pve.example.com:8006/",
},
{
name: "DNS with trailing slash",
host: "https://pve.example.com:8006/",
want: "https://pve.example.com:8006/",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := NewApiClient([]string{tt.host}, "token", "secret", time.Second)
defer client.httpClient.CloseIdleConnections()
if got := client.endpoints[0].host; got != tt.want {
t.Fatalf("normalized endpoint host = %q, want %q", got, tt.want)
}
if got := client.endpoints[0].host + "api2/json/"; got != tt.want+"api2/json/" {
t.Fatalf("liveness URL = %q, want %q", got, tt.want+"api2/json/")
}
})
}
}
+44 -5
View File
@@ -223,6 +223,40 @@ type PveDisk struct {
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.
type PveNodeTime struct {
Time uint64 `json:"time"` // Unix timestamp in UTC.
@@ -430,13 +464,18 @@ func (r *PveSdnResource) GetStatusNumeric() float64 {
return 0
}
// GetActiveNumeric returns the numeric state of a subscription.
// Returns 1 if the subscription status is "active", otherwise returns 0.
func (r *PveSubscription) GetActiveNumeric() float64 {
if r.Status == "active" {
// GetStatusNumeric returns the numeric state of a subscription.
// A missing subscription is 0, an active subscription is 1, and an expired
// or otherwise unusable subscription is 2.
func (r *PveSubscription) GetStatusNumeric() float64 {
switch r.Status {
case "notfound":
return 0
case "active":
return 1
default:
return 2
}
return 0
}
// GetSmartPassedState returns the numeric health state of a disk.
+85 -1
View File
@@ -1,6 +1,9 @@
package proxmox
import "testing"
import (
"encoding/json"
"testing"
)
func TestPveClusterStatusGetClusterModeNumeric(t *testing.T) {
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"
"errors"
"fmt"
"net/url"
"time"
"github.com/mitchellh/mapstructure"
@@ -212,6 +213,37 @@ func (instance *PveApiClient) GetNodeDisksList(node string) (*[]PveDisk, error)
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.
func (instance *PveApiClient) GetNodeTime(node string) (*PveNodeTime, error) {
res, err := instance.apiClient.PerformRequest("GET", "/nodes/"+node+"/time", nil)