Forward-port Proxmox storage pool metadata

This commit is contained in:
rcourtman
2026-04-01 22:54:51 +01:00
parent 6abb95681b
commit e86494668a
22 changed files with 316 additions and 25 deletions
@@ -189,6 +189,11 @@ Own canonical runtime payload shapes between backend and frontend.
account/billing shell must remain understandable from the primary header,
section title, and factual body content alone instead of depending on a
second context-chip strip to restate the same scope.
30. Keep storage wire metadata lossless across shared API payload types.
`frontend-modern/src/types/api.ts` must continue to expose provider-backed
storage metadata such as Proxmox `pool` and `zfsPool` fields when the
backend emits them, instead of silently dropping that detail from the
shared runtime contract.
## Forbidden Paths
@@ -82,6 +82,11 @@ truth for live infrastructure data.
declared ingestion mode on an admitted first-class platform, not a license
to create new platform ids from secondary pollers or optional agent
augmentation paths.
6. Preserve Proxmox storage backing-pool truth through the canonical storage
poller path. `pkg/proxmox.Storage`, `internal/monitoring/monitor_polling_storage.go`,
and the attached ZFS health model must carry the provider-reported `pool`
field through to runtime storage snapshots and use it before name/path
heuristics when matching ZFS pool health on multi-storage hosts.
## Current State
@@ -219,7 +219,13 @@ querying, and the operator-facing storage health presentation layer.
that retained-value behavior must stay route-owned and filter-owned through
the canonical recovery state model instead of recreating page-local
suspense escape hatches in `Recovery.tsx` or the recovery sections.
18. Keep storage route writes on the shared route-state scheduler. Storage page
18. Keep storage/recovery-adjacent resource metadata on the shared unified
resource contract. When canonical storage resources expose provider-backed
identity such as Proxmox storage `pool`, storage and recovery consumers
must inherit that field through `frontend-modern/src/hooks/useUnifiedResources.ts`
and `frontend-modern/src/types/resource.ts` instead of rebuilding backing
pool identity from labels, paths, or storage-row-local heuristics.
19. Keep storage route writes on the shared route-state scheduler. Storage page
filter and tab updates may still own their query keys locally, but
`frontend-modern/src/components/Storage/useStorageRouteState.ts` must route
same-route replace navigation through the shared
@@ -208,6 +208,14 @@ assembly branch.
`frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts`
must match the user-visible safe label for governed resources instead of
reintroducing redacted hostnames through search-only fallback candidates.
15. Preserve provider-backed storage backing-pool identity on canonical
storage resources. `internal/unifiedresources/types.go`,
`internal/unifiedresources/adapters.go`, `internal/unifiedresources/views.go`,
`frontend-modern/src/types/resource.ts`, and
`frontend-modern/src/hooks/useUnifiedResources.ts` must carry the
provider-reported storage `pool` metadata alongside path and ZFS health so
storage consumers do not have to recover backing-pool identity from names
or path heuristics.
## Current State
@@ -275,6 +275,7 @@ type APIResource = {
rebuildInProgress?: boolean;
rebuildSummary?: string;
nodes?: string[];
pool?: string;
path?: string;
zfsPoolState?: string;
zfsReadErrors?: number;
+1
View File
@@ -613,6 +613,7 @@ export interface Storage {
nodeIds?: string[];
nodeCount?: number;
pbsNames?: string[];
pool?: string;
// ZFS pool status
zfsPool?: ZFSPool;
}
+1
View File
@@ -260,6 +260,7 @@ export interface ResourceStorageMeta {
rebuildInProgress?: boolean;
rebuildSummary?: string;
nodes?: string[];
pool?: string;
path?: string;
zfsPoolState?: string;
zfsReadErrors?: number;
+4
View File
@@ -2113,6 +2113,7 @@ func TestResourceListIncludesStorageMetadata(t *testing.T) {
Node: "pve-1",
Instance: "cluster-a",
Type: "rbd",
Pool: "ceph/rbd-a",
Content: "images,backup",
Shared: true,
Status: "available",
@@ -2159,6 +2160,9 @@ func TestResourceListIncludesStorageMetadata(t *testing.T) {
if got, want := resource.Storage.ContentTypes, []string{"images", "backup"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("storage.contentTypes = %v, want %v", got, want)
}
if got, want := resource.Storage.Pool, "ceph/rbd-a"; got != want {
t.Fatalf("storage.pool = %q, want %q", got, want)
}
if !resource.Storage.Shared {
t.Fatalf("expected storage.shared=true")
}
+2
View File
@@ -816,6 +816,7 @@ func (s Storage) ToFrontend() StorageFrontend {
NodeCount: s.NodeCount,
Type: s.Type,
Status: s.Status,
Pool: s.Pool,
Total: s.Total,
Used: s.Used,
Avail: s.Free,
@@ -825,6 +826,7 @@ func (s Storage) ToFrontend() StorageFrontend {
Shared: s.Shared,
Enabled: s.Enabled,
Active: s.Active,
ZFSPool: s.ZFSPool,
}.NormalizeCollections()
}
+11 -1
View File
@@ -516,6 +516,7 @@ func TestStorageToFrontend(t *testing.T) {
NodeCount: 2,
Type: "lvmthin",
Status: "available",
Pool: "rpool/data",
Total: 1000000000000,
Used: 500000000000,
Free: 500000000000,
@@ -524,6 +525,10 @@ func TestStorageToFrontend(t *testing.T) {
Shared: false,
Enabled: true,
Active: true,
ZFSPool: &ZFSPool{
Name: "rpool",
State: "ONLINE",
},
}
frontend := storage.ToFrontend()
@@ -537,6 +542,9 @@ func TestStorageToFrontend(t *testing.T) {
if frontend.Type != storage.Type {
t.Errorf("Type = %q, want %q", frontend.Type, storage.Type)
}
if frontend.Pool != storage.Pool {
t.Errorf("Pool = %q, want %q", frontend.Pool, storage.Pool)
}
if frontend.Total != storage.Total {
t.Errorf("Total = %d, want %d", frontend.Total, storage.Total)
}
@@ -549,6 +557,9 @@ func TestStorageToFrontend(t *testing.T) {
if frontend.Avail != storage.Free {
t.Errorf("Avail = %d, want %d", frontend.Avail, storage.Free)
}
if frontend.ZFSPool == nil || frontend.ZFSPool.Name != "rpool" {
t.Fatalf("ZFSPool = %#v, want rpool", frontend.ZFSPool)
}
}
func TestHostSensorSummaryToFrontend(t *testing.T) {
@@ -601,7 +612,6 @@ func TestHostSensorSummaryToFrontend(t *testing.T) {
}
}
func TestToDockerHostCommandFrontend(t *testing.T) {
now := time.Now()
dispatched := now.Add(-time.Minute)
+1
View File
@@ -1073,6 +1073,7 @@ type Storage struct {
NodeCount int `json:"nodeCount,omitempty"`
Type string `json:"type"`
Status string `json:"status"`
Pool string `json:"pool,omitempty"`
Path string `json:"path,omitempty"`
Total int64 `json:"total"`
Used int64 `json:"used"`
+6
View File
@@ -695,6 +695,7 @@ type StorageFrontend struct {
NodeCount int `json:"nodeCount,omitempty"`
Type string `json:"type"`
Status string `json:"status"`
Pool string `json:"pool,omitempty"`
Total int64 `json:"total"`
Used int64 `json:"used"`
Avail int64 `json:"avail"` // Maps to Free
@@ -704,6 +705,7 @@ type StorageFrontend struct {
Shared bool `json:"shared"`
Enabled bool `json:"enabled"`
Active bool `json:"active"`
ZFSPool *ZFSPool `json:"zfsPool,omitempty"`
}
func (s StorageFrontend) NormalizeCollections() StorageFrontend {
@@ -713,6 +715,10 @@ func (s StorageFrontend) NormalizeCollections() StorageFrontend {
if s.NodeIDs == nil {
s.NodeIDs = []string{}
}
if s.ZFSPool != nil {
pool := s.ZFSPool.NormalizeCollections()
s.ZFSPool = &pool
}
return s
}
@@ -220,6 +220,26 @@ func TestProxmoxGuestDiskCarryForwardUsesCanonicalStabilityHelper(t *testing.T)
}
}
func TestStoragePollingUsesCanonicalPoolMetadataForZFSAttachment(t *testing.T) {
data, err := os.ReadFile("monitor_polling_storage.go")
if err != nil {
t.Fatalf("failed to read monitor_polling_storage.go: %v", err)
}
source := string(data)
for _, snippet := range []string{
"func matchZFSPoolForStorage(storage models.Storage, zfsPoolMap map[string]*models.ZFSPool) *models.ZFSPool {",
"storage.Pool,",
"Pool: storage.Pool,",
"if modelStorage.Pool == \"\" && clusterConfig.Pool != \"\" {",
"if pool := matchZFSPoolForStorage(modelStorage, zfsPoolMap); pool != nil {",
} {
if !strings.Contains(source, snippet) {
t.Fatalf("monitor_polling_storage.go must contain %q", snippet)
}
}
}
func TestMonitoringTemperatureFallbackUsesSMARTAwareSSHSkipRule(t *testing.T) {
requiredSnippets := map[string][]string{
"host_agent_temps.go": {
+70 -21
View File
@@ -79,6 +79,71 @@ func convertPoolInfoToModel(poolInfo *proxmox.ZFSPoolInfo) *models.ZFSPool {
return modelPool
}
func matchZFSPoolForStorage(storage models.Storage, zfsPoolMap map[string]*models.ZFSPool) *models.ZFSPool {
if len(zfsPoolMap) == 0 {
return nil
}
normalizedPools := make(map[string]*models.ZFSPool, len(zfsPoolMap))
var solePool *models.ZFSPool
for name, pool := range zfsPoolMap {
normalized := strings.ToLower(strings.Trim(strings.TrimSpace(name), "/"))
if normalized == "" || pool == nil {
continue
}
normalizedPools[normalized] = pool
solePool = pool
}
lookupCandidate := func(candidate string) *models.ZFSPool {
normalized := strings.ToLower(strings.Trim(strings.TrimSpace(candidate), "/"))
if normalized == "" {
return nil
}
if pool, ok := normalizedPools[normalized]; ok {
return pool
}
if idx := strings.Index(normalized, "/"); idx > 0 {
if pool, ok := normalizedPools[normalized[:idx]]; ok {
return pool
}
}
return nil
}
candidates := []string{
storage.Pool,
storage.Name,
storage.Path,
}
trimmedPath := strings.Trim(strings.TrimSpace(storage.Path), "/")
if trimmedPath != "" {
candidates = append(candidates, trimmedPath)
if idx := strings.Index(trimmedPath, "/"); idx > 0 {
candidates = append(candidates, trimmedPath[:idx])
}
}
normalizedName := strings.TrimSpace(storage.Name)
if strings.HasSuffix(strings.ToLower(normalizedName), "-zfs") {
candidates = append(candidates, strings.TrimSuffix(normalizedName, "-zfs"))
candidates = append(candidates, strings.TrimSuffix(normalizedName, "-ZFS"))
}
for _, candidate := range candidates {
if pool := lookupCandidate(candidate); pool != nil {
return pool
}
}
if len(normalizedPools) == 1 {
return solePool
}
return nil
}
// 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.
@@ -304,6 +369,7 @@ func (m *Monitor) pollStorageWithNodes(ctx context.Context, instanceName string,
Instance: storageInstanceName,
Type: storage.Type,
Status: "available",
Pool: storage.Pool,
Path: storage.Path,
Total: int64(storage.Total),
Used: int64(storage.Used),
@@ -319,6 +385,9 @@ func (m *Monitor) pollStorageWithNodes(ctx context.Context, instanceName string,
if nodes := parseClusterStorageNodes(clusterConfig.Nodes); len(nodes) > 0 {
modelStorage.Nodes = nodes
}
if modelStorage.Pool == "" && clusterConfig.Pool != "" {
modelStorage.Pool = clusterConfig.Pool
}
if modelStorage.Path == "" && clusterConfig.Path != "" {
modelStorage.Path = clusterConfig.Path
}
@@ -326,28 +395,8 @@ func (m *Monitor) pollStorageWithNodes(ctx context.Context, instanceName string,
// If this is ZFS storage, attach pool status information
if storage.Type == "zfspool" || storage.Type == "zfs" || storage.Type == "local-zfs" {
// Try to match by storage name or by common ZFS pool names
poolName := storage.Storage
// Common mappings
if poolName == "local-zfs" {
poolName = "rpool/data" // Common default
}
// Look for exact match first
if pool, found := zfsPoolMap[poolName]; found {
if pool := matchZFSPoolForStorage(modelStorage, zfsPoolMap); pool != nil {
modelStorage.ZFSPool = pool
} else {
// Try partial matches for common patterns
for name, pool := range zfsPoolMap {
if name == "rpool" && strings.Contains(storage.Storage, "rpool") {
modelStorage.ZFSPool = pool
break
} else if name == "data" && strings.Contains(storage.Storage, "data") {
modelStorage.ZFSPool = pool
break
}
}
}
}
+122 -2
View File
@@ -15,8 +15,9 @@ import (
// fakeStorageClient provides minimal PVE responses needed by the optimized storage poller.
type fakeStorageClient struct {
allStorage []proxmox.Storage
storageByNode map[string][]proxmox.Storage
allStorage []proxmox.Storage
storageByNode map[string][]proxmox.Storage
zfsPoolsByNode map[string][]proxmox.ZFSPoolInfo
}
func (f *fakeStorageClient) GetNodes(ctx context.Context) ([]proxmox.Node, error) {
@@ -122,6 +123,9 @@ func (f *fakeStorageClient) GetZFSPoolStatus(ctx context.Context, node string) (
}
func (f *fakeStorageClient) GetZFSPoolsWithDetails(ctx context.Context, node string) ([]proxmox.ZFSPoolInfo, error) {
if pools, ok := f.zfsPoolsByNode[node]; ok {
return pools, nil
}
return nil, nil
}
@@ -296,3 +300,119 @@ func TestPollStorageWithNodesSynthesizesSharedClusterOnlyStorage(t *testing.T) {
t.Fatalf("expected cluster storage capacity from config, got %+v", *shared)
}
}
func TestPollStorageWithNodesAttachesZFSPoolFromExplicitPoolField(t *testing.T) {
t.Setenv("PULSE_DATA_DIR", t.TempDir())
monitor := &Monitor{
state: &models.State{},
metricsHistory: NewMetricsHistory(16, time.Hour),
alertManager: alerts.NewManager(),
}
t.Cleanup(func() {
monitor.alertManager.Stop()
})
storage := proxmox.Storage{
Storage: "local-zfs",
Type: "zfspool",
Pool: "rpool/data",
Content: "images,rootdir",
Active: 1,
Enabled: 1,
Shared: 0,
Total: 1000,
Used: 250,
Available: 750,
}
client := &fakeStorageClient{
allStorage: []proxmox.Storage{storage},
storageByNode: map[string][]proxmox.Storage{
"node1": {storage},
},
zfsPoolsByNode: map[string][]proxmox.ZFSPoolInfo{
"node1": {
{Name: "rpool", Size: 1000, Alloc: 250, Free: 750, Frag: 1, Dedup: 1.0, Health: "ONLINE"},
},
},
}
nodes := []proxmox.Node{{Node: "node1", Status: "online"}}
monitor.pollStorageWithNodes(context.Background(), "inst1", client, nodes)
if len(monitor.state.Storage) != 1 {
t.Fatalf("expected 1 storage entry, got %d", len(monitor.state.Storage))
}
if got := monitor.state.Storage[0].Pool; got != "rpool/data" {
t.Fatalf("storage pool = %q, want %q", got, "rpool/data")
}
if monitor.state.Storage[0].ZFSPool == nil {
t.Fatal("expected explicit pool field to attach ZFS pool details")
}
if monitor.state.Storage[0].ZFSPool.Name != "rpool" {
t.Fatalf("ZFS pool name = %q, want rpool", monitor.state.Storage[0].ZFSPool.Name)
}
}
func TestPollStorageWithNodesUsesClusterStoragePoolFallback(t *testing.T) {
t.Setenv("PULSE_DATA_DIR", t.TempDir())
monitor := &Monitor{
state: models.NewState(),
config: &config.Config{
PVEInstances: []config.PVEInstance{
{
Name: "inst1",
IsCluster: true,
ClusterName: "cluster-a",
},
},
},
metricsHistory: NewMetricsHistory(16, time.Hour),
alertManager: alerts.NewManager(),
}
t.Cleanup(func() {
monitor.alertManager.Stop()
})
clusterStorage := proxmox.Storage{
Storage: "local-zfs",
Type: "zfspool",
Pool: "rpool/data",
Content: "images,rootdir",
Shared: 0,
Total: 1000,
Used: 250,
Available: 750,
}
nodeStorage := clusterStorage
nodeStorage.Pool = ""
nodeStorage.Active = 1
nodeStorage.Enabled = 1
client := &fakeStorageClient{
allStorage: []proxmox.Storage{clusterStorage},
storageByNode: map[string][]proxmox.Storage{
"node1": {nodeStorage},
},
zfsPoolsByNode: map[string][]proxmox.ZFSPoolInfo{
"node1": {
{Name: "rpool", Size: 1000, Alloc: 250, Free: 750, Frag: 1, Dedup: 1.0, Health: "ONLINE"},
},
},
}
nodes := []proxmox.Node{{Node: "node1", Status: "online"}}
monitor.pollStorageWithNodes(context.Background(), "inst1", client, nodes)
if len(monitor.state.Storage) != 1 {
t.Fatalf("expected 1 storage entry, got %d", len(monitor.state.Storage))
}
if got := monitor.state.Storage[0].Pool; got != "rpool/data" {
t.Fatalf("storage pool = %q, want %q", got, "rpool/data")
}
if monitor.state.Storage[0].ZFSPool == nil || monitor.state.Storage[0].ZFSPool.Name != "rpool" {
t.Fatalf("expected cluster pool fallback to attach rpool, got %#v", monitor.state.Storage[0].ZFSPool)
}
}
+1
View File
@@ -1166,6 +1166,7 @@ func resourceFromStorage(storage models.Storage) (Resource, ResourceIdentity) {
IsCeph: isCephStorageType(storageType),
IsZFS: isZFSStorageType(storageType) || storage.ZFSPool != nil,
Nodes: append([]string(nil), storage.Nodes...),
Pool: storage.Pool,
Path: storage.Path,
ZFSPoolState: zfsPoolState,
ZFSReadErrors: zfsReadErrors,
@@ -190,6 +190,7 @@ func TestResourceFromStorageIncludesStorageMetadata(t *testing.T) {
Node: "pve-1",
Instance: "cluster-a",
Type: "RBD",
Pool: "ceph/rbd-a",
Content: "images, rootdir,images",
Shared: true,
Status: "available",
@@ -211,6 +212,9 @@ func TestResourceFromStorageIncludesStorageMetadata(t *testing.T) {
if got, want := resource.Storage.Content, "images, rootdir,images"; got != want {
t.Fatalf("storage content = %q, want %q", got, want)
}
if got, want := resource.Storage.Pool, "ceph/rbd-a"; got != want {
t.Fatalf("storage pool = %q, want %q", got, want)
}
wantContentTypes := []string{"images", "rootdir"}
if len(resource.Storage.ContentTypes) != len(wantContentTypes) {
t.Fatalf("contentTypes length = %d, want %d (%v)", len(resource.Storage.ContentTypes), len(wantContentTypes), resource.Storage.ContentTypes)
@@ -252,6 +252,40 @@ func TestResourceAPIUsesCanonicalTenantUnifiedSeed(t *testing.T) {
}
}
func TestCanonicalStorageMetadataPreservesBackingPoolField(t *testing.T) {
requiredSnippets := map[string][]string{
"types.go": {
"Pool string `json:\"pool,omitempty\"`",
},
"adapters.go": {
"Pool: storage.Pool,",
},
"views.go": {
"func (v StoragePoolView) Pool() string {",
"return v.r.Storage.Pool",
},
filepath.Join("..", "..", "frontend-modern", "src", "types", "resource.ts"): {
"pool?: string;",
},
filepath.Join("..", "..", "frontend-modern", "src", "hooks", "useUnifiedResources.ts"): {
"pool?: string;",
},
}
for path, snippets := range requiredSnippets {
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("failed to read %s: %v", path, err)
}
source := string(data)
for _, snippet := range snippets {
if !strings.Contains(source, snippet) {
t.Fatalf("%s must contain %q", path, snippet)
}
}
}
}
func TestResourceAPIExposesDedicatedFacetReads(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "api", "resources.go"))
if err != nil {
+1
View File
@@ -280,6 +280,7 @@ type StorageMeta struct {
// Accessibility metadata.
Nodes []string `json:"nodes,omitempty"` // PVE nodes where this storage is accessible
Pool string `json:"pool,omitempty"` // Proxmox backing pool/dataset (for example rpool/data)
Path string `json:"path,omitempty"` // local mount path on the node
// ZFS metadata (when IsZFS is true and the source provides details).
+7
View File
@@ -2082,6 +2082,13 @@ func (v StoragePoolView) AccessibleNodes() []string {
return cloneStringSlice(v.r.Storage.Nodes)
}
func (v StoragePoolView) Pool() string {
if v.r == nil || v.r.Storage == nil {
return ""
}
return v.r.Storage.Pool
}
func (v StoragePoolView) Path() string {
if v.r == nil || v.r.Storage == nil {
return ""
+4
View File
@@ -1037,6 +1037,7 @@ func TestView_StoragePoolViewAccessors(t *testing.T) {
ProtectionReduced: true,
ProtectionSummary: "ZFS pool local-zfs is DEGRADED",
Nodes: []string{"pve-a", "pve-b"},
Pool: "rpool/data",
Path: "/mnt/pve/local-zfs",
ZFSPoolState: "ONLINE",
ZFSReadErrors: 1,
@@ -1068,6 +1069,9 @@ func TestView_StoragePoolViewAccessors(t *testing.T) {
t.Fatalf("expected ZFS fields to match, got state=%q read=%d write=%d cksum=%d", v.ZFSPoolState(), v.ZFSReadErrors(), v.ZFSWriteErrors(), v.ZFSChecksumErrors())
}
assertStringSlice(t, v.AccessibleNodes(), []string{"pve-a", "pve-b"})
if v.Pool() != "rpool/data" {
t.Fatalf("expected Pool %q, got %q", "rpool/data", v.Pool())
}
if v.Path() != "/mnt/pve/local-zfs" {
t.Fatalf("expected Path %q, got %q", "/mnt/pve/local-zfs", v.Path())
}
+1
View File
@@ -1127,6 +1127,7 @@ type Storage struct {
Enabled int `json:"enabled"`
Shared int `json:"shared"`
Nodes string `json:"nodes,omitempty"`
Pool string `json:"pool,omitempty"`
Path string `json:"path,omitempty"`
Total uint64 `json:"total"`
Used uint64 `json:"used"`