From 218661a3965f0c103705cdbeec7a64b74f4b7c68 Mon Sep 17 00:00:00 2001 From: "courtmanr@gmail.com" Date: Thu, 30 Jul 2026 16:45:13 +0100 Subject: [PATCH] feat(storage): surface Proxmox ZFS datasets --- docs/ZFS_MONITORING.md | 3 + .../v6/internal/subsystems/agent-lifecycle.md | 11 + .../v6/internal/subsystems/api-contracts.md | 10 + .../subsystems/frontend-primitives.md | 9 + .../v6/internal/subsystems/monitoring.md | 11 + .../internal/subsystems/storage-recovery.md | 7 + .../internal/subsystems/unified-resources.md | 8 + .../components/Storage/StoragePoolDetail.tsx | 50 +++++ .../__tests__/StoragePoolDetail.test.tsx | 44 ++++ .../storagePoolDetailPresentation.test.ts | 20 ++ .../storagePoolDetailPresentation.ts | 16 ++ frontend-modern/src/types/api.ts | 10 + internal/hostmetrics/collector.go | 1 + internal/hostmetrics/zfs.go | 201 +++++++++++++++++- internal/hostmetrics/zfs_test.go | 76 +++++++ internal/models/deepcopy.go | 6 + internal/models/deepcopy_test.go | 19 ++ internal/models/models.go | 49 ++++- internal/monitoring/monitor_agents.go | 57 +++++ .../monitoring/monitor_host_agents_test.go | 55 +++++ .../monitoring/monitor_polling_storage.go | 39 ++++ internal/monitoring/zfs_datasets_test.go | 37 ++++ internal/unifiedresources/clone.go | 1 + internal/unifiedresources/clone_test.go | 12 ++ pkg/agents/host/report.go | 27 ++- pkg/agents/host/report_test.go | 26 +++ 26 files changed, 787 insertions(+), 18 deletions(-) create mode 100644 internal/monitoring/zfs_datasets_test.go diff --git a/docs/ZFS_MONITORING.md b/docs/ZFS_MONITORING.md index 00a649c23..97000f0b4 100644 --- a/docs/ZFS_MONITORING.md +++ b/docs/ZFS_MONITORING.md @@ -9,12 +9,15 @@ Pulse automatically detects and monitors ZFS pools on your Proxmox nodes. * **Auto-Detection**: No configuration needed. * **Health Status**: Tracks `ONLINE`, `DEGRADED`, and `FAULTED` states. * **Error Tracking**: Monitors read, write, and checksum errors. +* **Dataset Inventory**: With a Unified Agent on the node, expanded pool details list ZFS filesystems and zvols with used, available, referenced, and mountpoint information. * **Alerts**: Notifies you of degraded pools or failing devices. ## ⚙️ Requirements The Pulse user needs `Sys.Audit` permission on `/nodes/{node}/disks` (included in the standard Pulse role). +Pool health and device status come from the Proxmox API. Dataset inventory additionally requires a Unified Agent on the Proxmox node with read access to the local `zfs` command. If the command is unavailable, Pulse falls back to the mounted ZFS datasets visible to the agent. + ```bash # Grant permission manually if needed pveum acl modify /nodes -user pulse-monitor@pve -role PVEAuditor diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 6be3dbb7e..1f2da0cff 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -5624,3 +5624,14 @@ tenant-local `reportBranding` settings record, but it must not enumerate agents, resolve install tokens, mutate agent state, or reuse agent lifecycle authority. Agent setup and reporting remain independent of whether an operator has configured a custom application name or logo. + +### ZFS dataset inventory is read-only host evidence + +The Unified Agent host report may attach a bounded `zfsDatasets` collection to +each detected ZFS pool disk. The collector executes only the fixed read-only +`zfs list` projection for pools it already discovered, admits only filesystem +and volume rows beneath those pools, and bounds command output, row counts, and +text fields. Dataset collection does not broaden registration, token, +configuration, update, or command authority. Servers validate and copy the +optional evidence during normal authenticated host-report ingest; agents that +do not send it remain wire-compatible. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index fd1edb757..ca191e393 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -8730,3 +8730,13 @@ invalid or unknown image without discarding a valid display name. `internal/api/runtime_branding_test.go` and `internal/api/route_inventory_test.go` pin the entitlement, shape, image, and route contracts. + +### ZFS dataset evidence is an optional nested host-report contract + +The existing authenticated host-report payload may include `zfsDatasets` on a +disk row. Each entry exposes only `name`, optional `type` and `mountpoint`, and +non-negative `usedBytes`, `availableBytes`, and `referencedBytes`. The field is +optional and additive: older agents omit it, older stored hosts may have no +dataset collection, and provider ZFS pool responses omit `datasets` when no +linked host evidence exists. Ingest trims text, rejects invalid counters, and +bounds rows and field lengths before the data reaches runtime models. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 39f2b72a4..262de7bc6 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -5521,3 +5521,12 @@ It must not create a page-local branding cache or render configured values when `white_label` is unavailable. Focused proofs live in `BrandingSettingsCard.test.tsx`, `AppLayout.test.tsx`, and `stores/__tests__/systemSettings.test.ts`. + +### ZFS datasets extend the existing storage detail primitive + +`StoragePoolDetail` remains the owner of the expandable ZFS pool presentation. +When a pool carries optional `datasets`, its already-expanded detail region +renders the canonical dataset name and formatted used, available, referenced, +and mountpoint values. Empty dataset collections add no new panel, route, or +navigation state. The presentation mapper owns byte formatting and missing +mountpoint fallback so components do not reinterpret provider data. diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 4294ecdc9..b21fe3eed 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -2605,3 +2605,14 @@ the same restriction, so both paths now agree. unrestricted storage still appears on every node, that a globally disabled unrestricted storage still surfaces as disabled, and that the shared-storage node list excludes non-member nodes. + +### Linked host evidence enriches provider-owned ZFS pools + +The host collector supplements mounted filesystem facts with a bounded, +read-only `zfs list` query for filesystems and zvols under already-discovered +pools. Authenticated report ingest validates that optional evidence and stores +it on the canonical host model. During Proxmox storage polling, a node's linked +host dataset evidence is copied onto the matching provider-owned ZFS pool; +provider health, scan, device, and error fields remain authoritative. When the +provider cannot return pool detail, monitoring may synthesize only a minimal +`UNKNOWN` pool so valid dataset evidence is still inspectable. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index eae1cc0fc..06924e2f4 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -4827,3 +4827,10 @@ configuration export/restore therefore carries the display name and bounded inline logo exactly as it already carries report branding. Runtime reads through `/api/runtime/branding` are read-only and entitlement-filtered; they do not mutate, relocate, or synthesize persisted branding state. + +ZFS dataset inventory is likewise read-only runtime evidence, not recovery or +storage-configuration state. The agent does not mount, create, snapshot, +destroy, or alter datasets, and the server does not persist a second provider +inventory. Optional dataset rows travel with the current host snapshot and +only enrich the matching Proxmox pool presentation; backup and restore +authority, storage mutation paths, and recovery semantics are unchanged. diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index cf865f808..ab03d1609 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -2014,6 +2014,14 @@ through the canonical resource model, but unified-resource consumers must not reintroduce removed workload aliases or feature-local resource-type shims just to satisfy one table, drawer, or badge surface. +ZFS dataset inventory remains nested evidence rather than a new unified +resource kind. The canonical host snapshot owns a deep-copied list of reported +pool datasets, and Proxmox storage polling copies that list into the matching +pool detail without changing resource identity, counts, parentage, or source +selection. Deep-copy boundaries must clone both pool and dataset slices so +state snapshots and unified-resource consumers cannot alias mutable ingest +buffers. + ### Alert-intent identity and availability evidence projection The monitor adapter may expose two optional read capabilities to the alert diff --git a/frontend-modern/src/components/Storage/StoragePoolDetail.tsx b/frontend-modern/src/components/Storage/StoragePoolDetail.tsx index f45e89d64..01d73bd6a 100644 --- a/frontend-modern/src/components/Storage/StoragePoolDetail.tsx +++ b/frontend-modern/src/components/Storage/StoragePoolDetail.tsx @@ -182,6 +182,56 @@ export const StoragePoolDetail: Component = (props) => { + 0}> +
+
+ Datasets ({zfsSummary()!.datasets.length}) +
+
+ + + + + + + + + + + + + {(dataset) => ( + + + + + + + + )} + + +
DatasetUsedAvailableReferencedMountpoint
+ {dataset.name} + + {dataset.usedLabel} + + {dataset.availableLabel} + + {dataset.referencedLabel} + + {dataset.mountpoint || + (dataset.type === 'volume' ? 'zvol' : '—')} +
+
+
+
diff --git a/frontend-modern/src/components/Storage/__tests__/StoragePoolDetail.test.tsx b/frontend-modern/src/components/Storage/__tests__/StoragePoolDetail.test.tsx index 230204833..d992f5901 100644 --- a/frontend-modern/src/components/Storage/__tests__/StoragePoolDetail.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/StoragePoolDetail.test.tsx @@ -198,4 +198,48 @@ describe('StoragePoolDetail', () => { expect(screen.getByText('spun down')).toBeInTheDocument(); expect(screen.getByText('16 errors')).toBeInTheDocument(); }); + + it('renders linked host-agent ZFS datasets inside the expanded pool detail', () => { + render(() => ( + + + + +
+ )); + + expect(screen.getByText('Datasets (1)')).toBeInTheDocument(); + expect(screen.getByText('tank/apps')).toBeInTheDocument(); + expect(screen.getByText('1.00 GB')).toBeInTheDocument(); + expect(screen.getByText('2.00 GB')).toBeInTheDocument(); + expect(screen.getByText('/tank/apps')).toBeInTheDocument(); + }); }); diff --git a/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts index 3bd84e13f..b0a42f7fe 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storagePoolDetailPresentation.test.ts @@ -37,6 +37,16 @@ const buildRecord = (overrides: Partial = {}): StorageRecord => ( readErrors: 1, writeErrors: 2, checksumErrors: 3, + datasets: [ + { + name: 'tank/apps', + type: 'filesystem', + mountpoint: '/tank/apps', + usedBytes: 1024, + availableBytes: 2048, + referencedBytes: 512, + }, + ], devices: [ { name: 'sda', type: 'disk', state: 'ONLINE' }, { @@ -134,6 +144,16 @@ describe('storagePoolDetailPresentation', () => { message: 'too many errors', }, ], + datasets: [ + { + name: 'tank/apps', + type: 'filesystem', + mountpoint: '/tank/apps', + usedLabel: '1.00 KB', + availableLabel: '2.00 KB', + referencedLabel: '512 B', + }, + ], }); expect(getStoragePoolLinkedDisks(record, [buildDisk()])).toEqual([ { diff --git a/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts b/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts index c24b9ca7b..703f78991 100644 --- a/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts +++ b/frontend-modern/src/features/storageBackups/storagePoolDetailPresentation.ts @@ -57,6 +57,14 @@ export type StoragePoolDetailZfsSummary = { scan: string; errorSummary: string | null; devices: StoragePoolDetailZfsDevice[]; + datasets: Array<{ + name: string; + type: string; + mountpoint: string; + usedLabel: string; + availableLabel: string; + referencedLabel: string; + }>; }; export const STORAGE_POOL_DETAIL_HISTORY_RANGE_OPTIONS: readonly { @@ -252,6 +260,14 @@ export function buildStoragePoolDetailZfsSummary( ), message: (device.message || '').trim(), })), + datasets: (pool.datasets || []).map((dataset) => ({ + name: dataset.name, + type: dataset.type || 'filesystem', + mountpoint: dataset.mountpoint || '', + usedLabel: formatBytes(dataset.usedBytes || 0), + availableLabel: formatBytes(dataset.availableBytes || 0), + referencedLabel: formatBytes(dataset.referencedBytes || 0), + })), }; } diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index 20deb4dce..e2b1c5a28 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -839,6 +839,16 @@ export interface ZFSPool { writeErrors: number; checksumErrors: number; devices: ZFSDevice[]; + datasets?: ZFSDataset[]; +} + +export interface ZFSDataset { + name: string; + type?: 'filesystem' | 'volume' | string; + mountpoint?: string; + usedBytes?: number; + availableBytes?: number; + referencedBytes?: number; } export interface ZFSScan { diff --git a/internal/hostmetrics/collector.go b/internal/hostmetrics/collector.go index 340c0cb9a..d0e4efa4b 100644 --- a/internal/hostmetrics/collector.go +++ b/internal/hostmetrics/collector.go @@ -325,6 +325,7 @@ func collectDisks(ctx context.Context, diskExclude []string) []agentshost.Disk { } zfsDisks := summarizeZFSPools(ctx, zfsDatasets) + enrichZFSPoolDisksWithDatasets(ctx, zfsDisks, zfsDatasets) log.Debug().Int("zfsDatasets", len(zfsDatasets)).Int("zfsDisks", len(zfsDisks)).Int("regularDisks", len(disks)).Msg("disk: collection summary") disks = append(disks, zfsDisks...) diff --git a/internal/hostmetrics/zfs.go b/internal/hostmetrics/zfs.go index 9ebd7ea2f..bb1f29a07 100644 --- a/internal/hostmetrics/zfs.go +++ b/internal/hostmetrics/zfs.go @@ -17,7 +17,13 @@ import ( "github.com/rs/zerolog/log" ) -const maxZpoolCommandOutputSize = 1 << 20 // 1 MiB +const ( + maxZpoolCommandOutputSize = 1 << 20 // 1 MiB + maxZFSCommandOutputSize = 4 << 20 // 4 MiB + maxZFSDatasetsPerPool = 128 + maxZFSDatasetNameBytes = 512 + maxZFSMountpointBytes = 1024 +) var errZpoolCommandOutputTooLarge = errors.New("zpool command output exceeded limit") @@ -39,9 +45,13 @@ type zfsDatasetUsage struct { } var queryZpoolStats = fetchZpoolStats +var queryZFSDatasets = fetchZFSDatasets var zpoolLookPath = exec.LookPath var zpoolStat = os.Stat var zpoolCommandRunner = runZpoolCommand +var zfsLookPath = exec.LookPath +var zfsStat = os.Stat +var zfsCommandRunner = runZFSCommand type limitedBuffer struct { buf bytes.Buffer @@ -205,6 +215,66 @@ func fallbackZFSDisks(bestDatasets map[string]zfsDatasetUsage, mountpoints map[s return disks } +func enrichZFSPoolDisksWithDatasets( + ctx context.Context, + disks []agentshost.Disk, + mounted []zfsDatasetUsage, +) { + if len(disks) == 0 { + return + } + pools := make([]string, 0, len(disks)) + for _, disk := range disks { + if pool, ok := normalizeZFSPoolName(disk.Device); ok { + pools = append(pools, pool) + } + } + + datasets, err := queryZFSDatasets(ctx, pools) + if err != nil { + log.Debug().Err(err).Msg("zfs: dataset inventory unavailable, using mounted datasets") + datasets = mountedZFSDatasets(mounted) + } + for index := range disks { + disks[index].ZFSDatasets = append( + []agentshost.ZFSDataset(nil), + datasets[disks[index].Device]..., + ) + } +} + +func mountedZFSDatasets(mounted []zfsDatasetUsage) map[string][]agentshost.ZFSDataset { + result := make(map[string][]agentshost.ZFSDataset) + seen := make(map[string]struct{}) + for _, dataset := range mounted { + pool, ok := normalizeZFSPoolName(dataset.Pool) + name := strings.TrimSpace(dataset.Dataset) + mountpoint := strings.TrimSpace(dataset.Mountpoint) + if !ok || name == pool || !strings.HasPrefix(name, pool+"/") || + len(name) > maxZFSDatasetNameBytes || len(mountpoint) > maxZFSMountpointBytes || + len(result[pool]) >= maxZFSDatasetsPerPool { + continue + } + if _, duplicate := seen[name]; duplicate { + continue + } + seen[name] = struct{}{} + result[pool] = append(result[pool], agentshost.ZFSDataset{ + Name: name, + Type: "filesystem", + Mountpoint: mountpoint, + UsedBytes: int64(dataset.Used), + AvailableBytes: int64(dataset.Free), + }) + } + for pool := range result { + sort.Slice(result[pool], func(i, j int) bool { + return result[pool][i].Name < result[pool][j].Name + }) + } + return result +} + // commonZpoolPaths lists common locations for the zpool binary. // TrueNAS SCALE, FreeBSD, and various Linux distributions may install // zpool in different locations that might not be in the agent's PATH. @@ -218,6 +288,15 @@ var commonZpoolPaths = []string{ "/usr/bin/zpool", // Some distributions } +var commonZFSPaths = []string{ + "/usr/sbin/zfs", + "/sbin/zfs", + "/usr/local/sbin/zfs", + "/usr/local/bin/zfs", + "/opt/zfs/bin/zfs", + "/usr/bin/zfs", +} + // findZpool returns the path to the zpool binary by preferring known absolute // locations first, then falling back to PATH lookup. func findZpool() (string, error) { @@ -249,6 +328,126 @@ func findZpool() (string, error) { return path, nil } +func findZFS() (string, error) { + for _, path := range commonZFSPaths { + if _, err := zfsStat(path); err == nil { + return path, nil + } + } + path, err := zfsLookPath("zfs") + if err != nil { + return "", fmt.Errorf("zfs binary not found in PATH or common locations") + } + path = filepath.Clean(path) + if !filepath.IsAbs(path) { + return "", fmt.Errorf("zfs path is not absolute: %q", path) + } + if _, err := zfsStat(path); err != nil { + return "", fmt.Errorf("zfs path unavailable: %w", err) + } + return path, nil +} + +func fetchZFSDatasets( + ctx context.Context, + pools []string, +) (map[string][]agentshost.ZFSDataset, error) { + pools = filterValidZFSPoolNames(pools) + if len(pools) == 0 { + return nil, nil + } + path, err := findZFS() + if err != nil { + return nil, err + } + args := []string{ + "list", "-Hp", "-t", "filesystem,volume", + "-o", "name,type,used,available,referenced,mountpoint", + } + args = append(args, pools...) + output, stderr, err := zfsCommandRunner(ctx, path, args...) + if err != nil { + if errors.Is(err, errZpoolCommandOutputTooLarge) { + return nil, fmt.Errorf("zfs list output exceeded %d bytes", maxZFSCommandOutputSize) + } + return nil, fmt.Errorf("zfs list failed (stderr %d bytes): %w", len(stderr), err) + } + return parseZFSList(output, pools) +} + +func runZFSCommand(ctx context.Context, name string, args ...string) ([]byte, []byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + stdout := limitedBuffer{maxBytes: maxZFSCommandOutputSize} + stderr := limitedBuffer{maxBytes: maxZFSCommandOutputSize} + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if stdout.exceeded || stderr.exceeded { + return stdout.Bytes(), stderr.Bytes(), errZpoolCommandOutputTooLarge + } + return stdout.Bytes(), stderr.Bytes(), err +} + +func parseZFSList( + output []byte, + allowedPools []string, +) (map[string][]agentshost.ZFSDataset, error) { + allowed := make(map[string]struct{}, len(allowedPools)) + for _, pool := range filterValidZFSPoolNames(allowedPools) { + allowed[pool] = struct{}{} + } + result := make(map[string][]agentshost.ZFSDataset) + scanner := bufio.NewScanner(bytes.NewReader(output)) + scanner.Buffer(make([]byte, 64*1024), maxZFSCommandOutputSize) + for scanner.Scan() { + fields := strings.Split(scanner.Text(), "\t") + if len(fields) != 6 { + continue + } + name := strings.TrimSpace(fields[0]) + pool := zfsPoolFromDevice(name) + if _, ok := allowed[pool]; !ok || name == pool || !strings.HasPrefix(name, pool+"/") || + len(name) > maxZFSDatasetNameBytes || len(result[pool]) >= maxZFSDatasetsPerPool { + continue + } + datasetType := strings.ToLower(strings.TrimSpace(fields[1])) + if datasetType != "filesystem" && datasetType != "volume" { + continue + } + used, usedErr := strconv.ParseInt(fields[2], 10, 64) + available, availableErr := strconv.ParseInt(fields[3], 10, 64) + referenced, referencedErr := strconv.ParseInt(fields[4], 10, 64) + if usedErr != nil || availableErr != nil || referencedErr != nil || + used < 0 || available < 0 || referenced < 0 { + continue + } + mountpoint := strings.TrimSpace(fields[5]) + if mountpoint == "-" || mountpoint == "none" || mountpoint == "legacy" { + mountpoint = "" + } + if len(mountpoint) > maxZFSMountpointBytes { + continue + } + result[pool] = append(result[pool], agentshost.ZFSDataset{ + Name: name, + Type: datasetType, + Mountpoint: mountpoint, + UsedBytes: used, + AvailableBytes: available, + ReferencedBytes: referenced, + }) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("scan zfs list output: %w", err) + } + for pool := range result { + sort.Slice(result[pool], func(i, j int) bool { + return result[pool][i].Name < result[pool][j].Name + }) + } + return result, nil +} + func fetchZpoolStats(ctx context.Context, pools []string) (map[string]zpoolStats, error) { if len(pools) == 0 { return nil, nil diff --git a/internal/hostmetrics/zfs_test.go b/internal/hostmetrics/zfs_test.go index 1f1d1a23a..03357296c 100644 --- a/internal/hostmetrics/zfs_test.go +++ b/internal/hostmetrics/zfs_test.go @@ -2,8 +2,12 @@ package hostmetrics import ( "context" + "fmt" "os" + "strings" "testing" + + agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host" ) func TestSummarizeZFSPoolsUsesZpoolStats(t *testing.T) { @@ -545,6 +549,78 @@ func TestParseZpoolList(t *testing.T) { } } +func TestParseZFSListPreservesDatasetHierarchyAndUsage(t *testing.T) { + output := []byte( + "rpool\tfilesystem\t100\t900\t75\t/rpool\n" + + "rpool/ROOT\tfilesystem\t200\t800\t150\tlegacy\n" + + "rpool/data/vm-100-disk-0\tvolume\t300\t700\t300\t-\n" + + "other/apps\tfilesystem\t400\t600\t350\t/other/apps\n", + ) + + got, err := parseZFSList(output, []string{"rpool"}) + if err != nil { + t.Fatal(err) + } + if len(got["rpool"]) != 2 { + t.Fatalf("datasets = %#v, want two descendants", got["rpool"]) + } + if got["rpool"][0] != (agentshost.ZFSDataset{ + Name: "rpool/ROOT", + Type: "filesystem", + UsedBytes: 200, + AvailableBytes: 800, + ReferencedBytes: 150, + }) { + t.Fatalf("filesystem dataset = %#v", got["rpool"][0]) + } + if got["rpool"][1].Type != "volume" || got["rpool"][1].Mountpoint != "" { + t.Fatalf("volume dataset = %#v", got["rpool"][1]) + } +} + +func TestParseZFSListBoundsDatasetInventory(t *testing.T) { + var output strings.Builder + for i := 0; i < maxZFSDatasetsPerPool+2; i++ { + _, _ = fmt.Fprintf( + &output, + "tank/data-%03d\tfilesystem\t1\t2\t1\t/tank/data-%03d\n", + i, + i, + ) + } + _, _ = fmt.Fprintf( + &output, + "tank/%s\tfilesystem\t1\t2\t1\t/tank/too-long\n", + strings.Repeat("x", maxZFSDatasetNameBytes), + ) + + got, err := parseZFSList([]byte(output.String()), []string{"tank"}) + if err != nil { + t.Fatal(err) + } + if len(got["tank"]) != maxZFSDatasetsPerPool { + t.Fatalf("dataset count = %d, want %d", len(got["tank"]), maxZFSDatasetsPerPool) + } +} + +func TestEnrichZFSPoolDisksWithDatasetsFallsBackToMountedInventory(t *testing.T) { + originalQuery := queryZFSDatasets + t.Cleanup(func() { queryZFSDatasets = originalQuery }) + queryZFSDatasets = func(context.Context, []string) (map[string][]agentshost.ZFSDataset, error) { + return nil, context.DeadlineExceeded + } + + disks := []agentshost.Disk{{Device: "tank", Type: "zfs"}} + enrichZFSPoolDisksWithDatasets(context.Background(), disks, []zfsDatasetUsage{ + {Pool: "tank", Dataset: "tank", Mountpoint: "/tank"}, + {Pool: "tank", Dataset: "tank/apps", Mountpoint: "/tank/apps", Used: 25, Free: 75}, + }) + + if len(disks[0].ZFSDatasets) != 1 || disks[0].ZFSDatasets[0].Name != "tank/apps" { + t.Fatalf("fallback datasets = %#v", disks[0].ZFSDatasets) + } +} + func TestUniqueZFSPools(t *testing.T) { tests := []struct { name string diff --git a/internal/models/deepcopy.go b/internal/models/deepcopy.go index 3f99e0e44..896b65c2f 100644 --- a/internal/models/deepcopy.go +++ b/internal/models/deepcopy.go @@ -377,6 +377,11 @@ func cloneHost(src Host) Host { dest := src dest.LoadAverage = append([]float64(nil), src.LoadAverage...) dest.Disks = append([]Disk(nil), src.Disks...) + dest.ZFSPools = make([]HostZFSPool, len(src.ZFSPools)) + for i := range src.ZFSPools { + dest.ZFSPools[i] = src.ZFSPools[i] + dest.ZFSPools[i].Datasets = append([]ZFSDataset(nil), src.ZFSPools[i].Datasets...) + } dest.DiskIO = append([]DiskIO(nil), src.DiskIO...) dest.NetworkInterfaces = cloneHostNetworkInterfaces(src.NetworkInterfaces) dest.Sensors = cloneHostSensorSummary(src.Sensors) @@ -1084,6 +1089,7 @@ func cloneZFSPool(src *ZFSPool) *ZFSPool { } dest := *src dest.Devices = append([]ZFSDevice(nil), src.Devices...) + dest.Datasets = append([]ZFSDataset(nil), src.Datasets...) normalized := dest.NormalizeCollections() return &normalized } diff --git a/internal/models/deepcopy_test.go b/internal/models/deepcopy_test.go index c15404edf..f383cadd3 100644 --- a/internal/models/deepcopy_test.go +++ b/internal/models/deepcopy_test.go @@ -24,6 +24,25 @@ func TestCloneBoolPtr_Value(t *testing.T) { } } +func TestCloneHostAndZFSPoolIsolateZFSDatasets(t *testing.T) { + host := Host{ZFSPools: []HostZFSPool{{ + Name: "tank", + Datasets: []ZFSDataset{{Name: "tank/apps", UsedBytes: 100}}, + }}} + hostClone := cloneHost(host) + hostClone.ZFSPools[0].Datasets[0].Name = "mutated" + if host.ZFSPools[0].Datasets[0].Name != "tank/apps" { + t.Fatal("host clone aliased zfs datasets") + } + + pool := &ZFSPool{Name: "tank", Datasets: []ZFSDataset{{Name: "tank/apps"}}} + poolClone := cloneZFSPool(pool) + poolClone.Datasets[0].Name = "mutated" + if pool.Datasets[0].Name != "tank/apps" { + t.Fatal("pool clone aliased zfs datasets") + } +} + func TestCloneDockerContainer_PreservesIndependentOOMEvidence(t *testing.T) { oomKilled := false src := DockerContainer{ID: "container-1", OOMKilled: &oomKilled} diff --git a/internal/models/models.go b/internal/models/models.go index 72a7ae63c..c895cb326 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -297,6 +297,7 @@ type Host struct { Memory Memory `json:"memory"` LoadAverage []float64 `json:"loadAverage,omitempty"` Disks []Disk `json:"disks,omitempty"` + ZFSPools []HostZFSPool `json:"zfsPools,omitempty"` DiskIO []DiskIO `json:"diskIO,omitempty"` NetworkInterfaces []HostNetworkInterface `json:"networkInterfaces,omitempty"` Sensors HostSensorSummary `json:"sensors,omitempty"` @@ -407,6 +408,12 @@ func (h Host) NormalizeCollections() Host { if h.Disks == nil { h.Disks = []Disk{} } + if h.ZFSPools == nil { + h.ZFSPools = []HostZFSPool{} + } + for i := range h.ZFSPools { + h.ZFSPools[i] = h.ZFSPools[i].NormalizeCollections() + } if h.DiskIO == nil { h.DiskIO = []DiskIO{} } @@ -2385,15 +2392,16 @@ func (s Storage) NormalizeCollections() Storage { // ZFSPool represents a ZFS pool with health and error information type ZFSPool struct { - Name string `json:"name"` - State string `json:"state"` // ONLINE, DEGRADED, FAULTED, OFFLINE, REMOVED, UNAVAIL - Status string `json:"status"` // Healthy, Degraded, Faulted, etc. - Scan string `json:"scan"` // Current scan status (scrub, resilver, none) - ScanDetails *ZFSScan `json:"scanDetails,omitempty"` - ReadErrors int64 `json:"readErrors"` - WriteErrors int64 `json:"writeErrors"` - ChecksumErrors int64 `json:"checksumErrors"` - Devices []ZFSDevice `json:"devices"` + Name string `json:"name"` + State string `json:"state"` // ONLINE, DEGRADED, FAULTED, OFFLINE, REMOVED, UNAVAIL + Status string `json:"status"` // Healthy, Degraded, Faulted, etc. + Scan string `json:"scan"` // Current scan status (scrub, resilver, none) + ScanDetails *ZFSScan `json:"scanDetails,omitempty"` + ReadErrors int64 `json:"readErrors"` + WriteErrors int64 `json:"writeErrors"` + ChecksumErrors int64 `json:"checksumErrors"` + Devices []ZFSDevice `json:"devices"` + Datasets []ZFSDataset `json:"datasets,omitempty"` } func (p ZFSPool) NormalizeCollections() ZFSPool { @@ -2403,6 +2411,29 @@ func (p ZFSPool) NormalizeCollections() ZFSPool { return p } +// ZFSDataset is the read-only dataset capacity projection reported by a linked +// host agent and attached to the provider-owned pool. +type ZFSDataset struct { + Name string `json:"name"` + Type string `json:"type,omitempty"` + Mountpoint string `json:"mountpoint,omitempty"` + UsedBytes int64 `json:"usedBytes,omitempty"` + AvailableBytes int64 `json:"availableBytes,omitempty"` + ReferencedBytes int64 `json:"referencedBytes,omitempty"` +} + +type HostZFSPool struct { + Name string `json:"name"` + Datasets []ZFSDataset `json:"datasets"` +} + +func (p HostZFSPool) NormalizeCollections() HostZFSPool { + if p.Datasets == nil { + p.Datasets = []ZFSDataset{} + } + return p +} + // ZFSScan is the provider-native scrub or resilver observation for a pool. // The legacy Scan string remains for compact consumers, while this shape keeps // progress, terminal errors, and lifecycle timestamps machine-readable. diff --git a/internal/monitoring/monitor_agents.go b/internal/monitoring/monitor_agents.go index 725dce075..89c8b17cb 100644 --- a/internal/monitoring/monitor_agents.go +++ b/internal/monitoring/monitor_agents.go @@ -28,6 +28,12 @@ const hostContinuityRetention = 72 * time.Hour const maxRetiredHostReportStreams = 8 +const ( + maxHostZFSDatasetsPerPool = 128 + maxHostZFSDatasetNameBytes = 512 + maxHostZFSMountpointBytes = 1024 +) + func normalizeAgentMemory(total, used, free, cache int64, usage float64, swapTotal, swapUsed int64) models.Memory { if total <= 0 { return models.Memory{} @@ -2571,6 +2577,7 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config. ) disks := make([]models.Disk, 0, len(report.Disks)) + hostZFSPools := hostZFSPoolsFromAgentDisks(report.Disks) for _, disk := range report.Disks { // Filter virtual/system filesystems and read-only filesystems to avoid cluttering // the UI with tmpfs, devtmpfs, /dev, /run, /sys, docker overlay mounts, snap mounts, @@ -2748,6 +2755,7 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config. LoadAverage: append([]float64(nil), report.Host.LoadAverage...), Memory: memory, Disks: disks, + ZFSPools: hostZFSPools, DiskIO: diskIO, NetworkInterfaces: network, Sensors: models.HostSensorSummary{ @@ -2792,6 +2800,9 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config. if len(host.Disks) == 0 { host.Disks = nil } + if len(host.ZFSPools) == 0 { + host.ZFSPools = nil + } if len(host.DiskIO) == 0 { host.DiskIO = nil } @@ -3002,6 +3013,52 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config. return host, nil } +func hostZFSPoolsFromAgentDisks(disks []agentshost.Disk) []models.HostZFSPool { + pools := make([]models.HostZFSPool, 0) + for _, disk := range disks { + name := strings.TrimSpace(disk.Device) + if name == "" || + !strings.EqualFold(strings.TrimSpace(disk.Type), "zfs") || + len(disk.ZFSDatasets) == 0 { + continue + } + datasets := make([]models.ZFSDataset, 0, len(disk.ZFSDatasets)) + for _, dataset := range disk.ZFSDatasets { + if len(datasets) >= maxHostZFSDatasetsPerPool { + break + } + datasetName := strings.TrimSpace(dataset.Name) + datasetType := strings.ToLower(strings.TrimSpace(dataset.Type)) + if datasetType == "" { + datasetType = "filesystem" + } + mountpoint := strings.TrimSpace(dataset.Mountpoint) + if datasetName == "" || + !strings.HasPrefix(datasetName, name+"/") || + (datasetType != "filesystem" && datasetType != "volume") || + len(datasetName) > maxHostZFSDatasetNameBytes || + len(mountpoint) > maxHostZFSMountpointBytes || + dataset.UsedBytes < 0 || + dataset.AvailableBytes < 0 || + dataset.ReferencedBytes < 0 { + continue + } + datasets = append(datasets, models.ZFSDataset{ + Name: datasetName, + Type: datasetType, + Mountpoint: mountpoint, + UsedBytes: dataset.UsedBytes, + AvailableBytes: dataset.AvailableBytes, + ReferencedBytes: dataset.ReferencedBytes, + }) + } + if len(datasets) > 0 { + pools = append(pools, models.HostZFSPool{Name: name, Datasets: datasets}) + } + } + return pools +} + func convertHostPackageUpdateStatus(status *agentshost.PackageUpdateStatus, observedAt time.Time) *models.HostPackageUpdateStatus { if status == nil { return nil diff --git a/internal/monitoring/monitor_host_agents_test.go b/internal/monitoring/monitor_host_agents_test.go index 39476a68f..ea0b225ab 100644 --- a/internal/monitoring/monitor_host_agents_test.go +++ b/internal/monitoring/monitor_host_agents_test.go @@ -21,6 +21,61 @@ import ( "github.com/rcourtman/pulse-go-rewrite/pkg/metrics" ) +func TestHostZFSPoolsFromAgentDisksPreservesDatasetFacts(t *testing.T) { + got := hostZFSPoolsFromAgentDisks([]agentshost.Disk{ + {Device: "/", Type: "ext4"}, + { + Device: "tank", + Type: "zfs", + ZFSDatasets: []agentshost.ZFSDataset{ + { + Name: " tank/apps ", + Type: " filesystem ", + Mountpoint: " /tank/apps ", + UsedBytes: 100, + AvailableBytes: 200, + ReferencedBytes: 50, + }, + }, + }, + }) + + if len(got) != 1 || got[0].Name != "tank" || len(got[0].Datasets) != 1 { + t.Fatalf("host zfs pools = %#v", got) + } + if got[0].Datasets[0].Name != "tank/apps" || + got[0].Datasets[0].Mountpoint != "/tank/apps" { + t.Fatalf("dataset was not normalized: %#v", got[0].Datasets[0]) + } +} + +func TestHostZFSPoolsFromAgentDisksRejectsInvalidAndBoundsRows(t *testing.T) { + datasets := []agentshost.ZFSDataset{ + {Name: "tank/negative", Type: "filesystem", UsedBytes: -1}, + {Name: "other/wrong-pool", Type: "filesystem"}, + {Name: strings.Repeat("x", maxHostZFSDatasetNameBytes+1), Type: "filesystem"}, + {Name: "tank/long-mount", Type: "filesystem", Mountpoint: strings.Repeat("x", maxHostZFSMountpointBytes+1)}, + } + for i := 0; i < maxHostZFSDatasetsPerPool+2; i++ { + datasets = append(datasets, agentshost.ZFSDataset{ + Name: fmt.Sprintf("tank/data-%03d", i), + Type: "filesystem", + }) + } + + got := hostZFSPoolsFromAgentDisks([]agentshost.Disk{{ + Device: "tank", + Type: "zfs", + ZFSDatasets: datasets, + }}) + if len(got) != 1 || len(got[0].Datasets) != maxHostZFSDatasetsPerPool { + t.Fatalf("bounded host zfs pools = %#v", got) + } + if got[0].Datasets[0].Name != "tank/data-000" { + t.Fatalf("invalid rows were not rejected: %#v", got[0].Datasets[0]) + } +} + func TestMonitoringBroadcastCarriesEveryAvailabilityProjection(t *testing.T) { input := monitorResourceToConvertInput(unifiedresources.Resource{ ID: "agent-core2026", diff --git a/internal/monitoring/monitor_polling_storage.go b/internal/monitoring/monitor_polling_storage.go index c35feea2f..4d246cf4a 100644 --- a/internal/monitoring/monitor_polling_storage.go +++ b/internal/monitoring/monitor_polling_storage.go @@ -144,6 +144,41 @@ func matchZFSPoolForStorage(storage models.Storage, zfsPoolMap map[string]*model return nil } +func mergeLinkedHostZFSDatasets( + pools map[string]*models.ZFSPool, + host *models.Host, +) { + if host == nil { + return + } + for _, reported := range host.ZFSPools { + name := strings.TrimSpace(reported.Name) + if name == "" || len(reported.Datasets) == 0 { + continue + } + pool := pools[name] + if pool == nil { + for candidateName, candidate := range pools { + if strings.EqualFold(strings.TrimSpace(candidateName), name) { + pool = candidate + break + } + } + } + if pool == nil { + pool = &models.ZFSPool{ + Name: name, + State: "UNKNOWN", + Status: "Dataset inventory available", + Scan: "none", + Devices: []models.ZFSDevice{}, + } + pools[name] = pool + } + pool.Datasets = append([]models.ZFSDataset(nil), reported.Datasets...) + } +} + // pollVMsWithNodes polls VMs from all nodes in parallel using goroutines // When the instance is part of a cluster, the cluster name is used for guest IDs to prevent duplicates // when multiple cluster nodes are configured as separate PVE instances. @@ -321,6 +356,10 @@ func (m *Monitor) pollStorageWithNodes(ctx context.Context, instanceName string, Msg("Could not get ZFS pool status (may require additional permissions)") } } + mergeLinkedHostZFSDatasets( + zfsPoolMap, + m.linkedHostForNode(instanceName, n.Node, n.Node), + ) // Process each storage for _, storage := range nodeStorage { diff --git a/internal/monitoring/zfs_datasets_test.go b/internal/monitoring/zfs_datasets_test.go new file mode 100644 index 000000000..ffee8f32f --- /dev/null +++ b/internal/monitoring/zfs_datasets_test.go @@ -0,0 +1,37 @@ +package monitoring + +import ( + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" +) + +func TestMergeLinkedHostZFSDatasetsEnrichesAndSynthesizesPools(t *testing.T) { + pools := map[string]*models.ZFSPool{ + "rpool": {Name: "rpool", State: "ONLINE"}, + } + host := &models.Host{ZFSPools: []models.HostZFSPool{ + { + Name: "rpool", + Datasets: []models.ZFSDataset{ + {Name: "rpool/data", Type: "filesystem", UsedBytes: 100}, + }, + }, + { + Name: "tank", + Datasets: []models.ZFSDataset{ + {Name: "tank/vm", Type: "volume", UsedBytes: 200}, + }, + }, + }} + + mergeLinkedHostZFSDatasets(pools, host) + + if len(pools["rpool"].Datasets) != 1 || pools["rpool"].State != "ONLINE" { + t.Fatalf("existing pool not enriched safely: %#v", pools["rpool"]) + } + if pools["tank"] == nil || pools["tank"].State != "UNKNOWN" || + len(pools["tank"].Datasets) != 1 { + t.Fatalf("agent-only pool not synthesized: %#v", pools["tank"]) + } +} diff --git a/internal/unifiedresources/clone.go b/internal/unifiedresources/clone.go index 061649ac3..033213cc9 100644 --- a/internal/unifiedresources/clone.go +++ b/internal/unifiedresources/clone.go @@ -191,6 +191,7 @@ func cloneZFSPool(in *models.ZFSPool) *models.ZFSPool { } out := *in out.Devices = append([]models.ZFSDevice(nil), in.Devices...) + out.Datasets = append([]models.ZFSDataset(nil), in.Datasets...) if in.ScanDetails != nil { scan := *in.ScanDetails scan.StartedAt = cloneTimePtr(in.ScanDetails.StartedAt) diff --git a/internal/unifiedresources/clone_test.go b/internal/unifiedresources/clone_test.go index 17815a549..c024d8d0e 100644 --- a/internal/unifiedresources/clone_test.go +++ b/internal/unifiedresources/clone_test.go @@ -23,6 +23,18 @@ func TestCloneResourcePtr_Nil(t *testing.T) { } } +func TestCloneZFSPoolIsolatesDatasets(t *testing.T) { + input := &models.ZFSPool{ + Name: "tank", + Datasets: []models.ZFSDataset{{Name: "tank/apps", UsedBytes: 100}}, + } + cloned := cloneZFSPool(input) + cloned.Datasets[0].Name = "mutated" + if input.Datasets[0].Name != "tank/apps" { + t.Fatal("unified resource zfs pool clone aliased datasets") + } +} + func TestCloneResource_MutateOriginalSlice(t *testing.T) { original := &Resource{ ID: "r-1", diff --git a/pkg/agents/host/report.go b/pkg/agents/host/report.go index 04a4b1b00..2b8f634f8 100644 --- a/pkg/agents/host/report.go +++ b/pkg/agents/host/report.go @@ -208,14 +208,25 @@ type MemoryMetric struct { // Disk represents disk utilisation metrics. type Disk struct { - Device string `json:"device,omitempty"` - Mountpoint string `json:"mountpoint,omitempty"` - Filesystem string `json:"filesystem,omitempty"` - Type string `json:"type,omitempty"` - TotalBytes int64 `json:"totalBytes,omitempty"` - UsedBytes int64 `json:"usedBytes,omitempty"` - FreeBytes int64 `json:"freeBytes,omitempty"` - Usage float64 `json:"usage,omitempty"` + Device string `json:"device,omitempty"` + Mountpoint string `json:"mountpoint,omitempty"` + Filesystem string `json:"filesystem,omitempty"` + Type string `json:"type,omitempty"` + TotalBytes int64 `json:"totalBytes,omitempty"` + UsedBytes int64 `json:"usedBytes,omitempty"` + FreeBytes int64 `json:"freeBytes,omitempty"` + Usage float64 `json:"usage,omitempty"` + ZFSDatasets []ZFSDataset `json:"zfsDatasets,omitempty"` +} + +// ZFSDataset is a bounded, read-only projection of `zfs list` for one pool. +type ZFSDataset struct { + Name string `json:"name"` + Type string `json:"type,omitempty"` + Mountpoint string `json:"mountpoint,omitempty"` + UsedBytes int64 `json:"usedBytes,omitempty"` + AvailableBytes int64 `json:"availableBytes,omitempty"` + ReferencedBytes int64 `json:"referencedBytes,omitempty"` } // DiskIO represents disk I/O statistics for a block device. diff --git a/pkg/agents/host/report_test.go b/pkg/agents/host/report_test.go index 5db97bec1..1c08051e6 100644 --- a/pkg/agents/host/report_test.go +++ b/pkg/agents/host/report_test.go @@ -170,6 +170,32 @@ func TestMemoryMetric_Fields(t *testing.T) { } } +func TestDiskZFSDatasetsJSONRoundTrip(t *testing.T) { + input := Disk{ + Device: "tank", + Type: "zfs", + ZFSDatasets: []ZFSDataset{{ + Name: "tank/apps", + Type: "filesystem", + Mountpoint: "/tank/apps", + UsedBytes: 100, + AvailableBytes: 200, + ReferencedBytes: 50, + }}, + } + data, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + var output Disk + if err := json.Unmarshal(data, &output); err != nil { + t.Fatal(err) + } + if len(output.ZFSDatasets) != 1 || output.ZFSDatasets[0].Name != "tank/apps" { + t.Fatalf("zfs datasets = %#v", output.ZFSDatasets) + } +} + func TestDisk_Fields(t *testing.T) { disk := Disk{ Device: "/dev/sda1",