Add vSphere network inventory

Project vCenter network inventory through canonical resources and add the vSphere Networks table backed by vCenter network topology. Align resource presentation coalescing so state and resource APIs share the same host contract.
This commit is contained in:
rcourtman
2026-05-22 20:26:56 +01:00
parent 6c7d64ae43
commit 5016cbc2ba
65 changed files with 2025 additions and 437 deletions
@@ -250,13 +250,11 @@ func TestBroadcastResourceProjectionCoalescesSplitHostIdentities(t *testing.T) {
for _, snippet := range []string{
"metricsTargetResolver := broadcastMetricsTargetResolver(unifiedView.readState)",
"broadcastResources := coalesceBroadcastResources(unifiedView.resources)",
"broadcastResources := unifiedresources.CoalescePresentationHostResources(unifiedView.resources)",
"frontendState.Resources = convertResourcesForBroadcast(broadcastResources, metricsTargetResolver)",
"frontendState.ConnectedInfrastructure = buildConnectedInfrastructure(broadcastResources, snapshot)",
"func coalesceBroadcastResources(resources []unifiedresources.Resource) []unifiedresources.Resource {",
"func attachBroadcastMetricsTargets(",
"func shouldMergeBroadcastHostResources(left, right unifiedresources.Resource) bool {",
"broadcastHasSource(sources, unifiedresources.SourceAgent) && broadcastHasRuntimePlatformSource(sources)",
"allResources = unifiedresources.CoalescePresentationHostResources(allResources)",
} {
if !strings.Contains(source, snippet) {
t.Fatalf("monitor.go must contain %q", snippet)
+2 -315
View File
@@ -3728,7 +3728,7 @@ func (m *Monitor) buildBroadcastFrontendStateFromSnapshot(snapshot models.StateS
}
unifiedView := m.currentUnifiedStateView()
metricsTargetResolver := broadcastMetricsTargetResolver(unifiedView.readState)
broadcastResources := coalesceBroadcastResources(unifiedView.resources)
broadcastResources := unifiedresources.CoalescePresentationHostResources(unifiedView.resources)
frontendState.Resources = convertResourcesForBroadcast(broadcastResources, metricsTargetResolver)
frontendState.ConnectedInfrastructure = buildConnectedInfrastructure(broadcastResources, snapshot)
if !unifiedView.freshness.IsZero() {
@@ -4909,319 +4909,6 @@ func (m *Monitor) getResourcesForBroadcast() []models.ResourceFrontend {
)
}
func coalesceBroadcastResources(resources []unifiedresources.Resource) []unifiedresources.Resource {
if len(resources) == 0 {
return resources
}
coalesced := make([]unifiedresources.Resource, 0, len(resources))
indexByHostKey := make(map[string]int, len(resources))
for _, resource := range resources {
resource.Type = unifiedresources.CanonicalResourceType(resource.Type)
hostKey := broadcastHostMergeKey(resource)
if hostKey == "" {
coalesced = append(coalesced, resource)
continue
}
existingIndex, ok := indexByHostKey[hostKey]
if !ok {
indexByHostKey[hostKey] = len(coalesced)
coalesced = append(coalesced, resource)
continue
}
existing := coalesced[existingIndex]
if !shouldMergeBroadcastHostResources(existing, resource) {
coalesced = append(coalesced, resource)
continue
}
coalesced[existingIndex] = mergeBroadcastHostResources(existing, resource)
}
return coalesced
}
func broadcastHostMergeKey(resource unifiedresources.Resource) string {
if unifiedresources.CanonicalResourceType(resource.Type) != unifiedresources.ResourceTypeAgent {
return ""
}
candidates := []string{}
if resource.Canonical != nil {
candidates = append(candidates, resource.Canonical.PlatformID, resource.Canonical.Hostname)
}
candidates = append(candidates, resource.Identity.Hostnames...)
if resource.Agent != nil {
candidates = append(candidates, resource.Agent.Hostname)
}
if resource.Proxmox != nil {
candidates = append(candidates, resource.Proxmox.NodeName)
}
candidates = append(candidates, resource.Name)
for _, candidate := range candidates {
normalized := unifiedresources.NormalizeHostname(candidate)
if normalized != "" {
return "agent:" + normalized
}
}
return ""
}
func shouldMergeBroadcastHostResources(left, right unifiedresources.Resource) bool {
if unifiedresources.CanonicalResourceType(left.Type) != unifiedresources.ResourceTypeAgent ||
unifiedresources.CanonicalResourceType(right.Type) != unifiedresources.ResourceTypeAgent {
return false
}
sources := mergeBroadcastSources(broadcastResourceSources(left), broadcastResourceSources(right))
return broadcastHasSource(sources, unifiedresources.SourceAgent) && broadcastHasRuntimePlatformSource(sources)
}
func broadcastResourceSources(resource unifiedresources.Resource) []unifiedresources.DataSource {
sources := append([]unifiedresources.DataSource(nil), resource.Sources...)
for source := range resource.SourceStatus {
sources = append(sources, source)
}
if resource.Agent != nil {
sources = append(sources, unifiedresources.SourceAgent)
}
if resource.Proxmox != nil {
sources = append(sources, unifiedresources.SourceProxmox)
}
if resource.Docker != nil {
sources = append(sources, unifiedresources.SourceDocker)
}
if resource.Kubernetes != nil {
sources = append(sources, unifiedresources.SourceK8s)
}
if resource.VMware != nil {
sources = append(sources, unifiedresources.SourceVMware)
}
if resource.TrueNAS != nil {
sources = append(sources, unifiedresources.SourceTrueNAS)
}
return mergeBroadcastSources(nil, sources)
}
func broadcastHasRuntimePlatformSource(sources []unifiedresources.DataSource) bool {
for _, source := range []unifiedresources.DataSource{
unifiedresources.SourceProxmox,
unifiedresources.SourceDocker,
unifiedresources.SourceK8s,
unifiedresources.SourceVMware,
unifiedresources.SourceTrueNAS,
} {
if broadcastHasSource(sources, source) {
return true
}
}
return false
}
func broadcastHasSource(sources []unifiedresources.DataSource, target unifiedresources.DataSource) bool {
for _, source := range sources {
if source == target {
return true
}
}
return false
}
func mergeBroadcastSources(left, right []unifiedresources.DataSource) []unifiedresources.DataSource {
merged := make([]unifiedresources.DataSource, 0, len(left)+len(right))
seen := make(map[unifiedresources.DataSource]struct{}, len(left)+len(right))
for _, source := range append(append([]unifiedresources.DataSource(nil), left...), right...) {
if strings.TrimSpace(string(source)) == "" {
continue
}
if _, ok := seen[source]; ok {
continue
}
seen[source] = struct{}{}
merged = append(merged, source)
}
return merged
}
func mergeBroadcastHostResources(left, right unifiedresources.Resource) unifiedresources.Resource {
primary, secondary := left, right
if preferBroadcastHostPrimary(right, left) {
primary, secondary = right, left
}
merged := primary
merged.Sources = mergeBroadcastSources(broadcastResourceSources(primary), broadcastResourceSources(secondary))
merged.SourceStatus = mergeBroadcastSourceStatus(primary.SourceStatus, secondary.SourceStatus, merged.Sources, primary.LastSeen, secondary.LastSeen)
merged.Identity = mergeBroadcastIdentity(primary.Identity, secondary.Identity)
if merged.Agent == nil {
merged.Agent = secondary.Agent
}
if merged.Proxmox == nil {
merged.Proxmox = secondary.Proxmox
}
if merged.Docker == nil {
merged.Docker = secondary.Docker
}
if merged.Kubernetes == nil {
merged.Kubernetes = secondary.Kubernetes
}
if merged.VMware == nil {
merged.VMware = secondary.VMware
}
if merged.TrueNAS == nil {
merged.TrueNAS = secondary.TrueNAS
}
if merged.Storage == nil {
merged.Storage = secondary.Storage
}
if merged.Metrics == nil {
merged.Metrics = secondary.Metrics
} else if secondary.Metrics != nil {
merged.Metrics = mergeBroadcastMetrics(merged.Metrics, secondary.Metrics)
}
if merged.DiscoveryTarget == nil {
merged.DiscoveryTarget = secondary.DiscoveryTarget
}
if merged.MetricsTarget == nil {
merged.MetricsTarget = secondary.MetricsTarget
}
if merged.Canonical == nil {
merged.Canonical = secondary.Canonical
}
merged.Tags = uniqueBroadcastStrings(append(append([]string(nil), secondary.Tags...), primary.Tags...))
merged.Incidents = append(append([]unifiedresources.ResourceIncident(nil), secondary.Incidents...), primary.Incidents...)
if secondary.LastSeen.After(merged.LastSeen) {
merged.LastSeen = secondary.LastSeen
}
if secondary.UpdatedAt.After(merged.UpdatedAt) {
merged.UpdatedAt = secondary.UpdatedAt
}
merged.Status = betterBroadcastStatus(merged.Status, secondary.Status)
return merged
}
func preferBroadcastHostPrimary(candidate, other unifiedresources.Resource) bool {
candidateHasAgent := broadcastHasSource(broadcastResourceSources(candidate), unifiedresources.SourceAgent)
otherHasAgent := broadcastHasSource(broadcastResourceSources(other), unifiedresources.SourceAgent)
if candidateHasAgent != otherHasAgent {
return candidateHasAgent
}
if candidate.LastSeen.Equal(other.LastSeen) {
return strings.TrimSpace(candidate.ID) < strings.TrimSpace(other.ID)
}
return candidate.LastSeen.After(other.LastSeen)
}
func mergeBroadcastSourceStatus(
left, right map[unifiedresources.DataSource]unifiedresources.SourceStatus,
sources []unifiedresources.DataSource,
leftLastSeen time.Time,
rightLastSeen time.Time,
) map[unifiedresources.DataSource]unifiedresources.SourceStatus {
merged := make(map[unifiedresources.DataSource]unifiedresources.SourceStatus, len(sources))
for source, status := range right {
merged[source] = status
}
for source, status := range left {
merged[source] = status
}
for _, source := range sources {
if _, ok := merged[source]; ok {
continue
}
lastSeen := leftLastSeen
if rightLastSeen.After(lastSeen) {
lastSeen = rightLastSeen
}
merged[source] = unifiedresources.SourceStatus{Status: "online", LastSeen: lastSeen}
}
return merged
}
func mergeBroadcastIdentity(left, right unifiedresources.ResourceIdentity) unifiedresources.ResourceIdentity {
merged := left
if merged.MachineID == "" {
merged.MachineID = right.MachineID
}
if merged.DMIUUID == "" {
merged.DMIUUID = right.DMIUUID
}
if merged.ClusterName == "" {
merged.ClusterName = right.ClusterName
}
merged.Hostnames = uniqueBroadcastStrings(append(append([]string(nil), left.Hostnames...), right.Hostnames...))
merged.IPAddresses = uniqueBroadcastStrings(append(append([]string(nil), left.IPAddresses...), right.IPAddresses...))
merged.MACAddresses = uniqueBroadcastStrings(append(append([]string(nil), left.MACAddresses...), right.MACAddresses...))
return merged
}
func uniqueBroadcastStrings(values []string) []string {
if len(values) == 0 {
return nil
}
unique := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
unique = append(unique, trimmed)
}
return unique
}
func mergeBroadcastMetrics(left, right *unifiedresources.ResourceMetrics) *unifiedresources.ResourceMetrics {
if left == nil {
return right
}
if right == nil {
return left
}
merged := *left
if merged.CPU == nil {
merged.CPU = right.CPU
}
if merged.Memory == nil {
merged.Memory = right.Memory
}
if merged.Disk == nil {
merged.Disk = right.Disk
}
if merged.NetIn == nil {
merged.NetIn = right.NetIn
}
if merged.NetOut == nil {
merged.NetOut = right.NetOut
}
if merged.DiskRead == nil {
merged.DiskRead = right.DiskRead
}
if merged.DiskWrite == nil {
merged.DiskWrite = right.DiskWrite
}
return &merged
}
func betterBroadcastStatus(left, right unifiedresources.ResourceStatus) unifiedresources.ResourceStatus {
rank := map[unifiedresources.ResourceStatus]int{
unifiedresources.StatusOnline: 4,
unifiedresources.StatusWarning: 3,
unifiedresources.StatusUnknown: 2,
unifiedresources.StatusOffline: 1,
}
if rank[right] > rank[left] {
return right
}
return left
}
// convertResourcesForBroadcast converts unified resources into the frontend payload shape.
func convertResourcesForBroadcast(
allResources []unifiedresources.Resource,
@@ -5234,7 +4921,7 @@ func convertResourcesForBroadcast(
allResources,
firstBroadcastMetricsTargetResolver(metricsTargetResolvers),
)
allResources = coalesceBroadcastResources(allResources)
allResources = unifiedresources.CoalescePresentationHostResources(allResources)
type broadcastResource struct {
input models.ResourceConvertInput
sortKey string
+4
View File
@@ -42,6 +42,7 @@ type VMwareConnectionObservedSummary struct {
Hosts int `json:"hosts"`
VMs int `json:"vms"`
Datastores int `json:"datastores"`
Networks int `json:"networks"`
VIRelease string `json:"viRelease,omitempty"`
Degraded bool `json:"degraded,omitempty"`
IssueCount int `json:"issueCount,omitempty"`
@@ -867,6 +868,7 @@ func (p *VMwarePoller) RecordConnectionTestSuccess(orgID, connID string, summary
Hosts: summary.Hosts,
VMs: summary.VMs,
Datastores: summary.Datastores,
Networks: summary.Networks,
VIRelease: strings.TrimSpace(summary.VIRelease),
}
status.observedIssueKey = ""
@@ -901,6 +903,7 @@ func buildVMwareObservedSummary(snapshot *vmware.InventorySnapshot) *VMwareConne
Hosts: len(snapshot.Hosts),
VMs: len(snapshot.VMs),
Datastores: len(snapshot.Datastores),
Networks: len(snapshot.Networks),
VIRelease: strings.TrimSpace(snapshot.VIRelease),
}
if len(snapshot.EnrichmentIssues) > 0 {
@@ -977,6 +980,7 @@ func cloneVMwareObservedSummary(value *VMwareConnectionObservedSummary) *VMwareC
Hosts: value.Hosts,
VMs: value.VMs,
Datastores: value.Datastores,
Networks: value.Networks,
VIRelease: strings.TrimSpace(value.VIRelease),
Degraded: value.Degraded,
IssueCount: value.IssueCount,
+2 -1
View File
@@ -206,6 +206,7 @@ func TestVMwarePollerConnectionSummariesCaptureFailuresWithoutClearingObservedSu
Hosts: 2,
VMs: 14,
Datastores: 3,
Networks: 5,
VIRelease: "8.0.3",
}, successAt)
poller.RecordConnectionTestFailure("default", connection.ID, &vmware.ConnectionError{
@@ -220,7 +221,7 @@ func TestVMwarePollerConnectionSummariesCaptureFailuresWithoutClearingObservedSu
if summary.Poll.ConsecutiveFailures != 1 || summary.Poll.LastError.Category != "permission" {
t.Fatalf("unexpected poll failure details: %+v", summary.Poll)
}
if summary.Observed == nil || summary.Observed.VMs != 14 || summary.Observed.VIRelease != "8.0.3" {
if summary.Observed == nil || summary.Observed.VMs != 14 || summary.Observed.Networks != 5 || summary.Observed.VIRelease != "8.0.3" {
t.Fatalf("expected observed summary to be preserved after failure, got %+v", summary.Observed)
}
}