mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Forward-port Proxmox storage pool metadata
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -613,6 +613,7 @@ export interface Storage {
|
||||
nodeIds?: string[];
|
||||
nodeCount?: number;
|
||||
pbsNames?: string[];
|
||||
pool?: string;
|
||||
// ZFS pool status
|
||||
zfsPool?: ZFSPool;
|
||||
}
|
||||
|
||||
@@ -260,6 +260,7 @@ export interface ResourceStorageMeta {
|
||||
rebuildInProgress?: boolean;
|
||||
rebuildSummary?: string;
|
||||
nodes?: string[];
|
||||
pool?: string;
|
||||
path?: string;
|
||||
zfsPoolState?: string;
|
||||
zfsReadErrors?: number;
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user