summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorIlya Mashchenko <ilya@netdata.cloud>2024-04-10 20:18:05 +0300
committerGitHub <noreply@github.com>2024-04-10 20:18:05 +0300
commit7b4b258879ba4a83817981033dd6342a70cf80af (patch)
tree9c028b0aac49b5e440c8e7ec66de91d162dd5ebb
parent30d5acdaef786228112ac1b9d9673b1cec6b887b (diff)
add collector to monitor ZFS pools space usage (#17367)
-rw-r--r--src/collectors/proc.plugin/plugin_proc.c1
-rw-r--r--src/go/collectors/go.d.plugin/README.md1
-rw-r--r--src/go/collectors/go.d.plugin/config/go.d.conf1
-rw-r--r--src/go/collectors/go.d.plugin/config/go.d/zfspool.conf9
-rw-r--r--src/go/collectors/go.d.plugin/modules/init.go1
-rw-r--r--src/go/collectors/go.d.plugin/modules/nvme/config_schema.json2
-rw-r--r--src/go/collectors/go.d.plugin/modules/zfspool/charts.go115
-rw-r--r--src/go/collectors/go.d.plugin/modules/zfspool/collect.go177
-rw-r--r--src/go/collectors/go.d.plugin/modules/zfspool/config_schema.json47
-rw-r--r--src/go/collectors/go.d.plugin/modules/zfspool/exec.go41
-rw-r--r--src/go/collectors/go.d.plugin/modules/zfspool/init.go37
-rw-r--r--src/go/collectors/go.d.plugin/modules/zfspool/metadata.yaml138
-rw-r--r--src/go/collectors/go.d.plugin/modules/zfspool/testdata/config.json5
-rw-r--r--src/go/collectors/go.d.plugin/modules/zfspool/testdata/config.yaml3
-rw-r--r--src/go/collectors/go.d.plugin/modules/zfspool/testdata/zpool-list.txt3
-rw-r--r--src/go/collectors/go.d.plugin/modules/zfspool/zfspool.go111
-rw-r--r--src/go/collectors/go.d.plugin/modules/zfspool/zfspool_test.go248
-rw-r--r--src/health/health.d/zfs.conf46
18 files changed, 985 insertions, 1 deletions
diff --git a/src/collectors/proc.plugin/plugin_proc.c b/src/collectors/proc.plugin/plugin_proc.c
index 7742b344f4..cacc70523e 100644
--- a/src/collectors/proc.plugin/plugin_proc.c
+++ b/src/collectors/proc.plugin/plugin_proc.c
@@ -160,6 +160,7 @@ void *proc_main(void *ptr)
netdata_thread_cleanup_push(proc_main_cleanup, ptr)
{
config_get_boolean("plugin:proc", "/proc/pagetypeinfo", CONFIG_BOOLEAN_NO);
+ config_get_boolean("plugin:proc", "/proc/spl/kstat/zfs/pool/state", CONFIG_BOOLEAN_NO);
// check the enabled status for each module
int i;
diff --git a/src/go/collectors/go.d.plugin/README.md b/src/go/collectors/go.d.plugin/README.md
index 3423c69686..41129166d7 100644
--- a/src/go/collectors/go.d.plugin/README.md
+++ b/src/go/collectors/go.d.plugin/README.md
@@ -128,6 +128,7 @@ see the appropriate collector readme.
| [whoisquery](https://github.com/netdata/netdata/tree/master/src/go/collectors/go.d.plugin/modules/whoisquery) | Domain Expiry |
| [windows](https://github.com/netdata/netdata/tree/master/src/go/collectors/go.d.plugin/modules/windows) | Windows |
| [x509check](https://github.com/netdata/netdata/tree/master/src/go/collectors/go.d.plugin/modules/x509check) | Digital Certificates |
+| [zfspool](https://github.com/netdata/netdata/tree/master/src/go/collectors/go.d.plugin/modules/zfspool) | ZFS Pools |
| [zookeeper](https://github.com/netdata/netdata/tree/master/src/go/collectors/go.d.plugin/modules/zookeeper) | ZooKeeper |
## Configuration
diff --git a/src/go/collectors/go.d.plugin/config/go.d.conf b/src/go/collectors/go.d.plugin/config/go.d.conf
index bcd5455bc0..a201c1ff22 100644
--- a/src/go/collectors/go.d.plugin/config/go.d.conf
+++ b/src/go/collectors/go.d.plugin/config/go.d.conf
@@ -88,4 +88,5 @@ modules:
# whoisquery: yes
# windows: yes
# x509check: yes
+# zfspool: yes
# zookeeper: yes
diff --git a/src/go/collectors/go.d.plugin/config/go.d/zfspool.conf b/src/go/collectors/go.d.plugin/config/go.d/zfspool.conf
new file mode 100644
index 0000000000..587c9b772a
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/config/go.d/zfspool.conf
@@ -0,0 +1,9 @@
+## All available configuration options, their descriptions and default values:
+## https://github.com/netdata/netdata/tree/master/src/go/collectors/go.d.plugin/modules/zpool#readme
+
+jobs:
+ - name: zfspool
+ binary_path: /usr/bin/zpool
+
+ - name: zfspool
+ binary_path: /sbin/zpool # FreeBSD
diff --git a/src/go/collectors/go.d.plugin/modules/init.go b/src/go/collectors/go.d.plugin/modules/init.go
index bcfd39f3e2..66ec43c165 100644
--- a/src/go/collectors/go.d.plugin/modules/init.go
+++ b/src/go/collectors/go.d.plugin/modules/init.go
@@ -80,5 +80,6 @@ import (
_ "github.com/netdata/netdata/go/go.d.plugin/modules/windows"
_ "github.com/netdata/netdata/go/go.d.plugin/modules/wireguard"
_ "github.com/netdata/netdata/go/go.d.plugin/modules/x509check"
+ _ "github.com/netdata/netdata/go/go.d.plugin/modules/zfspool"
_ "github.com/netdata/netdata/go/go.d.plugin/modules/zookeeper"
)
diff --git a/src/go/collectors/go.d.plugin/modules/nvme/config_schema.json b/src/go/collectors/go.d.plugin/modules/nvme/config_schema.json
index ee3f19d8d7..001d2a3e7d 100644
--- a/src/go/collectors/go.d.plugin/modules/nvme/config_schema.json
+++ b/src/go/collectors/go.d.plugin/modules/nvme/config_schema.json
@@ -1,7 +1,7 @@
{
"jsonSchema": {
"$schema": "http://json-schema.org/draft-07/schema#",
- "title": "NVMe Collector Configuration",
+ "title": "NVMe collector configuration",
"type": "object",
"properties": {
"update_every": {
diff --git a/src/go/collectors/go.d.plugin/modules/zfspool/charts.go b/src/go/collectors/go.d.plugin/modules/zfspool/charts.go
new file mode 100644
index 0000000000..45943c656e
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/zfspool/charts.go
@@ -0,0 +1,115 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package zfspool
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/netdata/netdata/go/go.d.plugin/agent/module"
+)
+
+const (
+ prioZpoolSpaceUtilization = 2820 + iota
+ prioZpoolSpaceUsage
+ prioZpoolFragmentation
+ prioZpoolHealthState
+)
+
+var zpoolChartsTmpl = module.Charts{
+ zpoolSpaceUtilizationChartTmpl.Copy(),
+ zpoolSpaceUsageChartTmpl.Copy(),
+
+ zpoolFragmentationChartTmpl.Copy(),
+
+ zpoolHealthStateChartTmpl.Copy(),
+}
+
+var (
+ zpoolSpaceUtilizationChartTmpl = module.Chart{
+ ID: "zfspool_%s_space_utilization",
+ Title: "Zpool space utilization",
+ Units: "percentage",
+ Fam: "space usage",
+ Ctx: "zfspool.pool_space_utilization",
+ Type: module.Area,
+ Priority: prioZpoolSpaceUtilization,
+ Dims: module.Dims{
+ {ID: "zpool_%s_cap", Name: "utilization"},
+ },
+ }
+ zpoolSpaceUsageChartTmpl = module.Chart{
+ ID: "zfspool_%s_space_usage",
+ Title: "Zpool space usage",
+ Units: "bytes",
+ Fam: "space usage",
+ Ctx: "zfspool.pool_space_usage",
+ Type: module.Stacked,
+ Priority: prioZpoolSpaceUsage,
+ Dims: module.Dims{
+ {ID: "zpool_%s_free", Name: "free"},
+ {ID: "zpool_%s_alloc", Name: "used"},
+ },
+ }
+
+ zpoolFragmentationChartTmpl = module.Chart{
+ ID: "zfspool_%s_fragmentation",
+ Title: "Zpool fragmentation",
+ Units: "percentage",
+ Fam: "fragmentation",
+ Ctx: "zfspool.pool_fragmentation",
+ Type: module.Line,
+ Priority: prioZpoolFragmentation,
+ Dims: module.Dims{
+ {ID: "zpool_%s_frag", Name: "fragmentation"},
+ },
+ }
+
+ zpoolHealthStateChartTmpl = module.Chart{
+ ID: "zfspool_%s_health_state",
+ Title: "Zpool health state",
+ Units: "state",
+ Fam: "health",
+ Ctx: "zfspool.pool_health_state",
+ Type: module.Line,
+ Priority: prioZpoolHealthState,
+ Dims: module.Dims{
+ {ID: "zpool_%s_health_state_online", Name: "online"},
+ {ID: "zpool_%s_health_state_degraded", Name: "degraded"},
+ {ID: "zpool_%s_health_state_faulted", Name: "faulted"},
+ {ID: "zpool_%s_health_state_offline", Name: "offline"},
+ {ID: "zpool_%s_health_state_unavail", Name: "unavail"},
+ {ID: "zpool_%s_health_state_removed", Name: "removed"},
+ {ID: "zpool_%s_health_state_suspended", Name: "suspended"},
+ },
+ }
+)
+
+func (z *ZFSPool) addZpoolCharts(name string) {
+ charts := zpoolChartsTmpl.Copy()
+
+ for _, chart := range *charts {
+ chart.ID = fmt.Sprintf(chart.ID, name)
+ chart.Labels = []module.Label{
+ {Key: "pool", Value: name},
+ }
+ for _, dim := range chart.Dims {
+ dim.ID = fmt.Sprintf(dim.ID, name)
+ }
+ }
+
+ if err := z.Charts().Add(*charts...); err != nil {
+ z.Warning(err)
+ }
+}
+
+func (z *ZFSPool) removeZpoolCharts(name string) {
+ px := fmt.Sprintf("zpool_%s_", name)
+
+ for _, chart := range *z.Charts() {
+ if strings.HasPrefix(chart.ID, px) {
+ chart.MarkRemove()
+ chart.MarkNotCreated()
+ }
+ }
+}
diff --git a/src/go/collectors/go.d.plugin/modules/zfspool/collect.go b/src/go/collectors/go.d.plugin/modules/zfspool/collect.go
new file mode 100644
index 0000000000..43994bfc1b
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/zfspool/collect.go
@@ -0,0 +1,177 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package zfspool
+
+import (
+ "bufio"
+ "bytes"
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+var zpoolHealthStates = []string{
+ "online",
+ "degraded",
+ "faulted",
+ "offline",
+ "removed",
+ "unavail",
+ "suspended",
+}
+
+type zpoolStats struct {
+ name string
+ sizeBytes string
+ allocBytes string
+ freeBytes string
+ fragPerc string
+ capPerc string
+ dedupRatio string
+ health string
+}
+
+func (z *ZFSPool) collect() (map[string]int64, error) {
+ bs, err := z.exec.list()
+ if err != nil {
+ return nil, err
+ }
+
+ zpools, err := parseZpoolListOutput(bs)
+ if err != nil {
+ return nil, err
+ }
+
+ mx := make(map[string]int64)
+
+ z.collectZpoolListStats(mx, zpools)
+
+ return mx, nil
+}
+
+func (z *ZFSPool) collectZpoolListStats(mx map[string]int64, zpools []zpoolStats) {
+ seen := make(map[string]bool)
+
+ for _, zpool := range zpools {
+ seen[zpool.name] = true
+
+ if !z.zpools[zpool.name] {
+ z.addZpoolCharts(zpool.name)
+ z.zpools[zpool.name] = true
+ }
+
+ px := "zpool_" + zpool.name + "_"
+
+ if v, ok := parseInt(zpool.sizeBytes); ok {
+ mx[px+"size"] = v
+ }
+ if v, ok := parseInt(zpool.freeBytes); ok {
+ mx[px+"free"] = v
+ }
+ if v, ok := parseInt(zpool.allocBytes); ok {
+ mx[px+"alloc"] = v
+ }
+ if v, ok := parseFloat(zpool.capPerc); ok {
+ mx[px+"cap"] = int64(v)
+ }
+ if v, ok := parseFloat(zpool.fragPerc); ok {
+ mx[px+"frag"] = int64(v)
+ }
+ for _, s := range zpoolHealthStates {
+ mx[px+"health_state_"+s] = 0
+ }
+ mx[px+"health_state_"+zpool.health] = 1
+ }
+
+ for name := range z.zpools {
+ if !seen[name] {
+ z.removeZpoolCharts(name)
+ delete(z.zpools, name)
+ }
+ }
+}
+
+func parseZpoolListOutput(bs []byte) ([]zpoolStats, error) {
+ var lines []string
+ sc := bufio.NewScanner(bytes.NewReader(bs))
+ for sc.Scan() {
+ if text := strings.TrimSpace(sc.Text()); text != "" {
+ lines = append(lines, text)
+ }
+
+ }
+ if len(lines) < 2 {
+ return nil, fmt.Errorf("unexpected data: wanted >= 2 lines, got %d", len(lines))
+ }
+
+ headers := strings.Fields(lines[0])
+ if len(headers) == 0 {
+ return nil, fmt.Errorf("unexpected data: missing headers")
+ }
+
+ var zpools []zpoolStats
+
+ /*
+ # zpool list -p
+ NAME SIZE ALLOC FREE EXPANDSZ FRAG CAP DEDUP HEALTH ALTROOT
+ rpool 21367462298 9051643576 12240656794 - 33 42 1.00 ONLINE -
+ zion - - - - - - - FAULTED -
+ */
+
+ for _, line := range lines[1:] {
+ values := strings.Fields(line)
+ if len(values) != len(headers) {
+ return nil, fmt.Errorf("unequal columns: headers(%d) != values(%d)", len(headers), len(values))
+ }
+
+ var zpool zpoolStats
+
+ for i, v := range values {
+ v = strings.TrimSpace(v)
+ switch strings.ToLower(headers[i]) {
+ case "name":
+ zpool.name = v
+ case "size":
+ zpool.sizeBytes = v
+ case "alloc":
+ zpool.allocBytes = v
+ case "free":
+ zpool.freeBytes = v
+ case "frag":
+ zpool.fragPerc = v
+ case "cap":
+ zpool.capPerc = v
+ case "dedup":
+ zpool.dedupRatio = v
+ case "health":
+ zpool.health = strings.ToLower(v)
+ }
+
+ if last := i+1 == len(headers); last && zpool.name != "" && zpool.health != "" {
+ zpools = append(zpools, zpool)
+ }
+ }
+ }
+
+ if len(zpools) == 0 {
+ return nil, fmt.Errorf("unexpected data: missing pools")
+ }
+
+ return zpools, nil
+}
+
+func parseInt(s string) (int64, bool) {
+ if s == "-" {
+ return 0, false
+ }
+ v, err := strconv.ParseInt(s, 10, 64)
+ return v, err == nil
+}
+
+func parseFloat(s string) (float64, bool) {
+ if s == "-" {
+ return 0, false
+ }
+ v, err := strconv.ParseFloat(s, 64)
+ return v, err == nil
+}
diff --git a/src/go/collectors/go.d.plugin/modules/zfspool/config_schema.json b/src/go/collectors/go.d.plugin/modules/zfspool/config_schema.json
new file mode 100644
index 0000000000..dc11055f00
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/zfspool/config_schema.json
@@ -0,0 +1,47 @@
+{
+ "jsonSchema": {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "ZFS Pools collector configuration",
+ "type": "object",
+ "properties": {
+ "update_every": {
+ "title": "Update every",
+ "description": "Data collection interval, measured in seconds.",
+ "type": "integer",
+ "minimum": 1,
+ "default": 10
+ },
+ "binary_path": {
+ "title": "Binary path",
+ "description": "Path to the `zpool` binary.",
+ "type": "string",
+ "default": "nvme"
+ },
+ "timeout": {
+ "title": "Timeout",
+ "description": "Timeout for executing the binary, specified in seconds.",
+ "type": "number",
+ "minimum": 0.5,
+ "default": 10
+ }
+ },
+ "required": [
+ "binary_path"
+ ],
+ "additionalProperties": false,
+ "patternProperties": {
+ "^name$": {}
+ }
+ },
+ "uiSchema": {
+ "uiOptions": {
+ "fullPage": true
+ },
+ "binary_path": {
+ "ui:help": "If an absolute path is provided, the collector will use it directly; otherwise, it will search for the binary in directories specified in the PATH environment variable."
+ },
+ "timeout": {
+ "ui:help": "Accepts decimals for precise control (e.g., type 1.5 for 1.5 seconds)."
+ }
+ }
+}
diff --git a/src/go/collectors/go.d.plugin/modules/zfspool/exec.go b/src/go/collectors/go.d.plugin/modules/zfspool/exec.go
new file mode 100644
index 0000000000..0c155872e4
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/zfspool/exec.go
@@ -0,0 +1,41 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package zfspool
+
+import (
+ "context"
+ "fmt"
+ "os/exec"
+ "time"
+
+ "github.com/netdata/netdata/go/go.d.plugin/logger"
+)
+
+func newZpoolCLIExec(binPath string, timeout time.Duration) *zpoolCLIExec {
+ return &zpoolCLIExec{
+ binPath: binPath,
+ timeout: timeout,
+ }
+}
+
+type zpoolCLIExec struct {
+ *logger.Logger
+
+ binPath string
+ timeout time.Duration
+}
+
+func (e *zpoolCLIExec) list() ([]byte, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), e.timeout)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, e.binPath, "list", "-p")
+ e.Debugf("executing '%s'", cmd)
+
+ bs, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("error on '%s': %v", cmd, err)
+ }
+
+ return bs, nil
+}
diff --git a/src/go/collectors/go.d.plugin/modules/zfspool/init.go b/src/go/collectors/go.d.plugin/modules/zfspool/init.go
new file mode 100644
index 0000000000..d4debe3fa0
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/zfspool/init.go
@@ -0,0 +1,37 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package zfspool
+
+import (
+ "errors"
+ "os"
+ "os/exec"
+ "strings"
+)
+
+func (z *ZFSPool) validateConfig() error {
+ if z.BinaryPath == "" {
+ return errors.New("no zpool binary path specified")
+ }
+ return nil
+}
+
+func (z *ZFSPool) initZPoolCLIExec() (zpoolCLI, error) {
+ binPath := z.BinaryPath
+
+ if !strings.HasPrefix(binPath, "/") {
+ path, err := exec.LookPath(binPath)
+ if err != nil {
+ return nil, err
+ }
+ binPath = path
+ }
+
+ if _, err := os.Stat(binPath); err != nil {
+ return nil, err
+ }
+
+ zpoolExec := newZpoolCLIExec(binPath, z.Timeout.Duration())
+
+ return zpoolExec, nil
+}
diff --git a/src/go/collectors/go.d.plugin/modules/zfspool/metadata.yaml b/src/go/collectors/go.d.plugin/modules/zfspool/metadata.yaml
new file mode 100644
index 0000000000..e82611be4f
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/zfspool/metadata.yaml
@@ -0,0 +1,138 @@
+plugin_name: go.d.plugin
+modules:
+ - meta:
+ id: collector-go.d.plugin-zfspool
+ plugin_name: go.d.plugin
+ module_name: zfspool
+ monitored_instance:
+ name: ZFS Pools
+ link: ""
+ icon_filename: filesystem.svg
+ categories:
+ - data-collection.storage-mount-points-and-filesystems
+ keywords:
+ - zfs pools
+ - pools
+ - zfs
+ - filesystem
+ related_resources:
+ integrations:
+ list: []
+ info_provided_to_referring_integrations:
+ description: ""
+ most_popular: false
+ overview:
+ data_collection:
+ metrics_description: >
+ This collector monitors the health and space usage of ZFS pools using the command line
+ tool [zpool](https://openzfs.github.io/openzfs-docs/man/master/8/zpool-list.8.html).
+ method_description: ""
+ supported_platforms:
+ include: []
+ exclude: []
+ multi_instance: false
+ additional_permissions:
+ description: ""
+ default_behavior:
+ auto_detection:
+ description: ""
+ limits:
+ description: ""
+ performance_impact:
+ description: ""
+ setup:
+ prerequisites:
+ list: []
+ configuration:
+ file:
+ name: go.d/zfspool.conf
+ options:
+ description: |
+ The following options can be defined globally: update_every.
+ folding:
+ title: Config options
+ enabled: true
+ list:
+ - name: update_every
+ description: Data collection frequency.
+ default_value: 10
+ required: false
+ - name: binary_path
+ description: Path to the `zpool` binary. If an absolute path is provided, the collector will use it directly; otherwise, it will search for the binary in directories specified in the PATH environment variable.
+ default_value: /usr/bin/zpool
+ required: true
+ - name: timeout
+ description: Timeout for executing the binary, specified in seconds.
+ default_value: 2
+ required: false
+ examples:
+ folding:
+ title: Config
+ enabled: true
+ list:
+ - name: Custom binary path
+ description: The executable is not in the directories specified in the PATH environment variable.
+ config: |
+ jobs:
+ - name: zfspool
+ binary_path: /usr/local/sbin/zpool
+ troubleshooting:
+ problems:
+ list: []
+ alerts:
+ - name: zfs_pool_space_utilization
+ metric: zfspool.pool_space_utilization
+ info: "ZFS pool **${label:pool}** is nearing capacity. Current space usage is above the threshold."
+ link: https://github.com/netdata/netdata/blob/master/src/health/health.d/zfs.conf
+ - name: zfs_pool_health_state_warn
+ metric: zfspool.pool_health_state
+ info: "ZFS pool ${label:pool} state is degraded"
+ link: https://github.com/netdata/netdata/blob/master/src/health/health.d/zfs.conf
+ - name: zfs_pool_health_state_crit
+ metric: zfspool.pool_health_state
+ info: "ZFS pool ${label:pool} state is faulted or unavail"
+ link: https://github.com/netdata/netdata/blob/master/src/health/health.d/zfs.conf
+ metrics:
+ folding:
+ title: Metrics
+ enabled: false
+ description: ""
+ availability: []
+ scopes:
+ - name: zfs pool
+ description: These metrics refer to the ZFS pool.
+ labels:
+ - name: pool
+ description: Zpool name
+ metrics:
+ - name: zfspool.pool_space_utilization
+ description: Zpool space utilization
+ unit: '%'
+ chart_type: area
+ dimensions:
+ - name: utilization
+ - name: zfspool.pool_space_usage
+ description: Zpool space usage
+ unit: 'bytes'
+ chart_type: stacked
+ dimensions:
+ - name: free
+ - name: used
+ - name: zfspool.pool_fragmentation
+ description: Zpool fragmentation
+ unit: '%'
+ chart_type: line
+ dimensions:
+ - name: fragmentation
+ - name: zfspool.pool_health_state
+ description: Zpool health state
+ unit: 'state'
+ chart_type: line
+ dimensions:
+ - name: online
+ - name: degraded
+ - name: faulted
+ - name: offline
+ - name: unavail
+ - name: removed
+ - name: suspended
diff --git a/src/go/collectors/go.d.plugin/modules/zfspool/testdata/config.json b/src/go/collectors/go.d.plugin/modules/zfspool/testdata/config.json
new file mode 100644
index 0000000000..0957131934
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/zfspool/testdata/config.json
@@ -0,0 +1,5 @@
+{
+ "update_every": 123,
+ "timeout": 123.123,
+ "binary_path": "ok"
+}
diff --git a/src/go/collectors/go.d.plugin/modules/zfspool/testdata/config.yaml b/src/go/collectors/go.d.plugin/modules/zfspool/testdata/config.yaml
new file mode 100644
index 0000000000..baf3bcd0b0
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/zfspool/testdata/config.yaml
@@ -0,0 +1,3 @@
+update_every: 123
+timeout: 123.123
+binary_path: "ok"
diff --git a/src/go/collectors/go.d.plugin/modules/zfspool/testdata/zpool-list.txt b/src/go/collectors/go.d.plugin/modules/zfspool/testdata/zpool-list.txt
new file mode 100644
index 0000000000..06d9915c22
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/zfspool/testdata/zpool-list.txt
@@ -0,0 +1,3 @@
+NAME SIZE ALLOC FREE EXPANDSZ FRAG CAP DEDUP HEALTH ALTROOT
+rpool 21367462298 9051643576 12240656794 - 33 42 1.00 ONLINE -
+zion - - - - - - - FAULTED -
diff --git a/src/go/collectors/go.d.plugin/modules/zfspool/zfspool.go b/src/go/collectors/go.d.plugin/modules/zfspool/zfspool.go
new file mode 100644
index 0000000000..eab83ef3bf
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/zfspool/zfspool.go
@@ -0,0 +1,111 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package zfspool
+
+import (
+ _ "embed"
+ "errors"
+ "time"
+
+ "github.com/netdata/netdata/go/go.d.plugin/agent/module"
+ "github.com/netdata/netdata/go/go.d.plugin/pkg/web"
+)
+
+//go:embed "config_schema.json"
+var configSchema string
+
+func init() {
+ module.Register("zfspool", module.Creator{
+ JobConfigSchema: configSchema,
+ Defaults: module.Defaults{
+ UpdateEvery: 10,
+ },
+ Create: func() module.Module { return New() },
+ })
+}
+
+func New() *ZFSPool {
+ return &ZFSPool{
+ Config: Config{
+ BinaryPath: "/usr/bin/zpool",
+ Timeout: web.Duration(time.Second * 2),
+ },
+ charts: &module.Charts{},
+ zpools: make(map[string]bool),
+ }
+}
+
+type Config struct {
+ UpdateEvery int `yaml:"update_every" json:"update_every"`
+ Timeout web.Duration `yaml:"timeout" json:"timeout"`
+ BinaryPath string `yaml:"binary_path" json:"binary_path"`
+}
+
+type (
+ ZFSPool struct {
+ module.Base
+ Config `yaml:",inline" json:""`
+
+ charts *module.Charts
+
+ exec zpoolCLI
+
+ zpools map[string]bool
+ }
+ zpoolCLI interface {
+ list() ([]byte, error)
+ }
+)
+
+func (z *ZFSPool) Configuration() any {
+ return z.Config
+}
+
+func (z *ZFSPool) Init() error {
+ if err := z.validateConfig(); err != nil {
+ z.Errorf("config validation: %s", err)
+ return err
+ }
+
+ zpoolExec, err := z.initZPoolCLIExec()
+ if err != nil {
+ z.Errorf("zpool exec initialization: %v", err)
+ return err
+ }
+ z.exec = zpoolExec
+
+ return nil
+}
+
+func (z *ZFSPool) Check() error {
+ mx, err := z.collect()
+ if err != nil {
+ z.Error(err)
+ return err
+ }
+
+ if len(mx) == 0 {
+ return errors.New("no metrics collected")
+ }
+
+ return nil
+}
+
+func (z *ZFSPool) Charts() *module.Charts {
+ return z.charts
+}
+
+func (z *ZFSPool) Collect() map[string]int64 {
+ mx, err := z.collect()
+ if err != nil {
+ z.Error(err)
+ }
+
+ if len(mx) == 0 {
+ return nil
+ }
+
+ return mx
+}
+
+func (z *ZFSPool) Cleanup() {}
diff --git a/src/go/collectors/go.d.plugin/modules/zfspool/zfspool_test.go b/src/go/collectors/go.d.plugin/modules/zfspool/zfspool_test.go
new file mode 100644
index 0000000000..7a9392a034
--- /dev/null
+++ b/src/go/collectors/go.d.plugin/modules/zfspool/zfspool_test.go
@@ -0,0 +1,248 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package zfspool
+
+import (
+ "errors"
+ "os"
+ "testing"
+
+ "github.com/netdata/netdata/go/go.d.plugin/agent/module"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var (
+ dataConfigJSON, _ = os.ReadFile("testdata/config.json")
+ dataConfigYAML, _ = os.ReadFile("testdata/config.yaml")
+
+ dataZpoolList, _ = os.ReadFile("testdata/zpool-list.txt")
+)
+
+func Test_testDataIsValid(t *testing.T) {
+ for name, data := range map[string][]byte{
+ "dataConfigJSON": dataConfigJSON,
+ "dataConfigYAML": dataConfigYAML,
+
+ "dataZpoolList": dataZpoolList,
+ } {
+ require.NotNil(t, data, name)
+
+ }
+}
+
+func TestZFSPool_Configuration(t *testing.T) {
+ module.TestConfigurationSerialize(t, &ZFSPool{}, dataConfigJSON, dataConfigYAML)
+}
+
+func TestZFSPool_Init(t *testing.T) {
+ tests := map[string]struct {
+ config Config
+ wantFail bool
+ }{
+ "fails if 'binary_path' is not set": {
+ wantFail: true,
+ config: Config{
+ BinaryPath: "",
+ },
+ },
+ "fails if failed to find binary": {
+ wantFail: true,
+ config: Config{
+ BinaryPath: "zpool!!!",
+ },
+ },
+ }
+
+ for name, test := range tests {
+ t.Run(name, func(t *testing.T) {
+ zp := New()
+ zp.Config = test.config
+
+ if test.wantFail {
+ assert.Error(t, zp.Init())
+ } else {
+ assert.NoError(t, zp.Init())
+ }
+ })
+ }
+
+}
+
+func TestZFSPool_Cleanup(t *testing.T) {
+ tests := map[string]struct {
+ prepare func() *ZFSPool
+ }{
+ "not initialized exec": {
+ prepare: func() *ZFSPool {
+ return New()
+ },
+ },
+ "after check": {
+ prepare: func() *ZFSPool {
+ zp := New()
+ zp.exec = prepareMockOK()
+ _ = zp.Check()
+ return zp
+ },
+ },
+ "after collect": {
+ prepare: func() *ZFSPool {
+ zp := New()
+ zp.exec = prepareMockOK()
+ _ = zp.Collect()
+ return zp
+ },
+ },
+ }
+
+ for name, test := range tests {
+ t.Run(name, func(t *testing.T) {
+ zp := test.prepare()
+
+ assert.NotPanics(t, zp.Cleanup)
+ })
+ }
+}
+
+func TestZFSPool_Charts(t *testing.T) {
+ assert.NotNil(t, New().Charts())
+}
+
+func TestZFSPool_Check(t *testing.T) {
+ tests := map[string]struct {
+ prepareMock func() *mockZpoolCLIExec
+ wantFail bool
+ }{
+ "success case": {
+ prepareMock: prepareMockOK,
+ wantFail: false,
+ },
+ "error on list call": {
+ prepareMock: prepareMockErrOnList,
+ wantFail: true,
+ },
+ "empty response": {
+ prepareMock: prepareMockEmptyResponse,
+ wantFail: true,
+ },
+ "unexpected response": {
+ prepareMock: prepareMockUnexpectedResponse,
+ wantFail: true,
+ },
+ }
+
+ for name, test := range tests {
+ t.Run(name, func(t *testing.T) {
+ zp := New()
+ mock := test.prepareMock()
+ zp.exec = mock
+
+ if test.wantFail {
+ assert.Error(t, zp.Check())