mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 02:28:59 +00:00
feat(storage): surface PBS host physical disks
This commit is contained in:
parent
6c82a3c47c
commit
32a215203c
21 changed files with 430 additions and 14 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ describe('useStoragePoolDetailModel', () => {
|
|||
scan: 'scrub in progress',
|
||||
errorSummary: null,
|
||||
devices: [{ name: 'sda', type: '', state: '', errorSummary: '', message: '' }],
|
||||
datasets: [],
|
||||
});
|
||||
expect(result.linkedDisks()).toEqual([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
123
internal/unifiedresources/pbs_host_disks.go
Normal file
123
internal/unifiedresources/pbs_host_disks.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 = ©
|
||||
}
|
||||
case ResourceTypePhysicalDisk:
|
||||
if resource.PhysicalDisk != nil && resource.PhysicalDisk.Serial == "PBS-DISK-1" {
|
||||
copy := resource
|
||||
diskResource = ©
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue