Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to CasaOS LocalStorage are documented here.

### Added

- **ZFS pools, datasets and snapshots, read only** (`GET /v1/disks/zfs`, `/v1/disks/zfs/snapshots?dataset=`, `/v1/disks/zfs/io`). Pools (size, allocated, free, fragmentation, health), the state of each (`zpool status`: scan, errors, tree of disks), datasets, the snapshots of one dataset (newest 200), and the I/O counters of each pool read from the kernel. Only `zpool list`, `zpool status`, `zfs list` run, without a shell, in their scripted forms (`-H -p`), with time-outs, one at a time: a pool that stopped answering cannot pile processes up. A box without ZFS answers `available: false`. Nothing here changes a pool.
- **`CASAOS_LSBLK_FILE`** (development): when set, `ExecLSBLK` returns that file instead of running host `lsblk`. `ExecLSBLKByPath` filters the same document.
- **`CASAOS_FAKE_STORAGE=1`** (development): `mount`, `umount`, `parted`, `partprobe`, `e2label` and `sfdisk` return success without exec, only when `CASAOS_LSBLK_FILE` is set too. `mdadm`, `mkfs` and `wipefs` still exec. Production is unchanged when the variables are unset.

Expand Down
3 changes: 3 additions & 0 deletions route/v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ func InitV1Router() http.Handler {
v1DisksGroup.DELETE("/snapraid", v1.DeleteSnapRAID)
v1DisksGroup.DELETE("/snapraid/job", v1.DeleteSnapRAIDJob)
v1DisksGroup.POST("/snapraid/:action", v1.PostSnapRAIDAction)
v1DisksGroup.GET("/zfs", v1.GetZFS)
v1DisksGroup.GET("/zfs/snapshots", v1.GetZFSSnapshots)
v1DisksGroup.GET("/zfs/io", v1.GetZFSIO)
}

v1StorageGroup := v1Group.Group("/storage")
Expand Down
43 changes: 43 additions & 0 deletions route/v1/zfs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package v1

import (
"errors"
"net/http"

"github.com/ReCasaOS/CasaOS-Common/model"
"github.com/ReCasaOS/CasaOS-Common/utils/common_err"
"github.com/ReCasaOS/CasaOS-LocalStorage/service"
"github.com/labstack/echo/v4"
)

// zfsKstat is where the kernel keeps a folder of counters for each pool, a var for the tests.
var zfsKstat = "/proc/spl/kstat/zfs"

// GetZFS is the ZFS pools and datasets, read only. A box without ZFS answers 200 with available false and the reason, so that the dashboard keeps quiet.
func GetZFS(ctx echo.Context) error {
return ctx.JSON(common_err.SUCCESS, model.Result{Success: common_err.SUCCESS, Message: common_err.GetMsg(common_err.SUCCESS), Data: service.ZFSOverview()})
}

// GetZFSSnapshots lists the snapshots of the dataset in ?dataset=, the newest first. A dataset the box does not list is 404 and nothing is run for it.
func GetZFSSnapshots(ctx echo.Context) error {
list, err := service.ZFSSnapshotsOf(ctx.QueryParam("dataset"))
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, service.ErrZFSNoDataset):
status = http.StatusNotFound
case errors.Is(err, service.ErrZFSUnavailable):
status = http.StatusConflict
case errors.Is(err, service.ErrZFSBusy):
status = http.StatusServiceUnavailable
}
return ctx.JSON(status, model.Result{Success: common_err.SERVICE_ERROR, Message: err.Error(), Data: err.Error()})
}
return ctx.JSON(common_err.SUCCESS, model.Result{Success: common_err.SUCCESS, Message: common_err.GetMsg(common_err.SUCCESS), Data: list})
}

// GetZFSIO is the counters of every pool (bytes and operations since it was imported), read from the kernel without running anything: the rate is the
// difference between two readings.
func GetZFSIO(ctx echo.Context) error {
return ctx.JSON(common_err.SUCCESS, model.Result{Success: common_err.SUCCESS, Message: common_err.GetMsg(common_err.SUCCESS), Data: service.ZFSIO(zfsKstat)})
}
67 changes: 67 additions & 0 deletions route/v1/zfs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package v1

import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"

"github.com/labstack/echo/v4"
)

func zfsCall(t *testing.T, handler echo.HandlerFunc, target string) (int, map[string]interface{}) {
t.Helper()
rec := httptest.NewRecorder()
c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, target, nil), rec)
if err := handler(c); err != nil {
t.Fatal(err)
}
var answer map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &answer); err != nil {
t.Fatalf("%s: %v", rec.Body.String(), err)
}
return rec.Code, answer
}

// On the machine the tests run on there is no ZFS: the answer is a quiet 200, not an error.
func TestGetZFSWithoutZFS(t *testing.T) {
code, answer := zfsCall(t, GetZFS, "/v1/disks/zfs")
data, _ := answer["data"].(map[string]interface{})
if code != http.StatusOK || data["available"] != false || data["reason"] == "" {
t.Fatalf("%d %v", code, answer)
}
if pools, ok := data["pools"].([]interface{}); !ok || len(pools) != 0 {
t.Fatalf("pools must be an empty list, not null: %v", data["pools"])
}
}

func TestGetZFSSnapshotsOfADatasetThatIsNotThere(t *testing.T) {
code, _ := zfsCall(t, GetZFSSnapshots, "/v1/disks/zfs/snapshots?dataset=-r")
if code != http.StatusConflict && code != http.StatusNotFound {
t.Fatalf("%d", code)
}
}

func TestGetZFSIO(t *testing.T) {
dir := t.TempDir()
saved := zfsKstat
zfsKstat = dir
t.Cleanup(func() { zfsKstat = saved })
if err := os.MkdirAll(filepath.Join(dir, "tank"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "tank", "io"), []byte("1 1 0x01 4 1 1 1\nnread nwritten reads writes\n10 20 3 4\n"), 0o644); err != nil {
t.Fatal(err)
}
code, answer := zfsCall(t, GetZFSIO, "/v1/disks/zfs/io")
list, _ := answer["data"].([]interface{})
if code != http.StatusOK || len(list) != 1 {
t.Fatalf("%d %v", code, answer)
}
pool := list[0].(map[string]interface{})
if pool["pool"] != "tank" || pool["read_bytes"] != float64(10) || pool["write_ops"] != float64(4) {
t.Fatalf("%v", pool)
}
}
11 changes: 11 additions & 0 deletions service/testdata/zfs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# ZFS fixtures

The outputs of `zpool` and `zfs` the parsers in `service/zfs.go` are tested on.

- `real_*`: **captured by a user on a real box** (se3, ReCasaOS/CasaOS discussion 66, 2026-11-09: OpenZFS 2.4.1, Ubuntu 26.04):
`zpool list -H -p`, `zfs list -H -p` and the text of `zpool status -p -P` of a healthy mirror whose pool does not have all
features enabled. In the pasted `zpool status` the indentation of the tree was lost by the forum, so `real_zpool_status_features.txt`
has the words and numbers as pasted with the tab and two-space indentation `zpool` prints put back.
- The other files are **written from the OpenZFS documentation and man pages**, not captured: a degraded raidz, scrubs and
resilvers in progress or canceled, logs/cache/spares, a volume, a locked dataset, no pool, no dataset, kstat counters.
Replace them with real captures when there are some (OpenZFS 2.1, 2.2, 2.3), and take this note out then.
3 changes: 3 additions & 0 deletions service/testdata/zfs/kstat_io.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
23 1 0x01 4 1088 21204339 64847143498
nread nwritten reads writes wtime wlentime wupdate rtime rlentime rupdate wcnt rcnt
9990000000 7020000000 1320000 1740000 0 0 0 0 0 0 0 0
4 changes: 4 additions & 0 deletions service/testdata/zfs/real_zfs_list.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
pool1 filesystem 1523315021414 607096213504 106496 65536 /pool1 yes 0 -
pool1/appdata filesystem 303757225984 407809804698 302870736896 886489088 /pool1/data1 yes 0 -
pool1/bitcoin-data filesystem 1018895228928 407809804698 1018895228928 0 /pool1/bitcoin-data yes 0 -
pool1/immich-db filesystem 1063133184 407809804698 1056239616 6893568 /pool1/immich-db yes 0 -
1 change: 1 addition & 0 deletions service/testdata/zfs/real_zpool_list.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pool1 1992864825344 1324916645888 667948179456 64 66 ONLINE
17 changes: 17 additions & 0 deletions service/testdata/zfs/real_zpool_status_features.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
pool: pool1
state: ONLINE
status: Some supported and requested features are not enabled on the pool.
The pool can still be used, but some features are unavailable.
action: Enable all features using 'zpool upgrade'. Once this is done,
the pool may no longer be accessible by software that does not support
the features. See zpool-features(7) for details.
scan: scrub repaired 0B in 01:21:07 with 0 errors on Sun Nov 8 01:45:08 2026
config:

NAME STATE READ WRITE CKSUM
pool1 ONLINE 0 0 0
mirror-0 ONLINE 0 0 0
/dev/disk/by-id/nvme-WD_BLACK_SN770_2TB_24443A806391_1-part1 ONLINE 0 0 0
/dev/disk/by-id/nvme-WD_BLACK_SN770_2TB_24443A805750_1-part1 ONLINE 0 0 0

errors: No known data errors
6 changes: 6 additions & 0 deletions service/testdata/zfs/zfs_list_datasets.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
pool1 filesystem 1264462766080 871000000000 98304 0 /pool1 yes 0 -
pool1/bitcoin-data filesystem 759000000000 671000000000 759000000000 0 /pool1/bitcoin-data yes 0 -
pool1/appdata filesystem 300647710720 671000000000 300647710720 31457280 /pool1/appdata yes 0 -
pool1/immich-db filesystem 1057000000 671000000000 1055000000 655360 /pool1/immich-db yes 0 -
pool1/vm-disk volume 107374182400 770000000000 12000000000 0 - - 0 -
pool1/secret filesystem 98304 671000000000 98304 0 /pool1/secret no 1073741824 unavailable
1 change: 1 addition & 0 deletions service/testdata/zfs/zfs_list_none.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
no datasets available
1 change: 1 addition & 0 deletions service/testdata/zfs/zfs_list_root_only.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pool1 filesystem 1264462766080 871000000000 98304 0 /pool1 yes 0 -
2 changes: 2 additions & 0 deletions service/testdata/zfs/zfs_list_snapshots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pool1/appdata@backup_20261105_023008 12373196 299000000000 17000000 1762309808
pool1/appdata@backup_20261105_013917 20447232 299000000000 299000000000 1762306757
1 change: 1 addition & 0 deletions service/testdata/zfs/zpool_list_none.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
no pools available
1 change: 1 addition & 0 deletions service/testdata/zfs/zpool_list_one.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pool1 1992864825344 1060842668032 932022157312 29 53 ONLINE
2 changes: 2 additions & 0 deletions service/testdata/zfs/zpool_list_two.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pool1 1992864825344 1060842668032 932022157312 29 53 ONLINE
backup 3985729650688 3500000000000 485729650688 - 87 DEGRADED
19 changes: 19 additions & 0 deletions service/testdata/zfs/zpool_status_degraded_raidz.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
pool: backup
state: DEGRADED
status: One or more devices could not be opened. Sufficient replicas exist for
the pool to continue functioning in a degraded state.
action: Attach the missing device and online it using 'zpool online'.
see: https://openzfs.github.io/openzfs-docs/msg/ZFS-8000-2Q
scan: resilver in progress since Sun Oct 11 03:10:00 2026
1.20T scanned at 1.1G/s, 800G issued at 700M/s, 1.80T total
300G resilvered, 43.20% done, 00:20:11 to go
config:

NAME STATE READ WRITE CKSUM
backup DEGRADED 0 0 0
raidz1-0 DEGRADED 0 0 0
/dev/sda1 ONLINE 0 0 0
/dev/sdb1 ONLINE 0 2 0
11485761428236503841 UNAVAIL 0 0 0 was /dev/sdc1

errors: 2 data errors, use '-v' for a list
21 changes: 21 additions & 0 deletions service/testdata/zfs/zpool_status_groups.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
pool: tank
state: ONLINE
scan: scrub canceled on Sun Oct 11 01:02:03 2026
config:

NAME STATE READ WRITE CKSUM
tank ONLINE 0 0 0
mirror-0 ONLINE 0 0 0
/dev/sda ONLINE 0 0 0
/dev/sdb ONLINE 0 0 0
mirror-1 ONLINE 0 0 0
/dev/sdc ONLINE 0 0 0
/dev/sdd ONLINE 0 0 0
logs
/dev/sde ONLINE 0 0 0
cache
/dev/sdf ONLINE 0 0 0
spares
/dev/sdg AVAIL

errors: No known data errors
12 changes: 12 additions & 0 deletions service/testdata/zfs/zpool_status_healthy_mirror.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
pool: pool1
state: ONLINE
scan: scrub repaired 0B in 00:16:47 with 0 errors on Sun Oct 11 00:40:48 2026
config:

NAME STATE READ WRITE CKSUM
pool1 ONLINE 0 0 0
mirror-0 ONLINE 0 0 0
/dev/disk/by-id/nvme-WD_BLACK_SN770_2TB_24443A806391_1-part1 ONLINE 0 0 0
/dev/disk/by-id/nvme-WD_BLACK_SN770_2TB_24443A805750_1-part1 ONLINE 0 0 0

errors: No known data errors
12 changes: 12 additions & 0 deletions service/testdata/zfs/zpool_status_scrub_running.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
pool: tank
state: ONLINE
scan: scrub in progress since Sun Oct 11 00:24:00 2026
500G scanned at 1.0G/s, 400G issued at 800M/s, 1.80T total
0B repaired, 21.70% done, no estimated completion time
config:

NAME STATE READ WRITE CKSUM
tank ONLINE 0 0 0
/dev/sda ONLINE 0 0 0

errors: No known data errors
Loading
Loading