feat(storage): surface PBS host physical disks

This commit is contained in:
courtmanr@gmail.com 2026-07-30 17:19:52 +01:00
parent 6c82a3c47c
commit 32a215203c
21 changed files with 430 additions and 14 deletions

View file

@ -5548,3 +5548,22 @@ default history groups unchanged. Rendered table proof belongs in
`frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx`;
drawer grouping and fallback proof belongs in
`frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.branchcov0712.test.ts`.
### Proxmox Storage reuses the shared product-family source scope
`frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx` mounts the
canonical shared Storage surface for its Storage tab and supplies the hidden
`proxmox-all` filter. Shared storage source matching owns that internal
umbrella and admits normalized PVE, PBS, and other Proxmox-family source keys;
visible source-picker filters remain exact. The page must not create a
PBS-only disk table or duplicate storage state in the Proxmox feature.
The rendered route contract in
`frontend-modern/src/features/proxmox/__tests__/ProxmoxPageSurface.contract.test.tsx`
and the shared-surface source guard in
`frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx`
pin this composition boundary.
ZFS dataset table rows inside `StoragePoolDetail` inherit their separator from
the shared `STORAGE_DETAIL_ROW_CLASS` presentation constant. The component
must not reintroduce a raw border-token class for dataset rows.

View file

@ -4834,3 +4834,24 @@ 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.
The shared Proxmox Storage route uses the internal `proxmox-all` source scope
to include normalized Proxmox-family storage rows and physical disks across
PVE and PBS instead of silently narrowing the workspace to PVE. This umbrella
scope is route-owned and hidden; ordinary source-picker selections remain
exact, so selecting PVE does not admit PBS and selecting PBS does not admit
PVE. Agent-reported SMART disks correlated to a unique PBS host may carry PBS
platform membership for this filter, but their health and device facts remain
agent telemetry rather than PBS API inventory.
`frontend-modern/src/features/storageBackups/__tests__/storageModelCore.test.ts`,
`frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts`,
and `frontend-modern/src/components/Storage/__tests__/storagePageState.test.ts`
pin the record, disk, and node-scope behavior. The Proxmox surface wiring is
also guarded by
`frontend-modern/src/features/proxmox/__tests__/ProxmoxBackupsTable.test.tsx`.
The ZFS pool-detail summary has one stable shape: `datasets` is always an
array, including when the backend reports no dataset inventory. Consumers and
branch proofs must retain the empty array instead of treating dataset support
as an optional return-field contract.

View file

@ -4210,6 +4210,22 @@ provider-local alert state.
`internal/unifiedresources/ceph_pool_health_contract_test.go`, and
`internal/api/resources_pool_health_contract_test.go` pin the normalized and
wire-level contract.
### PBS host physical-disk correlation preserves agent telemetry authority
`internal/unifiedresources/pbs_host_disks.go` may add `pbs` source membership
to an agent host and its agent-reported SMART disk resources only when one
host is uniquely corroborated by the PBS instance name, endpoint hostname, or
endpoint IP against the host report and interface addresses. Multiple matches
are ambiguous and must fail closed without assigning PBS ownership.
PBS does not supply the SMART inventory. Disk identity, health, temperature,
and other typed physical-disk facts therefore remain agent-owned, while the
PBS source mapping and PBS-parent relationship make the existing disk
discoverable in Proxmox Backup Server storage context. Canonical parent and
fact precedence continue to follow the existing source-priority rules.
`internal/unifiedresources/pbs_pmg_registry_test.go` pins the unique-match and
ambiguous-match boundaries.
That same shared-consumer boundary now also owns VMware phase-1 detail
presentation. `frontend-modern/src/components/Infrastructure/`
`resourceDetailDrawerVmwareModel.ts`,

View file

@ -201,7 +201,7 @@ export const StoragePoolDetail: Component<StoragePoolDetailProps> = (props) => {
<tbody>
<For each={zfsSummary()!.datasets}>
{(dataset) => (
<tr class="border-t border-border">
<tr class={STORAGE_DETAIL_ROW_CLASS}>
<td
class="max-w-64 truncate px-2 py-1.5 font-mono"
title={dataset.name}

View file

@ -196,7 +196,14 @@ describe('storagePageState', () => {
expect(storageResourceMatchesSourceFilter(node, 'all')).toBe(true);
expect(storageResourceMatchesSourceFilter(node, 'proxmox-pve')).toBe(true);
expect(storageResourceMatchesSourceFilter(node, 'proxmox-all')).toBe(true);
expect(storageResourceMatchesSourceFilter(node, 'truenas')).toBe(false);
const pbsNode = makeNode({
platformType: 'proxmox-pbs',
platformData: { sources: ['pbs', 'agent'] },
});
expect(storageResourceMatchesSourceFilter(pbsNode, 'proxmox-all')).toBe(true);
});
it('keeps expanded storage groups canonical across data refreshes', () => {

View file

@ -84,6 +84,7 @@ describe('useStoragePoolDetailModel', () => {
scan: 'scrub in progress',
errorSummary: null,
devices: [{ name: 'sda', type: '', state: '', errorSummary: '', message: '' }],
datasets: [],
});
expect(result.linkedDisks()).toEqual([
{

View file

@ -10,6 +10,7 @@ import type { Resource } from '@/types/resource';
import { getProxmoxData } from '@/utils/resourcePlatformData';
import { getResourceIdentityAliases } from '@/utils/resourceIdentity';
import { normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms';
import { storageSourceMatchesFilter } from '@/utils/storageSources';
import { matchesPhysicalDiskNode } from './diskResourceUtils';
import type { StorageGroupKey, StorageSortKey } from './useStorageModel';
import type { StorageRouteStateFields } from './useStorageRouteState';
@ -375,8 +376,8 @@ export const storageResourceMatchesSourceFilter = (
? platformData.sources.filter((source): source is string => typeof source === 'string')
: [];
const candidates = [resource.platformType, resource.sourceType, ...sources];
return candidates.some(
(candidate) => normalizeSourcePlatformQueryValue(candidate || '') === selectedSource,
return candidates.some((candidate) =>
storageSourceMatchesFilter(normalizeSourcePlatformQueryValue(candidate || ''), selectedSource),
);
};

View file

@ -62,7 +62,7 @@ import {
const PROXMOX_RESOURCE_QUERY =
'type=agent,vm,system-container,oci-container,storage,physical_disk,ceph,pbs,pmg';
const PROXMOX_PLATFORM_FILTER = 'proxmox-pve';
const PROXMOX_PLATFORM_FILTER = 'proxmox-all';
const PROXMOX_WORKLOAD_STATUS_STORAGE_SCOPE = 'proxmox';
const PROXMOX_WORKLOAD_EXCLUDED_TYPES = ['app-container'] as const;
const VALID_TABS = new Set<ProxmoxPageTabId>(PROXMOX_TAB_SPECS.map((tab) => tab.id));

View file

@ -377,6 +377,14 @@ describe('ProxmoxBackupsTable', () => {
expect(proxmoxPageSurfaceSource).not.toContain('workloads={workloadsState.allGuests');
});
it('keeps the shared storage surface scoped to the whole Proxmox product family', () => {
expect(proxmoxPageSurfaceSource).toContain("const PROXMOX_PLATFORM_FILTER = 'proxmox-all';");
expect(proxmoxPageSurfaceSource).toContain('forcedSourceFilter={PROXMOX_PLATFORM_FILTER}');
expect(proxmoxPageSurfaceSource).not.toContain(
"const PROXMOX_PLATFORM_FILTER = 'proxmox-pve';",
);
});
it('keeps Patrol coverage out of Proxmox evidence surfaces', () => {
expect(proxmoxPageSurfaceSource).not.toContain('getMonitorContextPatrolProtectionPosture');
expect(proxmoxPageSurfaceSource).not.toContain('getPatrolRunHistory(1)');

View file

@ -6,6 +6,7 @@ import { ProxmoxPageSurface } from '../ProxmoxPageSurface';
const mockUseUnifiedResources = vi.fn();
const mockPathname = vi.hoisted(() => vi.fn(() => '/proxmox/overview'));
const mockVersionInfo = vi.hoisted(() => vi.fn());
const mockStorageProps = vi.hoisted(() => vi.fn());
const makeResource = (resource: Partial<Resource> & Pick<Resource, 'id' | 'type'>): Resource =>
({
@ -61,7 +62,10 @@ vi.mock('@solidjs/router', async () => {
});
vi.mock('@/components/Storage/Storage', () => ({
default: () => <div data-testid="storage-surface" />,
default: (props: { forcedSourceFilter?: string }) => {
mockStorageProps(props);
return <div data-testid="storage-surface" />;
},
}));
vi.mock('@/components/Workloads/WorkloadsFilter', () => ({
@ -132,6 +136,25 @@ describe('ProxmoxPageSurface contract', () => {
vi.clearAllMocks();
});
it('scopes the storage tab to both PVE and PBS resources', () => {
mockPathname.mockReturnValue('/proxmox/storage');
setResources([
makeResource({
id: 'pbs-datastore-1',
type: 'storage',
platformType: 'proxmox-pbs',
sources: ['pbs'],
}),
]);
render(() => <ProxmoxPageSurface />);
expect(screen.getByTestId('storage-surface')).toBeInTheDocument();
expect(mockStorageProps).toHaveBeenCalledWith(
expect.objectContaining({ forcedSourceFilter: 'proxmox-all' }),
);
});
it('surfaces stale agent-backed Proxmox nodes', () => {
mockVersionInfo.mockReturnValue({
version: 'v6.0.0-rc.6',

View file

@ -433,6 +433,27 @@ describe('diskPresentation', () => {
matchesNode: () => true,
}).map((disk) => disk.id),
).toEqual(['disk-warning']);
expect(
filterAndSortPhysicalDisks(
[
warningDisk,
{
...healthyDisk,
id: 'pbs-disk',
platformType: 'proxmox-pbs',
sources: ['agent', 'pbs'],
},
],
{
selectedNode: null,
searchTerm: '',
sourceFilter: 'proxmox-all',
healthFilter: 'all',
getDiskData: (disk) => (disk.id === warningDisk.id ? warningData : healthyData),
matchesNode: () => true,
},
).map((disk) => disk.id),
).toEqual(['disk-warning', 'pbs-disk']);
expect(
filterAndSortPhysicalDisks([warningDisk, healthyDisk], {
selectedNode: null,
@ -461,10 +482,12 @@ describe('wearout evidence boundary', () => {
it('flags a spent SSD but leaves a rotational zero alone', () => {
expect(
getPhysicalDiskHealthStatus(makeDiskData({ health: 'PASSED', wearout: 0, type: 'ssd' })).label,
getPhysicalDiskHealthStatus(makeDiskData({ health: 'PASSED', wearout: 0, type: 'ssd' }))
.label,
).toBe('Needs Attention');
expect(
getPhysicalDiskHealthStatus(makeDiskData({ health: 'PASSED', wearout: 0, type: 'hdd' })).label,
getPhysicalDiskHealthStatus(makeDiskData({ health: 'PASSED', wearout: 0, type: 'hdd' }))
.label,
).toBe('Healthy');
});

View file

@ -120,6 +120,27 @@ describe('storageModelCore', () => {
});
});
it('keeps both PVE pools and PBS datastores in the Proxmox workspace scope', () => {
const pbs = makeRecord({
id: 'pbs-datastore',
source: {
platform: 'proxmox-pbs',
family: 'virtualization',
origin: 'resource',
adapterId: 'resource-storage',
},
});
expect(
filterStorageRecords([makeRecord(), pbs], {
search: '',
sourceFilter: 'proxmox-all',
healthFilter: 'all',
selectedNode: null,
}).map((record) => record.id),
).toEqual(['storage-1', 'pbs-datastore']);
});
it('sorts storage records by the visible pool-table columns', () => {
const alpha = makeRecord({
id: 'storage-alpha',

View file

@ -258,6 +258,7 @@ describe('buildStoragePoolDetailZfsSummary branch coverage', () => {
scan: '',
errorSummary: null,
devices: [],
datasets: [],
});
});
@ -279,6 +280,7 @@ describe('buildStoragePoolDetailZfsSummary branch coverage', () => {
scan: '',
errorSummary: null,
devices: [],
datasets: [],
});
});
@ -300,6 +302,7 @@ describe('buildStoragePoolDetailZfsSummary branch coverage', () => {
scan: 'scrub ok',
errorSummary: null,
devices: [{ name: 'sdc', type: '', state: '', errorSummary: '', message: '' }],
datasets: [],
});
});
@ -321,6 +324,7 @@ describe('buildStoragePoolDetailZfsSummary branch coverage', () => {
scan: '',
errorSummary: 'Errors: R:1 W:0 C:0',
devices: [],
datasets: [],
});
});
});

View file

@ -11,7 +11,7 @@ import {
import { getAllFilterOptionLabel } from '@/components/shared/filterOptionPresentation';
import { getPhysicalDiskNodeIdentity } from '@/components/Storage/diskResourceUtils';
import { getInfrastructureSettingsLocationLabel } from '@/utils/infrastructureSettingsPresentation';
import { normalizeStorageSourceKey } from '@/utils/storageSources';
import { normalizeStorageSourceKey, storageSourceMatchesFilter } from '@/utils/storageSources';
import type { NormalizedHealth, StorageHealthFilter } from './models';
import { matchesStorageNodeTerms, parseStorageSearchQuery } from './storageSearchQuery';
@ -452,7 +452,7 @@ export function matchesPhysicalDiskFilterState(
},
): boolean {
const selectedSource = normalizeStorageSourceKey(options.sourceFilter || 'all');
if (selectedSource !== 'all' && getPhysicalDiskSourceKey(resource) !== selectedSource) {
if (!storageSourceMatchesFilter(getPhysicalDiskSourceKey(resource), selectedSource)) {
return false;
}

View file

@ -1,5 +1,9 @@
import type { NormalizedHealth, StorageHealthFilter, StorageRecord } from './models';
import { normalizeStorageSourceKey, orderStorageSourceKeys } from '@/utils/storageSources';
import {
normalizeStorageSourceKey,
orderStorageSourceKeys,
storageSourceMatchesFilter,
} from '@/utils/storageSources';
import { matchesStorageNodeTerms, parseStorageSearchQuery } from './storageSearchQuery';
import type { StorageCapacityDeltaPresentation } from './storageCapacityDeltaPresentation';
import { resolveStorageRecordMetricResourceId } from './storageMetricsIdentity';
@ -146,9 +150,7 @@ export const filterStorageRecords = (
const selectedSource = normalizeStorageSourceKey(options.sourceFilter);
return records
.filter((record) =>
selectedSource === 'all'
? true
: normalizeStorageSourceKey(record.source.platform) === selectedSource,
storageSourceMatchesFilter(normalizeStorageSourceKey(record.source.platform), selectedSource),
)
.filter((record) => matchesStorageHealthFilter(record.health, options.healthFilter))
.filter((record) => matchesStorageRecordNode(record, options.selectedNode))

View file

@ -1605,7 +1605,7 @@ describe('frontend resource type boundaries', () => {
);
expect(storageDetailKeyValueRowSource).toContain('STORAGE_DETAIL_KEY_VALUE_ROW_CLASS');
expect(storageDetailMetricCardSource).toContain('STORAGE_DETAIL_CARD_CLASS');
expect(storagePoolDetailSource).toContain('STORAGE_DETAIL_ROW_CLASS');
expect(storagePoolDetailSource).toContain('<tr class={STORAGE_DETAIL_ROW_CLASS}>');
expect(storagePoolDetailSource).not.toContain('border-t border-border');
expect(storagePoolDetailSource).not.toContain('col-span-2');
expect(storagePoolDetailSource).not.toContain('text-base-content truncate flex-1');

View file

@ -6,6 +6,7 @@ import {
normalizeStorageSourceKey,
orderStorageSourceKeys,
resolveStorageSourceKey,
storageSourceMatchesFilter,
type StorageSourceOption,
} from '@/utils/storageSources';
@ -29,6 +30,14 @@ const makeStorage = (overrides: Partial<Pick<Storage, 'platform' | 'type'>>): St
}) as Storage;
describe('storageSources', () => {
it('matches the hidden Proxmox workspace scope across PVE, PBS, and PMG', () => {
expect(storageSourceMatchesFilter('proxmox-pve', 'proxmox-all')).toBe(true);
expect(storageSourceMatchesFilter('proxmox-pbs', 'proxmox-all')).toBe(true);
expect(storageSourceMatchesFilter('proxmox-pmg', 'proxmox-all')).toBe(true);
expect(storageSourceMatchesFilter('truenas', 'proxmox-all')).toBe(false);
expect(storageSourceMatchesFilter('proxmox-pbs', 'proxmox-pve')).toBe(false);
});
describe('normalizeStorageSourceKey', () => {
it.each([
['k8s', 'kubernetes'],

View file

@ -74,6 +74,17 @@ export const normalizeStorageSourceKey = (value: string | null | undefined): str
}
};
export const storageSourceMatchesFilter = (
source: string | null | undefined,
filter: string | null | undefined,
): boolean => {
const sourceKey = normalizeStorageSourceKey(source);
const filterKey = normalizeStorageSourceKey(filter);
if (!filterKey || filterKey === 'all') return true;
if (filterKey === 'proxmox-all') return sourceKey.startsWith('proxmox-');
return sourceKey === filterKey;
};
export const resolveStorageSourceKey = (storage: Storage): string => {
// Prefer the explicit canonical platform tag when the storage adapter
// already declared it (TrueNAS, VMware, Hyper-V, etc. set

View file

@ -0,0 +1,123 @@
package unifiedresources
import (
"fmt"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
)
// associatePBSHostAgentResources projects a corroborated PBS host-agent
// relationship onto the existing host and physical-disk resources. PBS does
// not expose SMART inventory through its API, so the disk facts remain
// agent-owned; the PBS source membership only makes their platform ownership
// explicit for shared Proxmox storage consumers.
func (rr *ResourceRegistry) associatePBSHostAgentResources(
instance models.PBSInstance,
hosts []models.Host,
) {
host := uniquePBSHostAgent(instance, hosts)
if host == nil {
return
}
pbsSourceID := pbsInstanceSourceID(instance)
pbsParentID := rr.sourceResourceID(SourcePBS, pbsSourceID)
if pbsSourceID == "" || pbsParentID == "" {
return
}
rr.mu.Lock()
defer rr.mu.Unlock()
if rr.bySource[SourcePBS] == nil {
rr.bySource[SourcePBS] = make(map[string]string)
}
attach := func(sourceID, canonicalID, parentID string) {
sourceID = normalizeSourceID(sourceID)
canonicalID = CanonicalResourceID(canonicalID)
if sourceID == "" || canonicalID == "" {
return
}
resource := rr.resources[canonicalID]
if resource == nil {
return
}
resource.Sources = addSource(resource.Sources, SourcePBS)
if resource.SourceStatus == nil {
resource.SourceStatus = make(map[DataSource]SourceStatus)
}
resource.SourceStatus[SourcePBS] = SourceStatus{
Status: sourceSightingStatus(instance.LastSeen),
LastSeen: instance.LastSeen,
}
if parentID != "" {
rr.setSourceParent(resource, SourcePBS, &parentID)
resource.ParentID = rr.resolveCanonicalParentID(resource)
}
rr.bySource[SourcePBS][sourceID] = canonicalID
}
hostCanonicalID := rr.bySource[SourceAgent][normalizeSourceID(host.ID)]
attach(
fmt.Sprintf("%s/agent:%s", pbsSourceID, strings.TrimSpace(host.ID)),
hostCanonicalID,
"",
)
for _, disk := range host.Sensors.SMART {
agentDiskSourceID := HostSMARTDiskSourceID(*host, disk)
diskCanonicalID := rr.bySource[SourceAgent][normalizeSourceID(agentDiskSourceID)]
attach(
fmt.Sprintf("%s/disk:%s", pbsSourceID, agentDiskSourceID),
diskCanonicalID,
pbsParentID,
)
}
rr.viewsDirty = true
}
func uniquePBSHostAgent(instance models.PBSInstance, hosts []models.Host) *models.Host {
var match *models.Host
for index := range hosts {
if !pbsInstanceCorroboratesHost(instance, hosts[index]) {
continue
}
if match != nil {
return nil
}
match = &hosts[index]
}
return match
}
func pbsInstanceCorroboratesHost(instance models.PBSInstance, host models.Host) bool {
hostName := NormalizeHostname(host.Hostname)
if hostName == "" {
return false
}
if instanceName := NormalizeHostname(instance.Name); instanceName != "" && instanceName == hostName {
return true
}
endpoint := strings.TrimSpace(strings.ToLower(extractHostname(instance.Host)))
if endpoint == "" {
return false
}
if ip := NormalizeIP(endpoint); ip != "" {
if NormalizeIP(host.ReportIP) == ip {
return true
}
for _, iface := range host.NetworkInterfaces {
for _, address := range iface.Addresses {
if NormalizeIP(address) == ip {
return true
}
}
}
return false
}
return NormalizeHostname(endpoint) == hostName
}

View file

@ -191,3 +191,129 @@ func TestIngestSnapshotIncludesPBSAndPMGInstances(t *testing.T) {
t.Fatalf("expected PMG mail count points, got %+v", pmgResource.PMG.MailCount)
}
}
func TestIngestSnapshotAssociatesPBSHostAgentPhysicalDisks(t *testing.T) {
now := time.Now().UTC()
registry := NewRegistry(nil)
registry.IngestSnapshot(models.StateSnapshot{
Hosts: []models.Host{
{
ID: "agent-pbs-1",
Hostname: "pbs-one.local",
MachineID: "machine-pbs-1",
Status: "online",
LastSeen: now,
Sensors: models.HostSensorSummary{
SMART: []models.HostDiskSMART{
{
Device: "/dev/sda",
Model: "Backup Drive",
Serial: "PBS-DISK-1",
Health: "PASSED",
Temperature: 36,
},
},
},
},
},
PBSInstances: []models.PBSInstance{
{
ID: "pbs-1",
Name: "pbs-one",
Host: "https://pbs-one.example:8007",
Status: "online",
LastSeen: now,
},
},
})
var hostResource *Resource
var diskResource *Resource
for _, resource := range registry.List() {
switch resource.Type {
case ResourceTypeAgent:
if resource.Agent != nil && resource.Agent.AgentID == "agent-pbs-1" {
copy := resource
hostResource = &copy
}
case ResourceTypePhysicalDisk:
if resource.PhysicalDisk != nil && resource.PhysicalDisk.Serial == "PBS-DISK-1" {
copy := resource
diskResource = &copy
}
}
}
if hostResource == nil || diskResource == nil {
t.Fatalf("expected correlated PBS host and disk, host=%+v disk=%+v", hostResource, diskResource)
}
if !containsDataSource(hostResource.Sources, SourcePBS) {
t.Fatalf("PBS host sources = %v, want PBS membership", hostResource.Sources)
}
if !containsDataSource(diskResource.Sources, SourceAgent) ||
!containsDataSource(diskResource.Sources, SourcePBS) {
t.Fatalf("PBS disk sources = %v, want agent and PBS", diskResource.Sources)
}
if diskResource.ParentID == nil || *diskResource.ParentID != hostResource.ID {
t.Fatalf(
"PBS disk parent = %v, want agent host %q to retain canonical parent authority",
diskResource.ParentID,
hostResource.ID,
)
}
RefreshCanonicalMetadata(diskResource)
if !containsPlatformScope(diskResource.PlatformScopes, "proxmox-pbs") {
t.Fatalf("PBS disk platform scopes = %v, want proxmox-pbs", diskResource.PlatformScopes)
}
}
func TestIngestSnapshotSkipsAmbiguousPBSHostAgentAssociation(t *testing.T) {
now := time.Now().UTC()
hosts := []models.Host{
{
ID: "agent-pbs-a",
Hostname: "pbs-one.local",
Status: "online",
LastSeen: now,
Sensors: models.HostSensorSummary{
SMART: []models.HostDiskSMART{{Device: "/dev/sda", Serial: "PBS-A"}},
},
},
{
ID: "agent-pbs-b",
Hostname: "pbs-one.example",
Status: "online",
LastSeen: now,
Sensors: models.HostSensorSummary{
SMART: []models.HostDiskSMART{{Device: "/dev/sdb", Serial: "PBS-B"}},
},
},
}
registry := NewRegistry(nil)
registry.IngestSnapshot(models.StateSnapshot{
Hosts: hosts,
PBSInstances: []models.PBSInstance{
{
ID: "pbs-1",
Name: "pbs-one",
Host: "https://pbs-one.example:8007",
Status: "online",
LastSeen: now,
},
},
})
for _, resource := range registry.ListByType(ResourceTypePhysicalDisk) {
if containsDataSource(resource.Sources, SourcePBS) {
t.Fatalf("ambiguous PBS host disk gained PBS source membership: %+v", resource)
}
}
}
func containsPlatformScope(values []string, expected string) bool {
for _, value := range values {
if value == expected {
return true
}
}
return false
}

View file

@ -211,6 +211,7 @@ func (rr *ResourceRegistry) ingestSnapshot(snapshot models.StateSnapshot, thresh
}
for _, instance := range snapshot.PBSInstances {
rr.ingestPBSInstance(instance)
rr.associatePBSHostAgentResources(instance, snapshot.Hosts)
}
for _, instance := range snapshot.PMGInstances {
rr.ingestPMGInstance(instance)