From 8bd256dd5632fc45d59b889e23658fb10c2062fb Mon Sep 17 00:00:00 2001 From: Patrick Hampson Date: Sun, 31 May 2026 22:44:20 -0700 Subject: [PATCH] feat(server): Add scrub stats --- README.md | 4 + collector/scrub.go | 189 +++++++++++++++++++++++++++++++++ collector/scrub_test.go | 186 +++++++++++++++++++++++++++++++++ collector/transform.go | 52 +++++++++ zfs/mock_zfs/mock_zfs.go | 15 +++ zfs/pool.go | 179 +++++++++++++++++++++++++++++++ zfs/pool_test.go | 220 +++++++++++++++++++++++++++++++++++++++ zfs/zfs.go | 43 ++++++++ 8 files changed, 888 insertions(+) create mode 100644 collector/scrub.go create mode 100644 collector/scrub_test.go create mode 100644 zfs/pool_test.go diff --git a/README.md b/README.md index 1f98a8e..3dc76e9 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,10 @@ Flags: --[no-]collector.pool Enable the pool collector (default: enabled) --properties.pool="allocated,dedupratio,fragmentation,free,freeing,health,leaked,readonly,size" Properties to include for the pool collector, comma-separated. + --[no-]collector.pool-scrub + Enable the pool-scrub collector (default: enabled) + --properties.pool-scrub="state,errors,repaired_bytes,last_completed,total_bytes,scanned_bytes,issued_bytes,rate_bytes_per_second" + Properties to include for the pool-scrub collector, comma-separated. --web.telemetry-path="/metrics" Path under which to expose metrics. --[no-]web.disable-exporter-metrics diff --git a/collector/scrub.go b/collector/scrub.go new file mode 100644 index 0000000..9361794 --- /dev/null +++ b/collector/scrub.go @@ -0,0 +1,189 @@ +package collector + +import ( + "fmt" + "log/slog" + "strconv" + "sync" + + "github.com/pdf/zfs_exporter/v2/zfs" + "github.com/prometheus/client_golang/prometheus" +) + +const ( + defaultScrubProps = `state,errors,repaired_bytes,last_completed,total_bytes,scanned_bytes,issued_bytes,rate_bytes_per_second` +) + +var ( + scrubLabels = []string{`pool`} + scrubProperties = propertyStore{ + defaultSubsystem: subsystemPool, + defaultLabels: scrubLabels, + store: map[string]property{ + `state`: newProperty( + subsystemPool, + `scrub_state`, + fmt.Sprintf("State code for the most recent scrub or resilver [%d: %s, %d: %s, %d: %s, %d: %s, %d: %s, %d: %s, %d: %s].", + scanStateNone, scanStateNone.label(), + scanStateScrubInProgress, scanStateScrubInProgress.label(), + scanStateScrubFinished, scanStateScrubFinished.label(), + scanStateScrubCanceled, scanStateScrubCanceled.label(), + scanStateScrubPaused, scanStateScrubPaused.label(), + scanStateResilverInProgress, scanStateResilverInProgress.label(), + scanStateResilverFinished, scanStateResilverFinished.label(), + ), + transformScanStateCode, + prometheus.GaugeValue, + scrubLabels..., + ), + `errors`: newProperty( + subsystemPool, + `scrub_errors`, + `Number of errors detected during the most recent scrub or resilver.`, + transformNumeric, + prometheus.GaugeValue, + scrubLabels..., + ), + `repaired_bytes`: newProperty( + subsystemPool, + `scrub_repaired_bytes`, + `Bytes repaired during the most recent scrub or resilver.`, + transformNumeric, + prometheus.GaugeValue, + scrubLabels..., + ), + `last_completed`: newProperty( + subsystemPool, + `scrub_last_completed_timestamp_seconds`, + `Unix timestamp of the most recently completed scrub or resilver, or 0 if none.`, + transformNumeric, + prometheus.GaugeValue, + scrubLabels..., + ), + `total_bytes`: newProperty( + subsystemPool, + `scrub_total_bytes`, + `Total bytes to scan in the in-progress scrub or resilver, 0 if no scan is running.`, + transformNumeric, + prometheus.GaugeValue, + scrubLabels..., + ), + `scanned_bytes`: newProperty( + subsystemPool, + `scrub_scanned_bytes`, + `Bytes read so far by the in-progress scrub or resilver, 0 if no scan is running.`, + transformNumeric, + prometheus.GaugeValue, + scrubLabels..., + ), + `issued_bytes`: newProperty( + subsystemPool, + `scrub_issued_bytes`, + `Bytes verified so far by the in-progress scrub or resilver, 0 if no scan is running.`, + transformNumeric, + prometheus.GaugeValue, + scrubLabels..., + ), + `rate_bytes_per_second`: newProperty( + subsystemPool, + `scrub_rate_bytes_per_second`, + `Current scan rate of the in-progress scrub or resilver, 0 if no scan is running.`, + transformNumeric, + prometheus.GaugeValue, + scrubLabels..., + ), + }, + } +) + +func init() { + registerCollector(`pool-scrub`, defaultEnabled, defaultScrubProps, newScrubCollector) +} + +type scrubCollector struct { + log *slog.Logger + client zfs.Client + props []string +} + +func (c *scrubCollector) describe(ch chan<- *prometheus.Desc) { + for _, k := range c.props { + prop, err := scrubProperties.find(k) + if err != nil { + c.log.Warn(propertyUnsupportedMsg, `help`, helpIssue, `collector`, `pool-scrub`, `property`, k, `err`, err) + continue + } + ch <- prop.desc + } +} + +func (c *scrubCollector) update(ch chan<- metric, pools []string, excludes regexpCollection) error { + var wg sync.WaitGroup + errChan := make(chan error, len(pools)) + for _, pool := range pools { + wg.Add(1) + go func(pool string) { + if err := c.updateScrubMetrics(ch, pool); err != nil { + errChan <- err + } + wg.Done() + }(pool) + } + wg.Wait() + + select { + case err := <-errChan: + return err + default: + return nil + } +} + +func (c *scrubCollector) updateScrubMetrics(ch chan<- metric, pool string) error { + p := c.client.Pool(pool) + scan, err := p.Scan() + if err != nil { + return err + } + + labelValues := []string{pool} + values := scrubMetricValues(scan) + + for _, k := range c.props { + prop, err := scrubProperties.find(k) + if err != nil { + c.log.Warn(propertyUnsupportedMsg, `help`, helpIssue, `collector`, `pool-scrub`, `property`, k, `err`, err) + continue + } + v, ok := values[k] + if !ok { + continue + } + if err = prop.push(ch, v, labelValues...); err != nil { + return err + } + } + + return nil +} + +func scrubMetricValues(scan zfs.PoolScan) map[string]string { + completed := `0` + if !scan.CompletedAt.IsZero() { + completed = strconv.FormatInt(scan.CompletedAt.Unix(), 10) + } + return map[string]string{ + `state`: string(scan.Function) + `:` + string(scan.State), + `errors`: strconv.FormatUint(scan.Errors, 10), + `repaired_bytes`: strconv.FormatUint(scan.Repaired, 10), + `last_completed`: completed, + `total_bytes`: strconv.FormatUint(scan.Total, 10), + `scanned_bytes`: strconv.FormatUint(scan.Scanned, 10), + `issued_bytes`: strconv.FormatUint(scan.Issued, 10), + `rate_bytes_per_second`: strconv.FormatUint(scan.Rate, 10), + } +} + +func newScrubCollector(l *slog.Logger, c zfs.Client, props []string) (Collector, error) { + return &scrubCollector{log: l, client: c, props: props}, nil +} diff --git a/collector/scrub_test.go b/collector/scrub_test.go new file mode 100644 index 0000000..2ef3603 --- /dev/null +++ b/collector/scrub_test.go @@ -0,0 +1,186 @@ +package collector + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/pdf/zfs_exporter/v2/zfs" + "github.com/pdf/zfs_exporter/v2/zfs/mock_zfs" + "go.uber.org/mock/gomock" +) + +func TestScrubMetrics(t *testing.T) { + completed := time.Date(2024, 5, 12, 2, 42, 37, 0, time.Local) + completedUnix := completed.Unix() + + testCases := []struct { + name string + pools []string + propsRequested []string + metricNames []string + scanResults map[string]zfs.PoolScan + metricResults string + }{ + { + name: `none requested`, + pools: []string{`testpool`}, + propsRequested: []string{`state`, `errors`, `repaired_bytes`, `last_completed`}, + metricNames: []string{`zfs_pool_scrub_state`, `zfs_pool_scrub_errors`, `zfs_pool_scrub_repaired_bytes`, `zfs_pool_scrub_last_completed_timestamp_seconds`}, + scanResults: map[string]zfs.PoolScan{ + `testpool`: {Function: zfs.ScanFunctionNone, State: zfs.ScanStateNone}, + }, + metricResults: `# HELP zfs_pool_scrub_errors Number of errors detected during the most recent scrub or resilver. +# TYPE zfs_pool_scrub_errors gauge +zfs_pool_scrub_errors{pool="testpool"} 0 +# HELP zfs_pool_scrub_last_completed_timestamp_seconds Unix timestamp of the most recently completed scrub or resilver, or 0 if none. +# TYPE zfs_pool_scrub_last_completed_timestamp_seconds gauge +zfs_pool_scrub_last_completed_timestamp_seconds{pool="testpool"} 0 +# HELP zfs_pool_scrub_repaired_bytes Bytes repaired during the most recent scrub or resilver. +# TYPE zfs_pool_scrub_repaired_bytes gauge +zfs_pool_scrub_repaired_bytes{pool="testpool"} 0 +# HELP zfs_pool_scrub_state State code for the most recent scrub or resilver [0: none, 1: scrub_in_progress, 2: scrub_finished, 3: scrub_canceled, 4: scrub_paused, 5: resilver_in_progress, 6: resilver_finished]. +# TYPE zfs_pool_scrub_state gauge +zfs_pool_scrub_state{pool="testpool"} 0 +`, + }, + { + name: `scrub finished`, + pools: []string{`testpool`}, + propsRequested: []string{`state`, `errors`, `repaired_bytes`, `last_completed`}, + metricNames: []string{`zfs_pool_scrub_state`, `zfs_pool_scrub_errors`, `zfs_pool_scrub_repaired_bytes`, `zfs_pool_scrub_last_completed_timestamp_seconds`}, + scanResults: map[string]zfs.PoolScan{ + `testpool`: { + Function: zfs.ScanFunctionScrub, + State: zfs.ScanStateFinished, + Errors: 3, + Repaired: 4096, + CompletedAt: completed, + }, + }, + metricResults: completedScrubMetricResults(`testpool`, 2, 3, 4096, completedUnix), + }, + { + name: `scrub in progress`, + pools: []string{`testpool`}, + propsRequested: []string{`state`}, + metricNames: []string{`zfs_pool_scrub_state`}, + scanResults: map[string]zfs.PoolScan{ + `testpool`: {Function: zfs.ScanFunctionScrub, State: zfs.ScanStateInProgress}, + }, + metricResults: `# HELP zfs_pool_scrub_state State code for the most recent scrub or resilver [0: none, 1: scrub_in_progress, 2: scrub_finished, 3: scrub_canceled, 4: scrub_paused, 5: resilver_in_progress, 6: resilver_finished]. +# TYPE zfs_pool_scrub_state gauge +zfs_pool_scrub_state{pool="testpool"} 1 +`, + }, + { + name: `resilver in progress`, + pools: []string{`testpool`}, + propsRequested: []string{`state`}, + metricNames: []string{`zfs_pool_scrub_state`}, + scanResults: map[string]zfs.PoolScan{ + `testpool`: {Function: zfs.ScanFunctionResilver, State: zfs.ScanStateInProgress}, + }, + metricResults: `# HELP zfs_pool_scrub_state State code for the most recent scrub or resilver [0: none, 1: scrub_in_progress, 2: scrub_finished, 3: scrub_canceled, 4: scrub_paused, 5: resilver_in_progress, 6: resilver_finished]. +# TYPE zfs_pool_scrub_state gauge +zfs_pool_scrub_state{pool="testpool"} 5 +`, + }, + { + name: `multiple pools`, + pools: []string{`testpool1`, `testpool2`}, + propsRequested: []string{`state`}, + metricNames: []string{`zfs_pool_scrub_state`}, + scanResults: map[string]zfs.PoolScan{ + `testpool1`: {Function: zfs.ScanFunctionScrub, State: zfs.ScanStateInProgress}, + `testpool2`: {Function: zfs.ScanFunctionScrub, State: zfs.ScanStatePaused}, + }, + metricResults: `# HELP zfs_pool_scrub_state State code for the most recent scrub or resilver [0: none, 1: scrub_in_progress, 2: scrub_finished, 3: scrub_canceled, 4: scrub_paused, 5: resilver_in_progress, 6: resilver_finished]. +# TYPE zfs_pool_scrub_state gauge +zfs_pool_scrub_state{pool="testpool1"} 1 +zfs_pool_scrub_state{pool="testpool2"} 4 +`, + }, + { + name: `scrub in progress with metrics`, + pools: []string{`testpool`}, + propsRequested: []string{`total_bytes`, `scanned_bytes`, `issued_bytes`, `rate_bytes_per_second`}, + metricNames: []string{`zfs_pool_scrub_total_bytes`, `zfs_pool_scrub_scanned_bytes`, `zfs_pool_scrub_issued_bytes`, `zfs_pool_scrub_rate_bytes_per_second`}, + scanResults: map[string]zfs.PoolScan{ + `testpool`: { + Function: zfs.ScanFunctionScrub, + State: zfs.ScanStateInProgress, + Total: 33200000000000, + Scanned: 11428000000000, + Issued: 7600000000000, + Rate: 864000000, + }, + }, + metricResults: `# HELP zfs_pool_scrub_issued_bytes Bytes verified so far by the in-progress scrub or resilver, 0 if no scan is running. +# TYPE zfs_pool_scrub_issued_bytes gauge +zfs_pool_scrub_issued_bytes{pool="testpool"} 7.6e+12 +# HELP zfs_pool_scrub_rate_bytes_per_second Current scan rate of the in-progress scrub or resilver, 0 if no scan is running. +# TYPE zfs_pool_scrub_rate_bytes_per_second gauge +zfs_pool_scrub_rate_bytes_per_second{pool="testpool"} 8.64e+08 +# HELP zfs_pool_scrub_scanned_bytes Bytes read so far by the in-progress scrub or resilver, 0 if no scan is running. +# TYPE zfs_pool_scrub_scanned_bytes gauge +zfs_pool_scrub_scanned_bytes{pool="testpool"} 1.1428e+13 +# HELP zfs_pool_scrub_total_bytes Total bytes to scan in the in-progress scrub or resilver, 0 if no scan is running. +# TYPE zfs_pool_scrub_total_bytes gauge +zfs_pool_scrub_total_bytes{pool="testpool"} 3.32e+13 +`, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctrl, ctx := gomock.WithContext(context.Background(), t) + zfsClient := mock_zfs.NewMockClient(ctrl) + config := defaultConfig(zfsClient) + + zfsClient.EXPECT().PoolNames().Return(tc.pools, nil).Times(1) + for _, pool := range tc.pools { + zfsPool := mock_zfs.NewMockPool(ctrl) + zfsPool.EXPECT().Scan().Return(tc.scanResults[pool], nil).Times(1) + zfsClient.EXPECT().Pool(pool).Return(zfsPool).Times(1) + } + + collector, err := NewZFS(config) + if err != nil { + t.Fatal(err) + } + collector.Collectors = map[string]State{ + `pool-scrub`: { + Name: "pool-scrub", + Enabled: boolPointer(true), + Properties: stringPointer(strings.Join(tc.propsRequested, `,`)), + factory: newScrubCollector, + }, + } + + if err = callCollector(ctx, collector, []byte(tc.metricResults), tc.metricNames); err != nil { + t.Fatal(err) + } + }) + } +} + +func completedScrubMetricResults(pool string, state, errors, repaired int, completedUnix int64) string { + return fmt.Sprintf(`# HELP zfs_pool_scrub_errors Number of errors detected during the most recent scrub or resilver. +# TYPE zfs_pool_scrub_errors gauge +zfs_pool_scrub_errors{pool=%q} %d +# HELP zfs_pool_scrub_last_completed_timestamp_seconds Unix timestamp of the most recently completed scrub or resilver, or 0 if none. +# TYPE zfs_pool_scrub_last_completed_timestamp_seconds gauge +zfs_pool_scrub_last_completed_timestamp_seconds{pool=%q} %d +# HELP zfs_pool_scrub_repaired_bytes Bytes repaired during the most recent scrub or resilver. +# TYPE zfs_pool_scrub_repaired_bytes gauge +zfs_pool_scrub_repaired_bytes{pool=%q} %d +# HELP zfs_pool_scrub_state State code for the most recent scrub or resilver [0: none, 1: scrub_in_progress, 2: scrub_finished, 3: scrub_canceled, 4: scrub_paused, 5: resilver_in_progress, 6: resilver_finished]. +# TYPE zfs_pool_scrub_state gauge +zfs_pool_scrub_state{pool=%q} %d +`, pool, errors, pool, completedUnix, pool, repaired, pool, state) +} diff --git a/collector/transform.go b/collector/transform.go index c76c20e..2fcd928 100644 --- a/collector/transform.go +++ b/collector/transform.go @@ -19,6 +19,38 @@ const ( poolSuspended ) +type poolScanStateCode int + +const ( + scanStateNone poolScanStateCode = iota + scanStateScrubInProgress + scanStateScrubFinished + scanStateScrubCanceled + scanStateScrubPaused + scanStateResilverInProgress + scanStateResilverFinished +) + +func (c poolScanStateCode) label() string { + switch c { + case scanStateNone: + return `none` + case scanStateScrubInProgress: + return `scrub_in_progress` + case scanStateScrubFinished: + return `scrub_finished` + case scanStateScrubCanceled: + return `scrub_canceled` + case scanStateScrubPaused: + return `scrub_paused` + case scanStateResilverInProgress: + return `resilver_in_progress` + case scanStateResilverFinished: + return `resilver_finished` + } + return `` +} + func transformNumeric(value string) (float64, error) { if value == `-` || value == `none` { return 0, nil @@ -50,6 +82,26 @@ func transformHealthCode(status string) (float64, error) { return float64(result), nil } +func transformScanStateCode(value string) (float64, error) { + switch value { + case `none:none`: + return float64(scanStateNone), nil + case `scrub:in_progress`: + return float64(scanStateScrubInProgress), nil + case `scrub:finished`: + return float64(scanStateScrubFinished), nil + case `scrub:canceled`: + return float64(scanStateScrubCanceled), nil + case `scrub:paused`: + return float64(scanStateScrubPaused), nil + case `resilver:in_progress`: + return float64(scanStateResilverInProgress), nil + case `resilver:finished`: + return float64(scanStateResilverFinished), nil + } + return -1, fmt.Errorf(`unknown scan state: %s`, value) +} + func transformBool(value string) (float64, error) { switch value { case `on`, `yes`, `enabled`, `active`: diff --git a/zfs/mock_zfs/mock_zfs.go b/zfs/mock_zfs/mock_zfs.go index ba76ebe..e695802 100644 --- a/zfs/mock_zfs/mock_zfs.go +++ b/zfs/mock_zfs/mock_zfs.go @@ -140,6 +140,21 @@ func (mr *MockPoolMockRecorder) Properties(props ...any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Properties", reflect.TypeOf((*MockPool)(nil).Properties), props...) } +// Scan mocks base method. +func (m *MockPool) Scan() (zfs.PoolScan, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Scan") + ret0, _ := ret[0].(zfs.PoolScan) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Scan indicates an expected call of Scan. +func (mr *MockPoolMockRecorder) Scan() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Scan", reflect.TypeOf((*MockPool)(nil).Scan)) +} + // MockPoolProperties is a mock of PoolProperties interface. type MockPoolProperties struct { ctrl *gomock.Controller diff --git a/zfs/pool.go b/zfs/pool.go index e7087ee..149ae23 100644 --- a/zfs/pool.go +++ b/zfs/pool.go @@ -2,10 +2,14 @@ package zfs import ( "bufio" + "bytes" "fmt" "io" "os/exec" + "regexp" + "strconv" "strings" + "time" ) // PoolStatus enum contains status text @@ -44,6 +48,34 @@ func (p poolImpl) Properties(props ...string) (PoolProperties, error) { return handler, nil } +func (p poolImpl) Scan() (PoolScan, error) { + cmd := exec.Command(`zpool`, `status`, `-p`, p.name) + stdout, err := cmd.StdoutPipe() + if err != nil { + return PoolScan{}, err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return PoolScan{}, err + } + + if err = cmd.Start(); err != nil { + return PoolScan{}, fmt.Errorf("failed to start command '%s': %w", cmd.String(), err) + } + + out, err := io.ReadAll(stdout) + if err != nil { + return PoolScan{}, err + } + stde, _ := io.ReadAll(stderr) + + if err = cmd.Wait(); err != nil { + return PoolScan{}, fmt.Errorf("failed to execute command '%s'; output: '%s' (%w)", cmd.String(), strings.TrimSpace(string(stde)), err) + } + + return parsePoolScan(out) +} + type poolPropertiesImpl struct { properties map[string]string } @@ -103,3 +135,150 @@ func newPoolPropertiesImpl() *poolPropertiesImpl { properties: make(map[string]string), } } + +var ( + scanCompletedRe = regexp.MustCompile(`^(scrub repaired|resilvered) (\d+)B? in .+ with (\d+) errors on (.+)$`) + scanProgressPartRe = regexp.MustCompile(`^(\S+)\s*/\s*(\S+)\s+(scanned|issued)\s+at\s+(\S+?)/s$`) +) + +func parsePoolScan(out []byte) (PoolScan, error) { + scan := PoolScan{Function: ScanFunctionNone, State: ScanStateNone} + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + raw := scanner.Text() + line := strings.TrimSpace(raw) + if !strings.HasPrefix(line, `scan:`) { + continue + } + rest := strings.TrimSpace(strings.TrimPrefix(line, `scan:`)) + parsed, err := parseScanLine(rest) + if err != nil { + return PoolScan{}, err + } + scan = parsed + if scan.State != ScanStateInProgress { + return scan, nil + } + for scanner.Scan() { + cont := scanner.Text() + if !strings.HasPrefix(cont, "\t") { + break + } + parseScanProgressLine(strings.TrimSpace(cont), &scan) + } + return scan, nil + } + if err := scanner.Err(); err != nil { + return PoolScan{}, err + } + return scan, nil +} + +func parseScanLine(rest string) (PoolScan, error) { + scan := PoolScan{Function: ScanFunctionNone, State: ScanStateNone} + switch { + case rest == `none requested`: + return scan, nil + case strings.HasPrefix(rest, `scrub in progress`): + scan.Function = ScanFunctionScrub + scan.State = ScanStateInProgress + case strings.HasPrefix(rest, `scrub paused since`): + scan.Function = ScanFunctionScrub + scan.State = ScanStatePaused + case strings.HasPrefix(rest, `scrub canceled on`): + scan.Function = ScanFunctionScrub + scan.State = ScanStateCanceled + case strings.HasPrefix(rest, `resilver in progress`): + scan.Function = ScanFunctionResilver + scan.State = ScanStateInProgress + default: + m := scanCompletedRe.FindStringSubmatch(rest) + if m == nil { + return PoolScan{}, fmt.Errorf("%w: unrecognized scan line: %q", ErrInvalidOutput, rest) + } + if m[1] == `scrub repaired` { + scan.Function = ScanFunctionScrub + } else { + scan.Function = ScanFunctionResilver + } + scan.State = ScanStateFinished + repaired, err := strconv.ParseUint(m[2], 10, 64) + if err != nil { + return PoolScan{}, fmt.Errorf("%w: invalid repaired bytes %q: %w", ErrInvalidOutput, m[2], err) + } + scan.Repaired = repaired + errs, err := strconv.ParseUint(m[3], 10, 64) + if err != nil { + return PoolScan{}, fmt.Errorf("%w: invalid error count %q: %w", ErrInvalidOutput, m[3], err) + } + scan.Errors = errs + t, err := time.ParseInLocation(time.ANSIC, strings.TrimSpace(m[4]), time.Local) + if err != nil { + return PoolScan{}, fmt.Errorf("%w: invalid completion timestamp %q: %w", ErrInvalidOutput, m[4], err) + } + scan.CompletedAt = t + } + return scan, nil +} + +func parseScanProgressLine(line string, scan *PoolScan) { + for _, part := range strings.Split(line, `, `) { + part = strings.TrimSpace(part) + if m := scanProgressPartRe.FindStringSubmatch(part); m != nil { + done, derr := parseScanBytes(m[1]) + total, terr := parseScanBytes(m[2]) + rate, rerr := parseScanBytes(m[4]) + if derr != nil || terr != nil || rerr != nil { + continue + } + if m[3] == `scanned` { + scan.Scanned = done + scan.Total = total + scan.Rate = rate + } else { + scan.Issued = done + if scan.Total == 0 { + scan.Total = total + } + } + continue + } + if strings.HasSuffix(part, ` repaired`) { + if v, err := parseScanBytes(strings.TrimSuffix(part, ` repaired`)); err == nil { + scan.Repaired = v + } + } + } +} + +func parseScanBytes(s string) (uint64, error) { + s = strings.TrimSpace(s) + if s == `` { + return 0, fmt.Errorf("%w: empty byte value", ErrInvalidOutput) + } + s = strings.TrimSuffix(s, `B`) + if s == `` { + return 0, nil + } + var multiplier uint64 = 1 + switch s[len(s)-1] { + case 'K', 'k': + multiplier = 1 << 10 + case 'M', 'm': + multiplier = 1 << 20 + case 'G', 'g': + multiplier = 1 << 30 + case 'T', 't': + multiplier = 1 << 40 + case 'P', 'p': + multiplier = 1 << 50 + } + if multiplier == 1 { + return strconv.ParseUint(s, 10, 64) + } + f, err := strconv.ParseFloat(s[:len(s)-1], 64) + if err != nil { + return 0, err + } + return uint64(f * float64(multiplier)), nil +} diff --git a/zfs/pool_test.go b/zfs/pool_test.go new file mode 100644 index 0000000..381a136 --- /dev/null +++ b/zfs/pool_test.go @@ -0,0 +1,220 @@ +package zfs + +import ( + "testing" + "time" +) + +var ( + mib = float64(uint64(1) << 20) + gib = float64(uint64(1) << 30) + tib = float64(uint64(1) << 40) + pib = float64(uint64(1) << 50) +) + +func TestParsePoolScan(t *testing.T) { + testCases := []struct { + name string + output string + expected PoolScan + }{ + { + name: `none requested`, + output: ` pool: tank + state: ONLINE + scan: none requested +config: +`, + expected: PoolScan{Function: ScanFunctionNone, State: ScanStateNone}, + }, + { + name: `scrub in progress`, + output: ` pool: tank + state: ONLINE + scan: scrub in progress since Sun May 12 00:24:01 2024 +config: +`, + expected: PoolScan{Function: ScanFunctionScrub, State: ScanStateInProgress}, + }, + { + name: `scrub in progress with progress lines`, + output: " pool: vault\n" + + " state: ONLINE\n" + + " scan: scrub in progress since Mon Jun 1 02:00:02 2026\n" + + "\t10.4T / 30.2T scanned at 864M/s, 7.60T / 30.2T issued at 635M/s\n" + + "\t0B repaired, 25.14% done, 10:23:16 to go\n" + + "config:\n", + expected: PoolScan{ + Function: ScanFunctionScrub, + State: ScanStateInProgress, + Total: uint64(30.2 * tib), + Scanned: uint64(10.4 * tib), + Issued: uint64(7.60 * tib), + Rate: uint64(864 * mib), + Repaired: 0, + }, + }, + { + name: `scrub in progress with parsable byte values`, + output: " pool: tank\n" + + " scan: scrub in progress since Mon Jun 1 02:00:02 2026\n" + + "\t11428000000000 / 33200000000000 scanned at 864000000/s, 7600000000000 / 33200000000000 issued at 635000000/s\n" + + "\t4096 repaired, 25.14% done, 10:23:16 to go\n", + expected: PoolScan{ + Function: ScanFunctionScrub, + State: ScanStateInProgress, + Total: 33200000000000, + Scanned: 11428000000000, + Issued: 7600000000000, + Rate: 864000000, + Repaired: 4096, + }, + }, + { + name: `scrub paused`, + output: ` pool: tank + state: ONLINE + scan: scrub paused since Sun May 12 00:24:01 2024 +config: +`, + expected: PoolScan{Function: ScanFunctionScrub, State: ScanStatePaused}, + }, + { + name: `scrub canceled`, + output: ` pool: tank + state: ONLINE + scan: scrub canceled on Sun May 12 00:24:01 2024 +config: +`, + expected: PoolScan{Function: ScanFunctionScrub, State: ScanStateCanceled}, + }, + { + name: `scrub finished`, + output: ` pool: tank + state: ONLINE + scan: scrub repaired 4096 in 02:18:36 with 3 errors on Sun May 12 02:42:37 2024 +config: +`, + expected: PoolScan{ + Function: ScanFunctionScrub, + State: ScanStateFinished, + Errors: 3, + Repaired: 4096, + CompletedAt: time.Date(2024, 5, 12, 2, 42, 37, 0, time.Local), + }, + }, + { + name: `scrub finished with B suffix`, + output: ` pool: tank + state: ONLINE + scan: scrub repaired 0B in 02:18:36 with 0 errors on Sun May 12 02:42:37 2024 +`, + expected: PoolScan{ + Function: ScanFunctionScrub, + State: ScanStateFinished, + Errors: 0, + Repaired: 0, + CompletedAt: time.Date(2024, 5, 12, 2, 42, 37, 0, time.Local), + }, + }, + { + name: `resilver in progress`, + output: ` pool: tank + state: DEGRADED + scan: resilver in progress since Sun May 12 00:24:01 2024 +`, + expected: PoolScan{Function: ScanFunctionResilver, State: ScanStateInProgress}, + }, + { + name: `resilver finished`, + output: ` pool: tank + state: ONLINE + scan: resilvered 1024 in 01:00:00 with 0 errors on Sun May 12 02:42:37 2024 +`, + expected: PoolScan{ + Function: ScanFunctionResilver, + State: ScanStateFinished, + Errors: 0, + Repaired: 1024, + CompletedAt: time.Date(2024, 5, 12, 2, 42, 37, 0, time.Local), + }, + }, + { + name: `missing scan line`, + output: " pool: tank\n state: ONLINE\nconfig:\n", + expected: PoolScan{Function: ScanFunctionNone, State: ScanStateNone}, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := parsePoolScan([]byte(tc.output)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Function != tc.expected.Function { + t.Errorf("Function: got %q, want %q", got.Function, tc.expected.Function) + } + if got.State != tc.expected.State { + t.Errorf("State: got %q, want %q", got.State, tc.expected.State) + } + if got.Errors != tc.expected.Errors { + t.Errorf("Errors: got %d, want %d", got.Errors, tc.expected.Errors) + } + if got.Repaired != tc.expected.Repaired { + t.Errorf("Repaired: got %d, want %d", got.Repaired, tc.expected.Repaired) + } + if !got.CompletedAt.Equal(tc.expected.CompletedAt) { + t.Errorf("CompletedAt: got %s, want %s", got.CompletedAt, tc.expected.CompletedAt) + } + if got.Total != tc.expected.Total { + t.Errorf("Total: got %d, want %d", got.Total, tc.expected.Total) + } + if got.Scanned != tc.expected.Scanned { + t.Errorf("Scanned: got %d, want %d", got.Scanned, tc.expected.Scanned) + } + if got.Issued != tc.expected.Issued { + t.Errorf("Issued: got %d, want %d", got.Issued, tc.expected.Issued) + } + if got.Rate != tc.expected.Rate { + t.Errorf("Rate: got %d, want %d", got.Rate, tc.expected.Rate) + } + }) + } +} + +func TestParseScanBytes(t *testing.T) { + testCases := []struct { + in string + want uint64 + }{ + {`0`, 0}, + {`0B`, 0}, + {`1024`, 1024}, + {`10K`, 10 << 10}, + {`10.4M`, uint64(10.4 * mib)}, + {`7.60G`, uint64(7.60 * gib)}, + {`30.2T`, uint64(30.2 * tib)}, + {`1.5P`, uint64(1.5 * pib)}, + {`11428000000000`, 11428000000000}, + } + for _, tc := range testCases { + got, err := parseScanBytes(tc.in) + if err != nil { + t.Errorf("parseScanBytes(%q) error: %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("parseScanBytes(%q) = %d, want %d", tc.in, got, tc.want) + } + } +} + +func TestParsePoolScanInvalid(t *testing.T) { + output := " pool: tank\n scan: scrub repaired bogus stuff\n" + if _, err := parsePoolScan([]byte(output)); err == nil { + t.Fatal("expected error parsing invalid scan line") + } +} diff --git a/zfs/zfs.go b/zfs/zfs.go index 5917a45..2e2af71 100644 --- a/zfs/zfs.go +++ b/zfs/zfs.go @@ -9,6 +9,7 @@ import ( "io" "os/exec" "strings" + "time" ) // ErrInvalidOutput is returned on unparseable CLI output @@ -25,6 +26,7 @@ type Client interface { type Pool interface { Name() string Properties(props ...string) (PoolProperties, error) + Scan() (PoolScan, error) } // PoolProperties provides access to the properties for a pool @@ -32,6 +34,47 @@ type PoolProperties interface { Properties() map[string]string } +// ScanFunction enum contains scan function text +type ScanFunction string + +const ( + // ScanFunctionNone enum entry + ScanFunctionNone ScanFunction = `none` + // ScanFunctionScrub enum entry + ScanFunctionScrub ScanFunction = `scrub` + // ScanFunctionResilver enum entry + ScanFunctionResilver ScanFunction = `resilver` +) + +// ScanState enum contains scan state text +type ScanState string + +const ( + // ScanStateNone enum entry + ScanStateNone ScanState = `none` + // ScanStateInProgress enum entry + ScanStateInProgress ScanState = `in_progress` + // ScanStateFinished enum entry + ScanStateFinished ScanState = `finished` + // ScanStateCanceled enum entry + ScanStateCanceled ScanState = `canceled` + // ScanStatePaused enum entry + ScanStatePaused ScanState = `paused` +) + +// PoolScan describes the most recent scrub or resilver for a pool +type PoolScan struct { + Function ScanFunction + State ScanState + Errors uint64 + Repaired uint64 + CompletedAt time.Time + Total uint64 + Scanned uint64 + Issued uint64 + Rate uint64 +} + // Datasets allows querying properties for datasets in a pool type Datasets interface { Pool() string