Files
pulse/internal/unifiedresources/adapters.go
T
rcourtman f8c5c55d11 Let --report-ip lead host identity and pass it through the installer
An explicit --report-ip is the user naming the primary address on a
multi-NIC host, but identityFromHost appended it after the auto-detected
interface addresses while every consumer of ResourceIdentity.IPAddresses
treats the first entry as primary, so the override never changed what
the Machines table displayed. Prepend it instead.

The install script also rejected --report-ip as an unknown argument even
though the agent supports the flag, forcing hand edits to the service
unit that a later --update run would drop. Accept the flag, render it
into the service ExecStart, persist it in connection state, and
recognise it during saved-state and arg-stream recovery so updates
preserve it.

Refs #829

Contract-Neutral: behavioral fix: user-specified report-ip leads host identity addresses and the installer passes --report-ip through; no public contract change (#829)
2026-07-22 00:49:43 +01:00

3853 lines
127 KiB
Go

package unifiedresources
import (
"fmt"
"net"
"net/url"
"strconv"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt"
"github.com/rcourtman/pulse-go-rewrite/internal/platformsupport"
"github.com/rcourtman/pulse-go-rewrite/internal/storagehealth"
"github.com/rcourtman/pulse-go-rewrite/pkg/diskinventory"
)
func resourceFromProxmoxNode(node models.Node, linkedHost *models.Host) (Resource, ResourceIdentity) {
name := node.Name
if node.DisplayName != "" {
name = node.DisplayName
}
endpointHost := extractHostname(node.Host)
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{node.Name}),
ClusterName: node.ClusterName,
}
if endpointHost != "" {
if parsed := net.ParseIP(endpointHost); parsed != nil {
identity.IPAddresses = uniqueStrings([]string{parsed.String()})
} else {
identity.Hostnames = uniqueStrings(append(identity.Hostnames, endpointHost))
}
}
if node.ClusterName != "" {
identity.Hostnames = uniqueStrings(append(identity.Hostnames, node.ClusterName+":"+node.Name))
}
if linkedHost != nil {
identity = mergeIdentity(identity, identityFromHost(*linkedHost))
}
linkedAgentID := strings.TrimSpace(node.LinkedAgentID)
if linkedAgentID == "" && linkedHost != nil {
linkedAgentID = strings.TrimSpace(linkedHost.ID)
}
proxmox := &ProxmoxData{
SourceID: node.ID,
NodeName: node.Name,
ClusterName: node.ClusterName,
IsClusterMember: node.IsClusterMember,
Instance: node.Instance,
HostURL: node.Host,
GuestURL: node.GuestURL,
ConnectionHealth: node.ConnectionHealth,
Temperature: maxNodeTemp(node.Temperature),
TemperatureDetails: cloneTemperature(node.Temperature),
PVEVersion: node.PVEVersion,
KernelVersion: node.KernelVersion,
Uptime: node.Uptime,
CPUInfo: &CPUInfo{Model: node.CPUInfo.Model, Cores: node.CPUInfo.Cores, Sockets: node.CPUInfo.Sockets},
LoadAverage: append([]float64(nil), node.LoadAverage...),
PendingUpdates: node.PendingUpdates,
TemperatureMonitoringEnabled: cloneBoolPtr(node.TemperatureMonitoringEnabled),
PendingUpdatesCheckedAt: zeroTimeToPtr(node.PendingUpdatesCheckedAt),
MemoryCache: node.Memory.Cache,
SwapUsed: node.Memory.SwapUsed,
SwapTotal: node.Memory.SwapTotal,
LinkedAgentID: linkedAgentID,
}
metrics := metricsFromProxmoxNode(node)
resource := Resource{
Type: ResourceTypeAgent,
Technology: "proxmox",
Name: name,
Status: statusFromString(node.Status),
LastSeen: node.LastSeen,
UpdatedAt: time.Now().UTC(),
Metrics: metrics,
Uptime: node.Uptime,
Temperature: proxmox.Temperature,
Proxmox: proxmox,
Tags: nil,
}
return resource, identity
}
func identityFromHost(host models.Host) ResourceIdentity {
ips, macs := collectInterfaceIDs(host.NetworkInterfaces)
if host.ReportIP != "" {
// --report-ip is the user naming the primary address on a multi-NIC
// host, and consumers treat the first entry as primary, so it must
// lead the list rather than trail the auto-detected ones (#829).
ips = append([]string{host.ReportIP}, ips...)
}
return ResourceIdentity{
MachineID: strings.TrimSpace(host.MachineID),
Hostnames: uniqueStrings([]string{host.Hostname}),
IPAddresses: uniqueStrings(ips),
MACAddresses: uniqueStrings(macs),
}
}
// HostIngestRecord converts a host snapshot into the canonical source-native
// ingest shape used by monitored-system previews and continuity projections.
func HostIngestRecord(host models.Host) IngestRecord {
resource, identity := resourceFromHost(host)
return IngestRecord{
SourceID: host.ID,
Resource: resource,
Identity: identity,
}
}
func resourceFromHost(host models.Host) (Resource, ResourceIdentity) {
name := host.Hostname
if host.DisplayName != "" {
name = host.DisplayName
}
identity := identityFromHost(host)
hostProfile := agentHostProfileForHost(host)
platform := agentRuntimePlatformForHost(host, hostProfile)
agent := &AgentData{
AgentID: host.ID,
AgentVersion: host.AgentVersion,
Hostname: host.Hostname,
MachineID: host.MachineID,
TokenID: host.TokenID,
TokenName: host.TokenName,
TokenHint: host.TokenHint,
TokenLastUsedAt: host.TokenLastUsedAt,
Platform: platform,
HostProfile: hostProfile,
OSName: host.OSName,
OSVersion: host.OSVersion,
KernelVersion: host.KernelVersion,
Architecture: host.Architecture,
CPUCount: host.CPUCount,
LoadAverage: append([]float64(nil), host.LoadAverage...),
UptimeSeconds: host.UptimeSeconds,
IntervalSeconds: host.IntervalSeconds,
Temperature: maxCPUTemp(host.Sensors),
NetworkInterfaces: convertInterfaces(host.NetworkInterfaces),
Disks: convertDisks(host.Disks),
Memory: &AgentMemoryMeta{
Total: host.Memory.Total,
Used: host.Memory.Used,
Free: host.Memory.Free,
Cache: host.Memory.Cache,
SwapUsed: host.Memory.SwapUsed,
SwapTotal: host.Memory.SwapTotal,
},
CommandsEnabled: host.CommandsEnabled,
OperationReceiptVersion: host.OperationReceiptVersion,
ReportIP: host.ReportIP,
DiskExclude: append([]string(nil), host.DiskExclude...),
IsLegacy: host.IsLegacy,
NetInRate: host.NetInRate,
NetOutRate: host.NetOutRate,
DiskReadRate: host.DiskReadRate,
DiskWriteRate: host.DiskWriteRate,
LinkedNodeID: host.LinkedNodeID,
LinkedVMID: host.LinkedVMID,
LinkedContainerID: host.LinkedContainerID,
}
if host.PackageUpdates != nil {
packages := make([]AgentPackageUpdate, len(host.PackageUpdates.Packages))
for i, pkg := range host.PackageUpdates.Packages {
packages[i] = AgentPackageUpdate{
Name: pkg.Name,
InstalledVersion: pkg.InstalledVersion,
AvailableVersion: pkg.AvailableVersion,
}
}
agent.PackageUpdates = &AgentPackageUpdateMeta{
Supported: host.PackageUpdates.Supported,
Manager: host.PackageUpdates.Manager,
InventoryHash: host.PackageUpdates.InventoryHash,
PendingCount: host.PackageUpdates.PendingCount,
Packages: packages,
CheckedAt: host.PackageUpdates.CheckedAt,
ObservedAt: host.PackageUpdates.ObservedAt,
RebootRequired: host.PackageUpdates.RebootRequired,
Error: host.PackageUpdates.Error,
}
}
if host.StorageCleanup != nil {
agent.StorageCleanup = &AgentStorageCleanupMeta{
Supported: host.StorageCleanup.Supported,
Provider: host.StorageCleanup.Provider,
Fingerprint: host.StorageCleanup.Fingerprint,
ReclaimableBytes: host.StorageCleanup.ReclaimableBytes,
CheckedAt: host.StorageCleanup.CheckedAt,
ObservedAt: host.StorageCleanup.ObservedAt,
Error: host.StorageCleanup.Error,
}
}
// Surface agent staleness so a row that stays online via another source
// (e.g. a Proxmox node still reachable over the PVE API) does not present a
// dead agent's version as if it were current. The staleness evaluator marks
// a host "offline" once it stops reporting; carry that onto the agent
// payload along with the agent's own last report time.
if !host.LastSeen.IsZero() {
lastReport := host.LastSeen
agent.LastReportAt = &lastReport
}
if strings.EqualFold(strings.TrimSpace(host.Status), "offline") {
agent.Stale = true
}
storageAssessments := make([]storagehealth.Assessment, 0, len(host.RAID)+1)
// Populate sensors
if len(host.Sensors.TemperatureCelsius) > 0 || len(host.Sensors.FanRPM) > 0 || len(host.Sensors.PowerWatts) > 0 || len(host.Sensors.Additional) > 0 || len(host.Sensors.GPU) > 0 || host.Sensors.ThermalState != nil || len(host.Sensors.SMART) > 0 {
sensorMeta := &HostSensorMeta{}
if len(host.Sensors.TemperatureCelsius) > 0 {
sensorMeta.TemperatureCelsius = make(map[string]float64, len(host.Sensors.TemperatureCelsius))
for k, v := range host.Sensors.TemperatureCelsius {
sensorMeta.TemperatureCelsius[k] = v
}
}
if len(host.Sensors.FanRPM) > 0 {
sensorMeta.FanRPM = make(map[string]float64, len(host.Sensors.FanRPM))
for k, v := range host.Sensors.FanRPM {
sensorMeta.FanRPM[k] = v
}
}
if len(host.Sensors.PowerWatts) > 0 {
sensorMeta.PowerWatts = make(map[string]float64, len(host.Sensors.PowerWatts))
for k, v := range host.Sensors.PowerWatts {
sensorMeta.PowerWatts[k] = v
}
}
if len(host.Sensors.Additional) > 0 {
sensorMeta.Additional = make(map[string]float64, len(host.Sensors.Additional))
for k, v := range host.Sensors.Additional {
sensorMeta.Additional[k] = v
}
}
if len(host.Sensors.GPU) > 0 {
sensorMeta.GPU = make([]HostGPUSensor, len(host.Sensors.GPU))
for i, gpu := range host.Sensors.GPU {
sensorMeta.GPU[i] = HostGPUSensor{
ID: gpu.ID,
Name: gpu.Name,
TemperatureCelsius: cloneFloat64Ptr(gpu.TemperatureCelsius),
UtilizationPercent: cloneFloat64Ptr(gpu.UtilizationPercent),
MemoryUsedBytes: cloneInt64Ptr(gpu.MemoryUsedBytes),
MemoryTotalBytes: cloneInt64Ptr(gpu.MemoryTotalBytes),
}
}
}
if host.Sensors.ThermalState != nil {
sensorMeta.ThermalState = hostThermalStateToMeta(host.Sensors.ThermalState)
}
if len(host.Sensors.SMART) > 0 {
sensorMeta.SMART = make([]HostSMARTMeta, len(host.Sensors.SMART))
for i, s := range host.Sensors.SMART {
sensorMeta.SMART[i] = HostSMARTMeta{
Device: s.Device,
Model: s.Model,
Serial: s.Serial,
WWN: s.WWN,
Type: s.Type,
Controller: s.Controller,
Target: s.Target,
SizeBytes: s.SizeBytes,
Temperature: s.Temperature,
Health: s.Health,
Standby: s.Standby,
Pool: s.Pool,
IO: physicalDiskIOToMeta(s.IO),
Collection: diskinventory.CloneStatus(s.Collection),
Attributes: cloneSMARTAttributes(s.Attributes),
}
}
}
agent.Sensors = sensorMeta
}
// Populate RAID
if len(host.RAID) > 0 {
raid := make([]HostRAIDMeta, len(host.RAID))
for i, r := range host.RAID {
devices := make([]HostRAIDDeviceMeta, len(r.Devices))
for j, device := range r.Devices {
devices[j] = HostRAIDDeviceMeta{
Device: device.Device,
State: device.State,
Slot: device.Slot,
}
}
assessment := storagehealth.AssessHostRAIDArray(r)
raid[i] = HostRAIDMeta{
Device: r.Device,
Name: r.Name,
Level: r.Level,
State: r.State,
TotalDevices: r.TotalDevices,
ActiveDevices: r.ActiveDevices,
WorkingDevices: r.WorkingDevices,
FailedDevices: r.FailedDevices,
SpareDevices: r.SpareDevices,
UUID: r.UUID,
Devices: devices,
RebuildPercent: r.RebuildPercent,
RebuildSpeed: r.RebuildSpeed,
Operation: r.Operation,
Risk: storageRiskFromAssessment(assessment),
}
if !isInternalHostRAIDDevice(r.Device) {
storageAssessments = append(storageAssessments, assessment)
}
}
agent.RAID = raid
}
if host.Unraid != nil {
disks := make([]HostUnraidDiskMeta, len(host.Unraid.Disks))
for i, disk := range host.Unraid.Disks {
sizeBytes := unraidDiskSizeBytes(host, disk)
disks[i] = HostUnraidDiskMeta{
Name: disk.Name,
Device: disk.Device,
Role: disk.Role,
Status: disk.Status,
RawStatus: disk.RawStatus,
Model: disk.Model,
Serial: disk.Serial,
Filesystem: disk.Filesystem,
Transport: disk.Transport,
SizeBytes: sizeBytes,
UsedBytes: disk.UsedBytes,
FreeBytes: disk.FreeBytes,
Temperature: disk.Temperature,
SpunDown: disk.SpunDown,
ReadCount: disk.ReadCount,
WriteCount: disk.WriteCount,
ErrorCount: disk.ErrorCount,
Slot: disk.Slot,
}
}
assessment := storagehealth.AssessUnraidStorage(*host.Unraid)
unraidRisk := storageRiskFromAssessment(assessment)
_, protectionReduced, rebuildInProgress, protectionSummary, rebuildSummary := StorageRiskSemantics(unraidRisk)
agent.Unraid = &HostUnraidMeta{
ArrayStarted: host.Unraid.ArrayStarted,
ArrayState: host.Unraid.ArrayState,
SyncAction: host.Unraid.SyncAction,
SyncProgress: host.Unraid.SyncProgress,
SyncErrors: host.Unraid.SyncErrors,
NumProtected: host.Unraid.NumProtected,
NumDisabled: host.Unraid.NumDisabled,
NumInvalid: host.Unraid.NumInvalid,
NumMissing: host.Unraid.NumMissing,
Disks: disks,
Risk: unraidRisk,
RiskSummary: StorageRiskSummary(unraidRisk),
PostureSummary: StorageRiskSummary(unraidRisk),
ProtectionReduced: protectionReduced,
ProtectionSummary: protectionSummary,
RebuildInProgress: rebuildInProgress,
RebuildSummary: rebuildSummary,
}
storageAssessments = append(storageAssessments, assessment)
}
if len(storageAssessments) > 0 {
agent.StorageRisk = storageRiskFromAssessment(storagehealth.SummarizeAssessments(storageAssessments...))
agent.StorageRiskSummary = StorageRiskSummary(agent.StorageRisk)
agent.StoragePostureSummary = agent.StorageRiskSummary
_, agent.ProtectionReduced, agent.RebuildInProgress, agent.ProtectionSummary, agent.RebuildSummary = StorageRiskSemantics(agent.StorageRisk)
}
// Populate DiskIO
if len(host.DiskIO) > 0 {
diskIO := make([]HostDiskIOMeta, len(host.DiskIO))
for i, d := range host.DiskIO {
diskIO[i] = HostDiskIOMeta{
Device: d.Device,
ReadBytes: d.ReadBytes,
WriteBytes: d.WriteBytes,
ReadOps: d.ReadOps,
WriteOps: d.WriteOps,
IOTimeMs: d.IOTime,
}
}
agent.DiskIO = diskIO
}
// Populate Ceph
if host.Ceph != nil {
healthChecks := make(map[string]HostCephCheckMeta, len(host.Ceph.Health.Checks))
for name, check := range host.Ceph.Health.Checks {
healthChecks[name] = HostCephCheckMeta{
Severity: check.Severity,
Message: check.Message,
Detail: append([]string(nil), check.Detail...),
}
}
healthSummary := make([]HostCephHealthSummaryMeta, len(host.Ceph.Health.Summary))
for i, summary := range host.Ceph.Health.Summary {
healthSummary[i] = HostCephHealthSummaryMeta{
Severity: summary.Severity,
Message: summary.Message,
}
}
monitors := make([]HostCephMonitorMeta, len(host.Ceph.MonMap.Monitors))
for i, mon := range host.Ceph.MonMap.Monitors {
monitors[i] = HostCephMonitorMeta{
Name: mon.Name,
Rank: mon.Rank,
Addr: mon.Addr,
Status: mon.Status,
}
}
pools := make([]HostCephPoolMeta, len(host.Ceph.Pools))
for i, pool := range host.Ceph.Pools {
pools[i] = HostCephPoolMeta{
ID: pool.ID,
Name: pool.Name,
BytesUsed: pool.BytesUsed,
BytesAvailable: pool.BytesAvailable,
Objects: pool.Objects,
PercentUsed: pool.PercentUsed,
}
}
services := make([]HostCephServiceMeta, len(host.Ceph.Services))
for i, service := range host.Ceph.Services {
services[i] = HostCephServiceMeta{
Type: service.Type,
Running: service.Running,
Total: service.Total,
Daemons: append([]string(nil), service.Daemons...),
}
}
agent.Ceph = &HostCephMeta{
FSID: host.Ceph.FSID,
Health: HostCephHealthMeta{
Status: host.Ceph.Health.Status,
Checks: healthChecks,
Summary: healthSummary,
},
MonMap: HostCephMonitorMapMeta{
Epoch: host.Ceph.MonMap.Epoch,
NumMons: host.Ceph.MonMap.NumMons,
Monitors: monitors,
},
MgrMap: HostCephManagerMapMeta{
Available: host.Ceph.MgrMap.Available,
NumMgrs: host.Ceph.MgrMap.NumMgrs,
ActiveMgr: host.Ceph.MgrMap.ActiveMgr,
Standbys: host.Ceph.MgrMap.Standbys,
},
OSDMap: HostCephOSDMapMeta{
Epoch: host.Ceph.OSDMap.Epoch,
NumOSDs: host.Ceph.OSDMap.NumOSDs,
NumUp: host.Ceph.OSDMap.NumUp,
NumIn: host.Ceph.OSDMap.NumIn,
NumDown: host.Ceph.OSDMap.NumDown,
NumOut: host.Ceph.OSDMap.NumOut,
},
PGMap: HostCephPGMapMeta{
NumPGs: host.Ceph.PGMap.NumPGs,
BytesTotal: host.Ceph.PGMap.BytesTotal,
BytesUsed: host.Ceph.PGMap.BytesUsed,
BytesAvailable: host.Ceph.PGMap.BytesAvailable,
DataBytes: host.Ceph.PGMap.DataBytes,
UsagePercent: host.Ceph.PGMap.UsagePercent,
DegradedRatio: host.Ceph.PGMap.DegradedRatio,
MisplacedRatio: host.Ceph.PGMap.MisplacedRatio,
ReadBytesPerSec: host.Ceph.PGMap.ReadBytesPerSec,
WriteBytesPerSec: host.Ceph.PGMap.WriteBytesPerSec,
ReadOpsPerSec: host.Ceph.PGMap.ReadOpsPerSec,
WriteOpsPerSec: host.Ceph.PGMap.WriteOpsPerSec,
},
Pools: pools,
Services: services,
CollectedAt: host.Ceph.CollectedAt,
HealthStatus: host.Ceph.Health.Status,
NumOSDs: host.Ceph.OSDMap.NumOSDs,
NumOSDsUp: host.Ceph.OSDMap.NumUp,
NumOSDsIn: host.Ceph.OSDMap.NumIn,
NumPGs: host.Ceph.PGMap.NumPGs,
UsagePercent: host.Ceph.PGMap.UsagePercent,
}
}
metrics := metricsFromHost(host)
resource := Resource{
Type: ResourceTypeAgent,
Technology: strings.TrimSpace(platform),
Name: name,
Status: storageStatus(statusFromString(host.Status), agent.StorageRisk),
LastSeen: host.LastSeen,
UpdatedAt: time.Now().UTC(),
Metrics: metrics,
Uptime: host.UptimeSeconds,
Temperature: agent.Temperature,
Agent: agent,
Tags: host.Tags,
Capabilities: hostActionCapabilities(host),
}
return resource, identity
}
const hostPackageUpdateHandler = "host.package_updates"
const hostStorageCleanupHandler = "host.storage_cleanup"
func hostActionCapabilities(host models.Host) []ResourceCapability {
capabilities := hostPackageUpdateCapabilities(host)
if capability, ok := hostStorageCleanupCapability(host); ok {
capabilities = append(capabilities, capability)
}
return capabilities
}
func hostPackageUpdateCapabilities(host models.Host) []ResourceCapability {
status := host.PackageUpdates
now := time.Now().UTC()
meta := agentPackageUpdateMetaFromModel(status)
if !host.CommandsEnabled || host.OperationReceiptVersion != operationreceipt.ProtocolVersion || status == nil || !status.Supported || strings.TrimSpace(status.Manager) != "apt" || strings.TrimSpace(status.InventoryHash) == "" || status.PendingCount <= 0 || strings.TrimSpace(status.Error) != "" || !HostPackageUpdateTelemetryFresh(meta, now) {
return nil
}
return []ResourceCapability{{
Name: "install_os_updates",
Type: CapabilityTypeCommon,
Description: "Refresh APT metadata and install standard OS package upgrades without removing packages or rebooting the host.",
MinimumApprovalLevel: ApprovalAdmin,
AutoAuthorization: AutoAuthorizeElevated,
Platform: "linux",
InternalHandler: hostPackageUpdateHandler,
}}
}
func hostStorageCleanupCapability(host models.Host) (ResourceCapability, bool) {
status := host.StorageCleanup
now := time.Now().UTC()
meta := agentStorageCleanupMetaFromModel(status)
if !host.CommandsEnabled || host.OperationReceiptVersion != operationreceipt.ProtocolVersion || status == nil || !status.Supported || strings.TrimSpace(status.Provider) != "apt-package-cache" || strings.TrimSpace(status.Fingerprint) == "" || status.ReclaimableBytes < HostStorageCleanupMinReclaimableBytes || strings.TrimSpace(status.Error) != "" || !HostStorageCleanupTelemetryFresh(meta, now) {
return ResourceCapability{}, false
}
if _, ok := HostStorageCleanupPressureDisk(convertDisks(host.Disks)); !ok {
return ResourceCapability{}, false
}
return ResourceCapability{
Name: "clean_package_cache",
Type: CapabilityTypeCommon,
Description: "Reclaim the agent-managed APT package cache without selecting paths, removing installed packages, or rebooting the host.",
MinimumApprovalLevel: ApprovalAdmin,
AutoAuthorization: AutoAuthorizeLowRisk,
Platform: "linux",
InternalHandler: hostStorageCleanupHandler,
}, true
}
func agentPackageUpdateMetaFromModel(status *models.HostPackageUpdateStatus) *AgentPackageUpdateMeta {
if status == nil {
return nil
}
return &AgentPackageUpdateMeta{CheckedAt: status.CheckedAt, ObservedAt: status.ObservedAt}
}
func agentStorageCleanupMetaFromModel(status *models.HostStorageCleanupStatus) *AgentStorageCleanupMeta {
if status == nil {
return nil
}
return &AgentStorageCleanupMeta{CheckedAt: status.CheckedAt, ObservedAt: status.ObservedAt}
}
func hostThermalStateToMeta(in *models.HostThermalState) *HostThermalState {
if in == nil {
return nil
}
return &HostThermalState{
Source: in.Source,
Pressure: in.Pressure,
ThermalWarningLevel: cloneIntPtr(in.ThermalWarningLevel),
PerformanceWarningLevel: cloneIntPtr(in.PerformanceWarningLevel),
CPUPowerStatus: cloneIntPtr(in.CPUPowerStatus),
LimitsPercent: cloneStringIntMap(in.LimitsPercent),
}
}
func agentRuntimePlatformForHost(host models.Host, hostProfile string) string {
return platformsupport.NormalizeRuntimePlatformForAgentHostProfile(hostProfile, host.Platform)
}
func agentHostProfileForHost(host models.Host) string {
if host.Unraid != nil {
return agentHostProfileIDFromIdentity("unraid")
}
return agentHostProfileIDFromIdentity(host.OSName, host.Platform)
}
func agentHostProfileIDFromIdentity(values ...string) string {
profile, ok := platformsupport.AgentHostProfileForIdentity(values...)
if !ok {
return ""
}
return profile.ID
}
func resourceFromHostUnraidStorage(host models.Host) (Resource, ResourceIdentity) {
name := host.Hostname
if host.DisplayName != "" {
name = host.DisplayName
}
name = strings.TrimSpace(name) + " Array"
assessment := storagehealth.AssessUnraidStorage(*host.Unraid)
protection := "none"
switch {
case host.Unraid.NumProtected >= 2:
protection = "dual-parity"
case host.Unraid.NumProtected == 1:
protection = "single-parity"
}
path := unraidStoragePath(host)
resource := Resource{
Type: ResourceTypeStorage,
Technology: "unraid",
Name: name,
Status: storageStatus(statusFromString(host.Status), storageRiskFromAssessment(assessment)),
LastSeen: host.LastSeen,
UpdatedAt: time.Now().UTC(),
Metrics: metricsFromUnraidStorage(host),
Storage: &StorageMeta{
Type: "unraid-array",
Content: "files",
ContentTypes: []string{"files"},
Shared: false,
Enabled: true,
Active: true,
IsCeph: false,
IsZFS: false,
Platform: "unraid",
Topology: "array",
Protection: protection,
Risk: storageRiskFromAssessment(assessment),
Path: path,
ArrayState: host.Unraid.ArrayState,
SyncAction: host.Unraid.SyncAction,
SyncProgress: host.Unraid.SyncProgress,
NumProtected: host.Unraid.NumProtected,
NumDisabled: host.Unraid.NumDisabled,
NumInvalid: host.Unraid.NumInvalid,
NumMissing: host.Unraid.NumMissing,
},
Tags: uniqueStrings([]string{
"unraid",
"storage",
"array",
protection,
}),
}
identity := ResourceIdentity{
MachineID: hostUnraidStorageIdentity(host),
Hostnames: uniqueStrings([]string{
host.Hostname,
host.Hostname + ":unraid-array",
}),
}
return resource, identity
}
func resourceFromHostUnraidCacheStorage(host models.Host, disk models.HostUnraidDisk) (Resource, ResourceIdentity) {
name := unraidCachePoolName(disk)
if name == "" {
name = "cache"
}
total, used, _, percent := unraidDiskCapacityForHost(host, disk)
resource := Resource{
Type: ResourceTypeStorage,
Technology: "unraid",
Name: name,
Status: statusFromString(firstNonEmpty(disk.Status, host.Status)),
LastSeen: host.LastSeen,
UpdatedAt: time.Now().UTC(),
Metrics: metricsFromUnraidDiskCapacity(total, used, percent),
Storage: &StorageMeta{
Type: "unraid-cache-pool",
Content: "files",
ContentTypes: []string{"files"},
Shared: false,
Enabled: true,
Active: strings.EqualFold(strings.TrimSpace(disk.Status), "online"),
IsCeph: false,
IsZFS: strings.EqualFold(strings.TrimSpace(disk.Filesystem), "zfs"),
Platform: "unraid",
Topology: "pool",
Protection: "none",
Path: "/mnt/" + name,
},
Tags: uniqueStrings([]string{
"unraid",
"storage",
"cache",
strings.TrimSpace(disk.Filesystem),
}),
}
identity := ResourceIdentity{
MachineID: hostUnraidCacheStorageIdentity(host, disk),
Hostnames: uniqueStrings([]string{
host.Hostname,
host.Hostname + ":unraid-cache:" + name,
}),
}
return resource, identity
}
func resourceFromHostUnraidPhysicalDisk(host models.Host, disk models.HostUnraidDisk) (Resource, ResourceIdentity) {
model := strings.TrimSpace(disk.Model)
name := firstNonEmpty(model, disk.Name, disk.Device, host.Hostname)
health := unraidPhysicalDiskHealth(disk)
assessment := assessUnraidPhysicalDisk(disk)
sizeBytes := unraidDiskSizeBytes(host, disk)
resource := Resource{
Type: ResourceTypePhysicalDisk,
Name: name,
Status: physicalDiskStatus(model, health, assessment),
LastSeen: host.LastSeen,
UpdatedAt: time.Now().UTC(),
PhysicalDisk: &PhysicalDiskMeta{
DevPath: strings.TrimSpace(disk.Device),
Model: model,
Serial: strings.TrimSpace(disk.Serial),
DiskType: unraidDiskDiskType(disk),
SizeBytes: sizeBytes,
Health: health,
Wearout: -1,
Temperature: disk.Temperature,
Used: unraidDiskMountPath(disk),
StorageRole: unraidDiskRole(&disk),
StorageGroup: unraidDiskGroup(&disk),
StorageState: strings.TrimSpace(disk.Status),
SpunDown: disk.SpunDown,
ReadCount: disk.ReadCount,
WriteCount: disk.WriteCount,
ErrorCount: disk.ErrorCount,
Risk: physicalDiskRiskFromAssessment(assessment),
},
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{host.Hostname}),
}
if serial := strings.TrimSpace(disk.Serial); serial != "" {
identity.MachineID = serial
}
return resource, identity
}
func resourceFromHostSMARTDisk(host models.Host, disk models.HostDiskSMART) (Resource, ResourceIdentity) {
name := strings.TrimSpace(disk.Model)
if name == "" {
name = strings.TrimSpace(disk.Device)
}
if name == "" {
name = strings.TrimSpace(host.Hostname)
}
var matchedDisk *models.Disk
normalizedDevice := strings.ToLower(normalizePhysicalDiskDeviceToken(disk.Device))
for i := range host.Disks {
hostDevice := strings.ToLower(normalizePhysicalDiskDeviceToken(host.Disks[i].Device))
if normalizedDevice == "" || hostDevice == "" {
continue
}
if strings.EqualFold(hostDevice, normalizedDevice) {
matchedDisk = &host.Disks[i]
break
}
}
// The agent reports the authoritative disk capacity (from /sys/block); the
// filesystem-usage match is only a last-resort fallback for older agents that
// predate the SizeBytes field. matchedDisk.Total is a filesystem size, not a
// whole-disk size, so it is never preferred over a real capacity.
sizeBytes := disk.SizeBytes
used := ""
if matchedDisk != nil {
if sizeBytes <= 0 {
sizeBytes = matchedDisk.Total
}
used = strings.TrimSpace(matchedDisk.Mountpoint)
}
unraidDisk := matchUnraidDisk(host.Unraid, disk)
model := strings.TrimSpace(disk.Model)
serial := strings.TrimSpace(disk.Serial)
diskType := strings.TrimSpace(disk.Type)
temperature := disk.Temperature
health := strings.TrimSpace(disk.Health)
if unraidDisk != nil {
if model == "" {
model = strings.TrimSpace(unraidDisk.Model)
}
if serial == "" {
serial = strings.TrimSpace(unraidDisk.Serial)
}
if diskType == "" {
diskType = unraidDiskDiskType(*unraidDisk)
}
if sizeBytes <= 0 {
sizeBytes = unraidDiskSizeBytes(host, *unraidDisk)
}
if temperature <= 0 {
temperature = unraidDisk.Temperature
}
if health == "" || strings.EqualFold(health, "UNKNOWN") {
health = unraidPhysicalDiskHealth(*unraidDisk)
}
}
assessment := storagehealth.AssessHostSMARTDisk(disk)
if unraidDisk != nil {
assessment = storagehealth.SummarizeAssessments(assessment, assessUnraidPhysicalDisk(*unraidDisk))
}
storageGroup := unraidDiskGroup(unraidDisk)
if storageGroup == "" {
storageGroup = strings.TrimSpace(disk.Pool)
}
resource := Resource{
Type: ResourceTypePhysicalDisk,
Name: firstNonEmpty(model, name),
Status: physicalDiskStatus(model, health, assessment),
LastSeen: host.LastSeen,
UpdatedAt: time.Now().UTC(),
PhysicalDisk: &PhysicalDiskMeta{
DevPath: strings.TrimSpace(disk.Device),
Model: model,
Serial: serial,
WWN: strings.TrimSpace(disk.WWN),
DiskType: diskType,
Controller: strings.TrimSpace(disk.Controller),
Target: strings.TrimSpace(disk.Target),
SizeBytes: sizeBytes,
Health: health,
Wearout: -1,
Temperature: temperature,
Used: used,
StorageRole: unraidDiskRole(unraidDisk),
StorageGroup: storageGroup,
StorageState: unraidDiskState(unraidDisk),
SpunDown: unraidDisk != nil && unraidDisk.SpunDown,
ReadCount: unraidDiskCounter(unraidDisk, "read"),
WriteCount: unraidDiskCounter(unraidDisk, "write"),
ErrorCount: unraidDiskCounter(unraidDisk, "error"),
IO: physicalDiskIOToMeta(disk.IO),
Collection: diskinventory.CloneStatus(disk.Collection),
SMART: convertSMARTAttributes(disk.Attributes),
Risk: physicalDiskRiskFromAssessment(assessment),
},
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{host.Hostname}),
}
if serial != "" {
identity.MachineID = serial
} else if disk.WWN != "" {
identity.MachineID = strings.TrimSpace(disk.WWN)
}
return resource, identity
}
func hostUnraidStorageIdentity(host models.Host) string {
if machineID := strings.TrimSpace(host.MachineID); machineID != "" {
return machineID + "/storage/unraid-array"
}
if hostname := strings.TrimSpace(host.Hostname); hostname != "" {
return hostname + "/storage/unraid-array"
}
return ""
}
func hostUnraidStorageSourceID(host models.Host) string {
hostID := strings.TrimSpace(host.ID)
if hostID == "" {
return ""
}
return hostID + "/storage:unraid-array"
}
func hostUnraidCacheStorageIdentity(host models.Host, disk models.HostUnraidDisk) string {
pool := unraidCachePoolName(disk)
if pool == "" {
return ""
}
if machineID := strings.TrimSpace(host.MachineID); machineID != "" {
return machineID + "/storage/unraid-cache/" + pool
}
if hostname := strings.TrimSpace(host.Hostname); hostname != "" {
return hostname + "/storage/unraid-cache/" + pool
}
return ""
}
func hostUnraidCacheStorageSourceID(host models.Host, disk models.HostUnraidDisk) string {
hostID := strings.TrimSpace(host.ID)
pool := unraidCachePoolName(disk)
if hostID == "" || pool == "" {
return ""
}
return hostID + "/storage:unraid-cache:" + pool
}
func unraidCachePoolName(disk models.HostUnraidDisk) string {
return strings.TrimSpace(firstNonEmpty(disk.Name, strings.TrimPrefix(disk.Device, "/dev/")))
}
func unraidStoragePath(host models.Host) string {
for _, disk := range host.Disks {
mount := strings.TrimSpace(disk.Mountpoint)
switch mount {
case "/mnt/user", "/mnt/user0":
return mount
}
}
return "/mnt/user"
}
func unraidDiskMountPath(disk models.HostUnraidDisk) string {
name := strings.TrimSpace(disk.Name)
switch unraidDiskRole(&disk) {
case "data":
if name != "" {
return "/mnt/" + name
}
case "cache":
if name != "" {
return "/mnt/" + name
}
}
return ""
}
func unraidStorageCapacity(host models.Host) (int64, int64, int64, float64) {
for _, disk := range host.Disks {
mount := strings.TrimSpace(disk.Mountpoint)
if mount == "/mnt/user" || mount == "/mnt/user0" {
return disk.Total, disk.Used, disk.Free, percentFromReportedPercent(disk.Usage)
}
}
deviceUsage := make(map[string]models.Disk, len(host.Disks))
for _, disk := range host.Disks {
device := strings.TrimSpace(strings.TrimPrefix(strings.ToLower(disk.Device), "/dev/"))
if device != "" {
deviceUsage[device] = disk
}
}
var total int64
var used int64
var free int64
for _, disk := range host.Unraid.Disks {
if unraidDiskRole(&disk) != "data" {
continue
}
diskTotal, diskUsed, diskFree, _ := unraidDiskCapacityForHost(host, disk)
if diskTotal > 0 {
total += diskTotal
used += diskUsed
free += diskFree
continue
}
device := strings.TrimSpace(strings.TrimPrefix(strings.ToLower(disk.Device), "/dev/"))
if usage, ok := deviceUsage[device]; ok && usage.Total > 0 {
total += usage.Total
used += usage.Used
free += usage.Free
}
}
if total <= 0 {
return 0, 0, 0, 0
}
return total, used, free, (float64(used) / float64(total)) * 100
}
func unraidDiskCapacity(disk models.HostUnraidDisk) (int64, int64, int64, float64) {
used := disk.UsedBytes
free := disk.FreeBytes
total := used + free
if total <= 0 {
total = disk.SizeBytes
}
percent := float64(0)
if total > 0 && used > 0 {
percent = (float64(used) / float64(total)) * 100
}
return total, used, free, percent
}
func unraidDiskCapacityForHost(host models.Host, disk models.HostUnraidDisk) (int64, int64, int64, float64) {
total, used, free, percent := unraidDiskCapacity(disk)
if used <= 0 && free <= 0 {
total = unraidDiskSizeBytes(host, disk)
}
if total > 0 && used > 0 {
percent = (float64(used) / float64(total)) * 100
}
return total, used, free, percent
}
func unraidDiskSizeBytes(host models.Host, disk models.HostUnraidDisk) int64 {
size := disk.SizeBytes
if size <= 0 {
return 0
}
if !shouldScaleLegacyUnraidKiBSize(host, disk, size) {
return size
}
if size > maxInt64()/1024 {
return size
}
return size * 1024
}
func shouldScaleLegacyUnraidKiBSize(host models.Host, disk models.HostUnraidDisk, size int64) bool {
const maxPlausibleLegacyKiBValue = int64(100_000_000_000)
if size <= 0 || size >= maxPlausibleLegacyKiBValue {
return false
}
if total := disk.UsedBytes + disk.FreeBytes; legacyKiBMatchesTotal(total, size) {
return true
}
role := unraidDiskRole(&disk)
switch role {
case "data", "parity":
arrayTotal := unraidArrayMountTotal(host)
if arrayTotal <= 0 || host.Unraid == nil {
return false
}
var rawDataTotal int64
for _, candidate := range host.Unraid.Disks {
if unraidDiskRole(&candidate) != "data" || candidate.SizeBytes <= 0 {
continue
}
rawDataTotal += candidate.SizeBytes
}
return legacyKiBMatchesTotal(arrayTotal, rawDataTotal)
case "cache":
return legacyKiBMatchesTotal(unraidDiskMountTotal(host, disk), size)
default:
return false
}
}
func legacyKiBMatchesTotal(total int64, rawKiB int64) bool {
if total <= 0 || rawKiB <= 0 || rawKiB > maxInt64()/1024 {
return false
}
scaled := rawKiB * 1024
lower := scaled - scaled/20
upper := scaled + scaled/20
return total >= lower && total <= upper
}
func unraidArrayMountTotal(host models.Host) int64 {
for _, disk := range host.Disks {
mount := strings.TrimSpace(disk.Mountpoint)
if (mount == "/mnt/user" || mount == "/mnt/user0") && disk.Total > 0 {
return disk.Total
}
}
return 0
}
func unraidDiskMountTotal(host models.Host, disk models.HostUnraidDisk) int64 {
mountPath := unraidDiskMountPath(disk)
if mountPath == "" {
return 0
}
for _, mounted := range host.Disks {
if strings.TrimSpace(mounted.Mountpoint) == mountPath && mounted.Total > 0 {
return mounted.Total
}
}
return 0
}
func maxInt64() int64 {
return int64(^uint64(0) >> 1)
}
func matchUnraidDisk(unraid *models.HostUnraidStorage, disk models.HostDiskSMART) *models.HostUnraidDisk {
if unraid == nil || len(unraid.Disks) == 0 {
return nil
}
normalizedDevice := strings.ToLower(normalizePhysicalDiskDeviceToken(disk.Device))
normalizedSerial := strings.TrimSpace(strings.ToLower(disk.Serial))
for i := range unraid.Disks {
candidate := &unraid.Disks[i]
if normalizedSerial != "" && strings.EqualFold(strings.TrimSpace(candidate.Serial), normalizedSerial) {
return candidate
}
candidateDevice := strings.ToLower(normalizePhysicalDiskDeviceToken(candidate.Device))
if normalizedDevice != "" && candidateDevice != "" && candidateDevice == normalizedDevice {
return candidate
}
}
return nil
}
func unraidDiskRole(disk *models.HostUnraidDisk) string {
if disk == nil {
return ""
}
role := strings.ToLower(strings.TrimSpace(disk.Role))
if role != "" {
return role
}
name := strings.ToLower(strings.TrimSpace(disk.Name))
switch {
case strings.HasPrefix(name, "parity"):
return "parity"
case strings.HasPrefix(name, "cache"):
return "cache"
case strings.HasPrefix(name, "disk"), strings.HasPrefix(name, "md"), disk.Slot > 0:
return "data"
default:
return ""
}
}
func unraidDiskGroup(disk *models.HostUnraidDisk) string {
if disk == nil {
return ""
}
role := unraidDiskRole(disk)
switch role {
case "parity", "data":
return "unraid-array"
case "cache":
return unraidCachePoolName(*disk)
default:
return ""
}
}
func unraidDiskState(disk *models.HostUnraidDisk) string {
if disk == nil {
return ""
}
return strings.TrimSpace(disk.Status)
}
func unraidDiskCounter(disk *models.HostUnraidDisk, counter string) int64 {
if disk == nil {
return 0
}
switch counter {
case "read":
return disk.ReadCount
case "write":
return disk.WriteCount
case "error":
return disk.ErrorCount
default:
return 0
}
}
func unraidDiskDiskType(disk models.HostUnraidDisk) string {
transport := strings.ToLower(strings.TrimSpace(disk.Transport))
switch transport {
case "ata":
return "sata"
case "nvme", "sata", "sas", "ssd", "hdd", "usb":
return transport
default:
return transport
}
}
func unraidPhysicalDiskHealth(disk models.HostUnraidDisk) string {
switch strings.ToLower(strings.TrimSpace(disk.Status)) {
case "online":
return "PASSED"
case "disabled", "invalid", "missing", "wrong", "error":
return "FAILED"
default:
return "UNKNOWN"
}
}
func assessUnraidPhysicalDisk(disk models.HostUnraidDisk) storagehealth.Assessment {
assessment := storagehealth.AssessSample(storagehealth.Sample{
Model: disk.Model,
Health: unraidPhysicalDiskHealth(disk),
Temperature: disk.Temperature,
Wearout: -1,
})
addReason := func(code string, severity storagehealth.RiskLevel, summary string) {
if strings.TrimSpace(summary) == "" {
return
}
assessment.Reasons = append(assessment.Reasons, storagehealth.Reason{
Code: code,
Severity: severity,
Summary: summary,
})
if incidentSeverityRank(severity) > incidentSeverityRank(assessment.Level) {
assessment.Level = severity
}
}
label := firstNonEmpty(disk.Name, disk.Device, disk.Serial, "disk")
status := strings.ToUpper(strings.TrimSpace(disk.Status))
switch strings.ToLower(strings.TrimSpace(disk.Status)) {
case "", "online":
default:
addReason("unraid_disk_state", storagehealth.RiskCritical, "Unraid disk "+label+" is "+status)
}
if disk.ErrorCount > 0 {
addReason("unraid_disk_errors", storagehealth.RiskWarning, "Unraid disk "+label+" reports "+int64Label(disk.ErrorCount)+" error(s)")
}
return assessment
}
func int64Label(value int64) string {
return strconv.FormatInt(value, 10)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func resourceFromDockerHost(host models.DockerHost) (Resource, ResourceIdentity) {
name := host.Hostname
if host.CustomDisplayName != "" {
name = host.CustomDisplayName
} else if host.DisplayName != "" {
name = host.DisplayName
}
ips, macs := collectInterfaceIDs(host.NetworkInterfaces)
identity := ResourceIdentity{
MachineID: host.MachineID,
Hostnames: uniqueStrings([]string{host.Hostname}),
IPAddresses: uniqueStrings(ips),
MACAddresses: uniqueStrings(macs),
}
// If this Docker host is part of a Swarm, surface the Swarm cluster as the resource cluster.
// This drives unified Infrastructure grouping and /api/resources?cluster filtering.
if host.Swarm != nil {
clusterName := strings.TrimSpace(host.Swarm.ClusterName)
if clusterName == "" {
clusterName = strings.TrimSpace(host.Swarm.ClusterID)
}
if clusterName != "" {
identity.ClusterName = clusterName
identity.Hostnames = uniqueStrings(append(identity.Hostnames, clusterName+":"+host.Hostname))
}
}
updatesAvailableCount := 0
var updatesLastCheckedAt time.Time
for _, container := range host.Containers {
if container.UpdateStatus == nil {
continue
}
if container.UpdateStatus.UpdateAvailable {
updatesAvailableCount++
}
if container.UpdateStatus.LastChecked.After(updatesLastCheckedAt) {
updatesLastCheckedAt = container.UpdateStatus.LastChecked
}
}
var updatesLastCheckedPtr *time.Time
if !updatesLastCheckedAt.IsZero() {
copied := updatesLastCheckedAt
updatesLastCheckedPtr = &copied
}
docker := &DockerData{
HostSourceID: host.ID,
AgentID: host.AgentID,
Hostname: host.Hostname,
DisplayName: host.DisplayName,
CustomDisplayName: host.CustomDisplayName,
MachineID: host.MachineID,
Temperature: host.Temperature,
Runtime: host.Runtime,
RuntimeVersion: host.RuntimeVersion,
DockerVersion: host.DockerVersion,
OS: host.OS,
KernelVersion: host.KernelVersion,
Architecture: host.Architecture,
AgentVersion: host.AgentVersion,
CPUs: host.CPUs,
TotalMemoryBytes: host.TotalMemoryBytes,
UptimeSeconds: host.UptimeSeconds,
LoadAverage: append([]float64(nil), host.LoadAverage...),
IntervalSeconds: host.IntervalSeconds,
NetInRate: host.NetInRate,
NetOutRate: host.NetOutRate,
DiskReadRate: host.DiskReadRate,
DiskWriteRate: host.DiskWriteRate,
ContainerCount: len(host.Containers),
ImageCount: len(host.Images),
VolumeCount: len(host.Volumes),
NetworkCount: len(host.Networks),
NodeCount: len(host.Nodes),
SecretCount: len(host.Secrets),
ConfigCount: len(host.Configs),
UpdatesAvailableCount: updatesAvailableCount,
UpdatesLastCheckedAt: updatesLastCheckedPtr,
TokenID: host.TokenID,
TokenName: host.TokenName,
TokenHint: host.TokenHint,
TokenLastUsedAt: host.TokenLastUsedAt,
Hidden: host.Hidden,
PendingUninstall: host.PendingUninstall,
IsLegacy: host.IsLegacy,
Command: cloneDockerHostCommandStatus(host.Command),
Security: cloneDockerHostSecurity(host.Security),
IdentityConflict: cloneDockerHostIdentityConflict(host.IdentityConflict),
Swarm: convertSwarm(host.Swarm),
NetworkInterfaces: convertInterfaces(host.NetworkInterfaces),
Disks: convertDisks(host.Disks),
Containers: append([]models.DockerContainer(nil), host.Containers...),
Images: append([]models.DockerImage(nil), host.Images...),
Volumes: append([]models.DockerVolume(nil), host.Volumes...),
NetworksRaw: append([]models.DockerNetwork(nil), host.Networks...),
Services: append([]models.DockerService(nil), host.Services...),
Tasks: append([]models.DockerTask(nil), host.Tasks...),
Nodes: append([]models.DockerNode(nil), host.Nodes...),
Secrets: append([]models.DockerSecret(nil), host.Secrets...),
Configs: append([]models.DockerConfig(nil), host.Configs...),
}
if host.StorageUsage != nil {
docker.ImagesUsage = dockerStorageUsageMeta(host.StorageUsage.Images)
docker.ContainersUsage = dockerStorageUsageMeta(host.StorageUsage.Containers)
docker.VolumesUsage = dockerStorageUsageMeta(host.StorageUsage.Volumes)
docker.BuildCacheUsage = dockerStorageUsageMeta(host.StorageUsage.BuildCache)
}
metrics := metricsFromDockerHost(host)
resource := Resource{
Type: ResourceTypeAgent,
Technology: strings.TrimSpace(host.Runtime),
Name: name,
Status: statusFromString(host.Status),
LastSeen: host.LastSeen,
UpdatedAt: time.Now().UTC(),
Metrics: metrics,
Uptime: host.UptimeSeconds,
Temperature: host.Temperature,
Docker: docker,
Tags: nil,
}
return resource, identity
}
func resourceFromPBSInstance(instance models.PBSInstance) (Resource, ResourceIdentity) {
name := instance.Name
if strings.TrimSpace(name) == "" {
name = extractHostname(instance.Host)
}
assessments := make([]storagehealth.Assessment, 0, len(instance.Datastores))
for _, datastore := range instance.Datastores {
assessments = append(assessments, storagehealth.AssessPBSDatastore(datastore))
}
storageAssessment := storagehealth.SummarizeAssessments(assessments...)
storageRisk := storageRiskFromAssessment(storageAssessment)
incidents := incidentsFromAssessment("pulse", string(SourcePBS), "pbs-instance:"+name, storageAssessment, instance.LastSeen)
status := statusFromPBSInstance(instance)
status = storageStatus(status, storageRisk)
status = incidentsStatus(status, incidents)
resource := Resource{
Type: ResourceTypePBS,
Name: name,
Status: status,
LastSeen: instance.LastSeen,
UpdatedAt: time.Now().UTC(),
Metrics: metricsFromPBSInstance(instance),
Uptime: instance.Uptime,
CustomURL: instance.GuestURL,
Incidents: incidents,
PBS: &PBSData{
InstanceID: instance.ID,
Hostname: extractHostname(instance.Host),
HostURL: instance.Host,
GuestURL: instance.GuestURL,
Version: instance.Version,
UptimeSeconds: instance.Uptime,
DatastoreCount: len(instance.Datastores),
DatastoreDetails: clonePBSDatastores(instance.Datastores),
BackupJobCount: len(instance.BackupJobs),
BackupJobs: append([]models.PBSBackupJob(nil), instance.BackupJobs...),
SyncJobCount: len(instance.SyncJobs),
SyncJobs: append([]models.PBSSyncJob(nil), instance.SyncJobs...),
VerifyJobCount: len(instance.VerifyJobs),
VerifyJobs: append([]models.PBSVerifyJob(nil), instance.VerifyJobs...),
PruneJobCount: len(instance.PruneJobs),
PruneJobs: append([]models.PBSPruneJob(nil), instance.PruneJobs...),
GarbageJobCount: len(instance.GarbageJobs),
GarbageJobs: append([]models.PBSGarbageJob(nil), instance.GarbageJobs...),
JobHealthEvidenceCount: len(instance.JobHealthEvidence),
JobHealthEvidence: append([]models.PBSJobHealthEvidence(nil), instance.JobHealthEvidence...),
StorageRisk: storageRisk,
ConnectionHealth: instance.ConnectionHealth,
},
}
if len(instance.Datastores) > 0 {
datastores := make([]PBSDatastoreMeta, len(instance.Datastores))
for i, ds := range instance.Datastores {
datastores[i] = PBSDatastoreMeta{
Name: ds.Name,
Total: ds.Total,
Used: ds.Used,
Available: ds.Free,
UsagePercent: ds.Usage,
Status: ds.Status,
Error: ds.Error,
DeduplicationFactor: ds.DeduplicationFactor,
}
}
resource.PBS.Datastores = datastores
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{
instance.Name,
extractHostname(instance.Host),
}),
}
return resource, identity
}
func resourceFromPBSDatastore(instance models.PBSInstance, datastore models.PBSDatastore) (Resource, ResourceIdentity) {
name := strings.TrimSpace(datastore.Name)
assessment := storagehealth.AssessPBSDatastore(datastore)
risk := storageRiskFromAssessment(assessment)
status := storageStatus(statusFromString(datastore.Status), risk)
incidents := incidentsFromAssessment("pulse", string(SourcePBS), "pbs-datastore:"+name, assessment, instance.LastSeen)
status = incidentsStatus(status, incidents)
resource := Resource{
Type: ResourceTypeStorage,
Name: name,
Status: status,
LastSeen: instance.LastSeen,
UpdatedAt: time.Now().UTC(),
Metrics: metricsFromPBSDatastore(datastore),
Storage: &StorageMeta{
Type: "pbs-datastore",
Platform: "pbs",
Topology: "datastore",
Protection: "backup-repository",
Content: "backup",
ContentTypes: []string{"backup"},
Enabled: true,
Active: true,
Risk: risk,
},
Incidents: incidents,
Tags: []string{
"pbs",
"datastore",
"backup",
},
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{
name,
instance.Name,
extractHostname(instance.Host),
}),
}
return resource, identity
}
func resourceFromPMGInstance(instance models.PMGInstance) (Resource, ResourceIdentity) {
name := instance.Name
if strings.TrimSpace(name) == "" {
name = extractHostname(instance.Host)
}
uptime := maxPMGUptime(instance.Nodes)
resource := Resource{
Type: ResourceTypePMG,
Name: name,
Status: statusFromPMGInstance(instance),
LastSeen: instance.LastSeen,
UpdatedAt: time.Now().UTC(),
Metrics: metricsFromPMGInstance(instance),
Uptime: uptime,
CustomURL: instance.GuestURL,
PMG: &PMGData{
InstanceID: instance.ID,
Hostname: extractHostname(instance.Host),
HostURL: strings.TrimSpace(instance.Host),
GuestURL: strings.TrimSpace(instance.GuestURL),
Version: instance.Version,
NodeCount: len(instance.Nodes),
UptimeSeconds: uptime,
ConnectionHealth: instance.ConnectionHealth,
LastUpdated: instance.LastUpdated,
},
}
if instance.MailStats != nil {
resource.PMG.MailCountTotal = instance.MailStats.CountTotal
resource.PMG.SpamIn = instance.MailStats.SpamIn
resource.PMG.VirusIn = instance.MailStats.VirusIn
}
queue := aggregatePMGQueue(instance.Nodes)
resource.PMG.QueueActive = queue.Active
resource.PMG.QueueDeferred = queue.Deferred
resource.PMG.QueueHold = queue.Hold
resource.PMG.QueueIncoming = queue.Incoming
resource.PMG.QueueTotal = queue.Total
// Populate per-node data
if len(instance.Nodes) > 0 {
nodes := make([]PMGNodeMeta, len(instance.Nodes))
for i, n := range instance.Nodes {
nodes[i] = PMGNodeMeta{
Name: n.Name,
Status: n.Status,
Role: n.Role,
Uptime: n.Uptime,
LoadAvg: n.LoadAvg,
}
if n.QueueStatus != nil {
nodes[i].QueueStatus = &PMGQueueMeta{
Active: n.QueueStatus.Active,
Deferred: n.QueueStatus.Deferred,
Hold: n.QueueStatus.Hold,
Incoming: n.QueueStatus.Incoming,
Total: n.QueueStatus.Total,
OldestAge: n.QueueStatus.OldestAge,
UpdatedAt: n.QueueStatus.UpdatedAt,
}
}
}
resource.PMG.Nodes = nodes
}
// Populate mail stats
if instance.MailStats != nil {
resource.PMG.MailStats = &PMGMailStatsMeta{
Timeframe: instance.MailStats.Timeframe,
CountTotal: instance.MailStats.CountTotal,
CountIn: instance.MailStats.CountIn,
CountOut: instance.MailStats.CountOut,
SpamIn: instance.MailStats.SpamIn,
SpamOut: instance.MailStats.SpamOut,
VirusIn: instance.MailStats.VirusIn,
VirusOut: instance.MailStats.VirusOut,
BouncesIn: instance.MailStats.BouncesIn,
BouncesOut: instance.MailStats.BouncesOut,
BytesIn: instance.MailStats.BytesIn,
BytesOut: instance.MailStats.BytesOut,
GreylistCount: instance.MailStats.GreylistCount,
JunkIn: instance.MailStats.JunkIn,
RBLRejects: instance.MailStats.RBLRejects,
AverageProcessTimeMs: instance.MailStats.AverageProcessTimeMs,
PregreetRejects: instance.MailStats.PregreetRejects,
UpdatedAt: instance.MailStats.UpdatedAt,
}
}
if len(instance.MailCount) > 0 {
resource.PMG.MailCount = append([]models.PMGMailCountPoint(nil), instance.MailCount...)
}
// Populate quarantine
if instance.Quarantine != nil {
resource.PMG.Quarantine = &PMGQuarantineMeta{
Spam: instance.Quarantine.Spam,
Virus: instance.Quarantine.Virus,
Attachment: instance.Quarantine.Attachment,
Blacklisted: instance.Quarantine.Blacklisted,
}
}
// Populate spam distribution
if len(instance.SpamDistribution) > 0 {
buckets := make([]PMGSpamBucketMeta, len(instance.SpamDistribution))
for i, b := range instance.SpamDistribution {
buckets[i] = PMGSpamBucketMeta{
Bucket: b.Score,
Count: b.Count,
}
}
resource.PMG.SpamDistribution = buckets
}
// Populate relay domains
if len(instance.RelayDomains) > 0 {
relayDomains := make([]PMGRelayDomainMeta, len(instance.RelayDomains))
for i, d := range instance.RelayDomains {
relayDomains[i] = PMGRelayDomainMeta{
Domain: strings.TrimSpace(d.Domain),
Comment: strings.TrimSpace(d.Comment),
}
}
resource.PMG.RelayDomains = relayDomains
}
// Populate domain stats
if len(instance.DomainStats) > 0 {
stats := make([]PMGDomainStatMeta, len(instance.DomainStats))
for i, s := range instance.DomainStats {
stats[i] = PMGDomainStatMeta{
Domain: strings.TrimSpace(s.Domain),
MailCount: s.MailCount,
SpamCount: s.SpamCount,
VirusCount: s.VirusCount,
Bytes: s.Bytes,
}
}
resource.PMG.DomainStats = stats
resource.PMG.DomainStatsAsOf = instance.DomainStatsAsOf
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{
instance.Name,
extractHostname(instance.Host),
}),
}
return resource, identity
}
func resourceFromVM(vm models.VM) (Resource, ResourceIdentity) {
sourceID := proxmoxVMSourceID(vm)
metrics := metricsFromVM(vm)
proxmox := &ProxmoxData{
SourceID: sourceID,
NodeName: vm.Node,
Pool: vm.Pool,
Instance: vm.Instance,
VMID: vm.VMID,
CPUs: vm.CPUs,
Uptime: vm.Uptime,
Template: vm.Template,
LastBackup: vm.LastBackup,
DiskStatusReason: vm.DiskStatusReason,
GuestAgentStatus: vm.GuestAgentStatus,
GuestAgentExpected: vm.GuestAgentExpected,
OSName: vm.OSName,
OSVersion: vm.OSVersion,
AgentVersion: vm.AgentVersion,
NetworkInterfaces: convertGuestInterfaces(vm.NetworkInterfaces),
Disks: convertDisks(vm.Disks),
SwapUsed: vm.Memory.SwapUsed,
SwapTotal: vm.Memory.SwapTotal,
Balloon: vm.Memory.Balloon,
MemoryCache: vm.Memory.Cache,
Lock: vm.Lock,
}
resource := Resource{
Type: ResourceTypeVM,
Technology: "qemu",
Name: vm.Name,
Status: statusFromGuest(vm.Status),
LastSeen: vm.LastSeen,
UpdatedAt: time.Now().UTC(),
Metrics: metrics,
Proxmox: proxmox,
Tags: vm.Tags,
}
if incident, ok := vmGuestAgentIncident(vm); ok {
resource.Incidents = append(resource.Incidents, incident)
resource.Status = incidentsStatus(resource.Status, resource.Incidents)
}
resource.Capabilities = proxmoxGuestLifecycleCapabilities("vm", "qemu", vm.Status, vm.Template, vm.Lock)
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{vm.Name}),
IPAddresses: uniqueStrings(vm.IPAddresses),
}
return resource, identity
}
func vmGuestAgentIncident(vm models.VM) (ResourceIncident, bool) {
if !vm.GuestAgentExpected || vm.GuestAgentStatus != "expected-unreachable" || !strings.EqualFold(strings.TrimSpace(vm.Status), "running") {
return ResourceIncident{}, false
}
return ResourceIncident{
Provider: "proxmox",
NativeID: proxmoxVMSourceID(vm),
Code: "availability_unreachable",
Severity: storagehealth.RiskWarning,
Source: "qemu-guest-agent",
Summary: "QEMU guest agent stopped responding while the VM is still running",
StartedAt: vm.LastSeen,
}, true
}
func resourceFromContainer(ct models.Container) (Resource, ResourceIdentity) {
sourceID := proxmoxContainerSourceID(ct)
metrics := metricsFromContainer(ct)
proxmox := &ProxmoxData{
SourceID: sourceID,
NodeName: ct.Node,
Pool: ct.Pool,
Instance: ct.Instance,
VMID: ct.VMID,
ContainerType: ct.Type,
IsOCI: ct.IsOCI,
CPUs: ct.CPUs,
Uptime: ct.Uptime,
Template: ct.Template,
LastBackup: ct.LastBackup,
OSName: ct.OSName,
NetworkInterfaces: convertGuestInterfaces(ct.NetworkInterfaces),
OSTemplate: ct.OSTemplate,
HasDocker: ct.HasDocker,
DockerCheckedAt: timePtr(ct.DockerCheckedAt),
Disks: convertDisks(ct.Disks),
SwapUsed: ct.Memory.SwapUsed,
SwapTotal: ct.Memory.SwapTotal,
Balloon: ct.Memory.Balloon,
MemoryCache: ct.Memory.Cache,
Lock: ct.Lock,
}
resource := Resource{
Type: ResourceTypeSystemContainer,
Technology: "lxc",
Name: ct.Name,
Status: statusFromGuest(ct.Status),
LastSeen: ct.LastSeen,
UpdatedAt: time.Now().UTC(),
Metrics: metrics,
Proxmox: proxmox,
Tags: ct.Tags,
}
resource.Capabilities = proxmoxGuestLifecycleCapabilities("ct", "lxc", ct.Status, ct.Template, ct.Lock)
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{ct.Name}),
IPAddresses: uniqueStrings(ct.IPAddresses),
}
return resource, identity
}
func proxmoxGuestLifecycleCapabilities(kind, platform, status string, template bool, lock string) []ResourceCapability {
if template || strings.TrimSpace(lock) != "" {
return nil
}
handler := ""
subject := ""
switch strings.ToLower(strings.TrimSpace(kind)) {
case "vm", "qemu":
handler = "proxmox.vm.lifecycle"
subject = "VM"
case "ct", "lxc", "container":
handler = "proxmox.ct.lifecycle"
subject = "LXC"
default:
return nil
}
status = strings.ToLower(strings.TrimSpace(status))
operations := []string{}
switch status {
case "running", "online":
operations = []string{"shutdown", "reboot", "stop"}
case "stopped", "offline":
operations = []string{"start"}
default:
return nil
}
capabilities := make([]ResourceCapability, 0, len(operations))
for _, operation := range operations {
capabilities = append(capabilities, ResourceCapability{
Name: operation,
Type: CapabilityTypeCommon,
Description: proxmoxGuestLifecycleDescription(subject, operation),
MinimumApprovalLevel: ApprovalAdmin,
Platform: strings.TrimSpace(platform),
InternalHandler: handler,
})
}
return capabilities
}
func proxmoxGuestLifecycleDescription(subject, operation string) string {
switch operation {
case "start":
return "Start this Proxmox " + subject
case "shutdown":
return "Gracefully shut down this Proxmox " + subject
case "reboot":
return "Reboot this Proxmox " + subject
case "stop":
return "Hard stop this Proxmox " + subject
default:
return "Run Proxmox " + subject + " lifecycle action"
}
}
func convertGuestInterfaces(in []models.GuestNetworkInterface) []NetworkInterface {
if len(in) == 0 {
return nil
}
out := make([]NetworkInterface, 0, len(in))
for _, iface := range in {
out = append(out, NetworkInterface{
Name: strings.TrimSpace(iface.Name),
MAC: strings.TrimSpace(iface.MAC),
Addresses: uniqueStrings(iface.Addresses),
RXBytes: uint64(max(0, iface.RXBytes)),
TXBytes: uint64(max(0, iface.TXBytes)),
})
}
return out
}
func timePtr(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
copy := t
return &copy
}
func resourceFromStorage(storage models.Storage) (Resource, ResourceIdentity) {
name := strings.TrimSpace(storage.Name)
if name == "" {
name = strings.TrimSpace(storage.ID)
}
storageType := strings.ToLower(strings.TrimSpace(storage.Type))
content := strings.TrimSpace(storage.Content)
now := time.Now().UTC()
zfsPoolState := ""
var zfsReadErrors, zfsWriteErrors, zfsChecksumErrors int64
var zfsPool *models.ZFSPool
if storage.ZFSPool != nil {
normalized := storage.ZFSPool.NormalizeCollections()
zfsPool = &normalized
zfsPoolState = strings.TrimSpace(storage.ZFSPool.State)
zfsReadErrors = storage.ZFSPool.ReadErrors
zfsWriteErrors = storage.ZFSPool.WriteErrors
zfsChecksumErrors = storage.ZFSPool.ChecksumErrors
}
resource := Resource{
Type: ResourceTypeStorage,
Name: name,
Status: statusFromStorage(storage),
LastSeen: storage.LastSeen,
UpdatedAt: now,
Metrics: metricsFromStorage(storage),
Proxmox: &ProxmoxData{
SourceID: storage.ID,
NodeName: storage.Node,
Instance: storage.Instance,
},
Storage: &StorageMeta{
Type: storageType,
Content: content,
ContentTypes: parseStorageContentTypes(content),
Shared: storage.Shared,
Enabled: storage.Enabled,
Active: storage.Active,
IsCeph: isCephStorageType(storageType),
IsZFS: isZFSStorageType(storageType) || storage.ZFSPool != nil,
Nodes: append([]string(nil), storage.Nodes...),
Pool: storage.Pool,
Path: storage.Path,
ZFSPool: zfsPool,
ZFSPoolState: zfsPoolState,
ZFSReadErrors: zfsReadErrors,
ZFSWriteErrors: zfsWriteErrors,
ZFSChecksumErrors: zfsChecksumErrors,
},
}
if storage.ZFSPool != nil {
assessment := storagehealth.AssessZFSPool(*storage.ZFSPool)
resource.Storage.Risk = storageRiskFromAssessment(assessment)
resource.Status = storageStatus(resource.Status, resource.Storage.Risk)
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{
storage.Name,
storage.Node,
}),
}
return resource, identity
}
func resourceFromCephCluster(cluster models.CephCluster) (Resource, ResourceIdentity) {
name := cluster.Name
if name == "" {
name = cluster.FSID
}
if name == "" {
name = cluster.ID
}
cephMeta := &CephMeta{
FSID: cluster.FSID,
HealthStatus: cluster.Health,
HealthMessage: cluster.HealthMessage,
NumMons: cluster.NumMons,
NumMgrs: cluster.NumMgrs,
NumOSDs: cluster.NumOSDs,
NumOSDsUp: cluster.NumOSDsUp,
NumOSDsIn: cluster.NumOSDsIn,
NumPGs: cluster.NumPGs,
Pools: convertCephPools(cluster.Pools),
Services: convertCephServices(cluster.Services),
}
resource := Resource{
Type: ResourceTypeCeph,
Name: name,
Status: statusFromCephHealth(cluster.Health),
LastSeen: cluster.LastUpdated,
UpdatedAt: time.Now().UTC(),
Metrics: metricsFromCephCluster(cluster),
Ceph: cephMeta,
Tags: cephClusterTags(cluster),
}
identity := ResourceIdentity{}
if cluster.FSID != "" {
identity.MachineID = cluster.FSID
}
identity.Hostnames = uniqueStrings([]string{cluster.Name, cluster.Instance})
return resource, identity
}
func convertCephPools(pools []models.CephPool) []CephPoolMeta {
if len(pools) == 0 {
return nil
}
out := make([]CephPoolMeta, 0, len(pools))
for _, p := range pools {
out = append(out, CephPoolMeta{
Name: p.Name,
StoredBytes: p.StoredBytes,
AvailableBytes: p.AvailableBytes,
Objects: p.Objects,
PercentUsed: p.PercentUsed,
})
}
return out
}
func convertCephServices(services []models.CephServiceStatus) []CephServiceMeta {
if len(services) == 0 {
return nil
}
out := make([]CephServiceMeta, 0, len(services))
for _, s := range services {
out = append(out, CephServiceMeta{
Type: s.Type,
Running: s.Running,
Total: s.Total,
})
}
return out
}
func cephClusterTags(cluster models.CephCluster) []string {
var tags []string
tags = append(tags, "ceph")
if cluster.Health != "" {
tags = append(tags, strings.ToLower(cluster.Health))
}
if cluster.Instance != "" {
tags = append(tags, cluster.Instance)
}
return uniqueStrings(tags)
}
func resourceFromPhysicalDisk(disk models.PhysicalDisk) (Resource, ResourceIdentity) {
name := disk.Model
if name == "" {
name = disk.DevPath
}
assessment := storagehealth.AssessPhysicalDisk(disk)
pdMeta := &PhysicalDiskMeta{
DevPath: disk.DevPath,
Model: disk.Model,
Serial: disk.Serial,
WWN: disk.WWN,
DiskType: disk.Type,
Controller: disk.Controller,
Target: disk.Target,
SizeBytes: disk.Size,
Health: disk.Health,
Wearout: disk.Wearout,
Temperature: disk.Temperature,
RPM: disk.RPM,
Used: disk.Used,
StorageGroup: disk.StorageGroup,
IO: physicalDiskIOToMeta(disk.IO),
Collection: diskinventory.CloneStatus(disk.Collection),
Risk: physicalDiskRiskFromAssessment(assessment),
}
if disk.SmartAttributes != nil {
pdMeta.SMART = convertSMARTAttributes(disk.SmartAttributes)
}
resource := Resource{
Type: ResourceTypePhysicalDisk,
Name: name,
Status: physicalDiskStatus(disk.Model, disk.Health, assessment),
LastSeen: disk.LastChecked,
UpdatedAt: time.Now().UTC(),
Metrics: metricsFromPhysicalDisk(disk),
PhysicalDisk: pdMeta,
Proxmox: &ProxmoxData{
SourceID: disk.ID,
NodeName: disk.Node,
Instance: disk.Instance,
},
Tags: physicalDiskTags(disk),
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{disk.Node}),
}
if disk.Serial != "" {
identity.MachineID = disk.Serial
} else if disk.WWN != "" {
identity.MachineID = disk.WWN
}
return resource, identity
}
func physicalDiskIOToMeta(in *models.DiskIO) *PhysicalDiskIOMeta {
if in == nil {
return nil
}
return &PhysicalDiskIOMeta{
Device: strings.TrimSpace(in.Device),
ReadBytes: in.ReadBytes,
WriteBytes: in.WriteBytes,
ReadOps: in.ReadOps,
WriteOps: in.WriteOps,
ReadTimeMs: in.ReadTime,
WriteTimeMs: in.WriteTime,
IOTimeMs: in.IOTime,
}
}
func convertSMARTAttributes(attrs *models.SMARTAttributes) *SMARTMeta {
if attrs == nil {
return nil
}
m := &SMARTMeta{}
if attrs.PowerOnHours != nil {
m.PowerOnHours = *attrs.PowerOnHours
}
if attrs.PowerCycles != nil {
m.PowerCycles = *attrs.PowerCycles
}
if attrs.ReallocatedSectors != nil {
m.ReallocatedSectors = *attrs.ReallocatedSectors
}
if attrs.PendingSectors != nil {
m.PendingSectors = *attrs.PendingSectors
}
if attrs.OfflineUncorrectable != nil {
m.OfflineUncorrectable = *attrs.OfflineUncorrectable
}
if attrs.UDMACRCErrors != nil {
m.UDMACRCErrors = *attrs.UDMACRCErrors
}
if attrs.PercentageUsed != nil {
m.PercentageUsed = *attrs.PercentageUsed
}
if attrs.AvailableSpare != nil {
m.AvailableSpare = *attrs.AvailableSpare
}
if attrs.MediaErrors != nil {
m.MediaErrors = *attrs.MediaErrors
}
if attrs.UnsafeShutdowns != nil {
m.UnsafeShutdowns = *attrs.UnsafeShutdowns
}
return m
}
func physicalDiskTags(disk models.PhysicalDisk) []string {
var tags []string
if disk.Type != "" {
tags = append(tags, disk.Type)
}
if disk.Health != "" {
tags = append(tags, strings.ToLower(disk.Health))
}
if disk.Node != "" {
tags = append(tags, disk.Node)
}
return uniqueStrings(tags)
}
func parseStorageContentTypes(content string) []string {
if strings.TrimSpace(content) == "" {
return nil
}
parts := strings.Split(content, ",")
seen := make(map[string]struct{}, len(parts))
out := make([]string, 0, len(parts))
for _, part := range parts {
value := strings.ToLower(strings.TrimSpace(part))
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func isCephStorageType(storageType string) bool {
switch strings.ToLower(strings.TrimSpace(storageType)) {
case "rbd", "cephfs", "ceph":
return true
default:
return false
}
}
func isZFSStorageType(storageType string) bool {
normalized := strings.ToLower(strings.TrimSpace(storageType))
switch normalized {
case "zfspool", "zfs", "local-zfs":
return true
default:
return strings.Contains(normalized, "zfs")
}
}
func resourceFromDockerContainer(ct models.DockerContainer, host models.DockerHost) (Resource, ResourceIdentity) {
metrics := metricsFromDockerContainer(ct, host.CPUs)
now := time.Now().UTC()
runtime := strings.TrimSpace(host.Runtime)
if runtime == "" {
if ct.Podman != nil {
runtime = "podman"
} else {
runtime = "docker"
}
}
docker := &DockerData{
HostSourceID: host.ID,
AgentID: strings.TrimSpace(host.AgentID),
ContainerID: ct.ID,
Hostname: host.Hostname,
Image: ct.Image,
ImageID: ct.ImageDigest,
UptimeSeconds: ct.UptimeSeconds,
ContainerState: ct.State,
Health: ct.Health,
RestartCount: ct.RestartCount,
ExitCode: ct.ExitCode,
OOMKilled: cloneBoolPtr(ct.OOMKilled),
CPURawPercent: ct.CPUPercent,
CPUCapacityPercent: models.DockerContainerCPUCapacityPercent(ct, host.CPUs),
CPUCapacityCores: host.CPUs,
Labels: cloneLabelMap(ct.Labels),
Runtime: runtime,
RuntimeVersion: host.RuntimeVersion,
DockerVersion: host.DockerVersion,
Security: cloneDockerHostSecurity(host.Security),
}
if !ct.CreatedAt.IsZero() {
docker.CreatedAt = ct.CreatedAt.UTC().Format(time.RFC3339)
}
if ct.StartedAt != nil && !ct.StartedAt.IsZero() {
startedAt := ct.StartedAt.UTC()
docker.StartedAt = &startedAt
}
if ct.FinishedAt != nil && !ct.FinishedAt.IsZero() {
finishedAt := ct.FinishedAt.UTC()
docker.FinishedAt = &finishedAt
}
if ct.BlockIO != nil && (ct.BlockIO.ReadBytes > 0 || ct.BlockIO.WriteBytes > 0) {
docker.BlockIO = &DockerContainerBlockIOMeta{
ReadBytes: ct.BlockIO.ReadBytes,
WriteBytes: ct.BlockIO.WriteBytes,
}
}
if ct.Podman != nil {
docker.Podman = &DockerPodmanContainerMeta{
PodName: strings.TrimSpace(ct.Podman.PodName),
PodID: strings.TrimSpace(ct.Podman.PodID),
Infra: ct.Podman.Infra,
ComposeProject: strings.TrimSpace(ct.Podman.ComposeProject),
ComposeService: strings.TrimSpace(ct.Podman.ComposeService),
AutoUpdatePolicy: strings.TrimSpace(ct.Podman.AutoUpdatePolicy),
UserNamespace: strings.TrimSpace(ct.Podman.UserNamespace),
}
}
if len(ct.Ports) > 0 {
docker.Ports = make([]DockerPortMeta, len(ct.Ports))
for i, p := range ct.Ports {
docker.Ports[i] = DockerPortMeta{
PrivatePort: p.PrivatePort,
PublicPort: p.PublicPort,
Protocol: p.Protocol,
IP: p.IP,
}
}
}
if len(ct.Networks) > 0 {
docker.Networks = make([]DockerNetworkMeta, len(ct.Networks))
for i, n := range ct.Networks {
docker.Networks[i] = DockerNetworkMeta{
Name: n.Name,
IPv4: n.IPv4,
IPv6: n.IPv6,
}
}
}
if len(ct.Mounts) > 0 {
docker.Mounts = make([]DockerMountMeta, len(ct.Mounts))
for i, m := range ct.Mounts {
docker.Mounts[i] = DockerMountMeta{
Type: m.Type,
Source: m.Source,
Destination: m.Destination,
Mode: m.Mode,
RW: m.RW,
}
}
}
if ct.UpdateStatus != nil {
docker.UpdateStatus = &DockerUpdateStatusMeta{
UpdateAvailable: ct.UpdateStatus.UpdateAvailable,
CurrentDigest: ct.UpdateStatus.CurrentDigest,
LatestDigest: ct.UpdateStatus.LatestDigest,
LastChecked: ct.UpdateStatus.LastChecked,
Error: ct.UpdateStatus.Error,
}
}
resource := Resource{
Type: ResourceTypeAppContainer,
Technology: runtime,
Name: ct.Name,
Status: statusFromDockerState(ct.State),
LastSeen: host.LastSeen,
UpdatedAt: now,
Metrics: metrics,
Capabilities: dockerContainerLifecycleCapabilities(ct, host, runtime, now),
}
resource.Docker = docker
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{ct.Name}),
}
return resource, identity
}
func dockerContainerLifecycleCapabilities(ct models.DockerContainer, host models.DockerHost, runtime string, now time.Time) []ResourceCapability {
runtime = normalizeDockerLifecycleRuntime(runtime, ct.Podman != nil)
if runtime == "" {
return nil
}
if strings.TrimSpace(host.AgentID) == "" {
return nil
}
if host.Security != nil && host.Security.MutatingCommandsBlocked {
return nil
}
if host.LastSeen.IsZero() {
return nil
}
if now.IsZero() {
now = time.Now().UTC()
} else {
now = now.UTC()
}
if threshold := defaultStaleThresholds[SourceDocker]; threshold > 0 && now.Sub(host.LastSeen.UTC()) > threshold {
return nil
}
var capabilities []ResourceCapability
switch strings.ToLower(strings.TrimSpace(ct.State)) {
case "running":
capabilities = dockerContainerLifecycleCapabilitySet(runtime, "stop", "restart")
case "created", "exited", "dead", "stopped":
capabilities = dockerContainerLifecycleCapabilitySet(runtime, "start")
default:
return nil
}
if ct.UpdateStatus != nil && ct.UpdateStatus.UpdateAvailable && strings.TrimSpace(ct.UpdateStatus.CurrentDigest) != "" {
capabilities = append(capabilities, dockerContainerUpdateCapability(runtime))
}
return capabilities
}
func dockerContainerUpdateCapability(runtime string) ResourceCapability {
displayRuntime := "Docker"
if runtime == "podman" {
displayRuntime = "Podman"
}
return ResourceCapability{
Name: "update",
Type: CapabilityTypeCommon,
Description: fmt.Sprintf("Update this %s container to its latest image through its reporting Pulse agent, with automatic backup and rollback.", displayRuntime),
MinimumApprovalLevel: ApprovalAdmin,
AutoAuthorization: AutoAuthorizeLowRisk,
SupportsRollback: true,
Platform: runtime,
InternalHandler: "docker.container.update",
}
}
func normalizeDockerLifecycleRuntime(runtime string, podman bool) string {
switch strings.ToLower(strings.TrimSpace(runtime)) {
case "docker":
return "docker"
case "podman":
return "podman"
case "":
if podman {
return "podman"
}
return "docker"
default:
return ""
}
}
func dockerContainerLifecycleCapabilitySet(runtime string, names ...string) []ResourceCapability {
displayRuntime := "Docker"
if runtime == "podman" {
displayRuntime = "Podman"
}
capabilities := make([]ResourceCapability, 0, len(names))
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" {
continue
}
autoAuthorization := AutoAuthorizeNever
if name == "restart" {
autoAuthorization = AutoAuthorizeLowRisk
}
capabilities = append(capabilities, ResourceCapability{
Name: name,
Type: CapabilityTypeCommon,
Description: fmt.Sprintf("%s this %s container through its reporting Pulse agent.", titleAction(name), displayRuntime),
MinimumApprovalLevel: ApprovalAdmin,
AutoAuthorization: autoAuthorization,
Platform: runtime,
InternalHandler: "docker.container.lifecycle",
})
}
return capabilities
}
func titleAction(action string) string {
action = strings.TrimSpace(action)
if action == "" {
return "Run"
}
return strings.ToUpper(action[:1]) + action[1:]
}
func resourceFromDockerService(service models.DockerService, host models.DockerHost) (Resource, ResourceIdentity) {
now := time.Now().UTC()
clusterName := ""
if host.Swarm != nil {
clusterName = strings.TrimSpace(host.Swarm.ClusterName)
if clusterName == "" {
clusterName = strings.TrimSpace(host.Swarm.ClusterID)
}
}
docker := &DockerData{
HostSourceID: host.ID,
Hostname: host.Hostname,
ServiceID: service.ID,
Stack: strings.TrimSpace(service.Stack),
Image: strings.TrimSpace(service.Image),
Mode: strings.TrimSpace(service.Mode),
DesiredTasks: service.DesiredTasks,
RunningTasks: service.RunningTasks,
CompletedTasks: service.CompletedTasks,
Labels: cloneLabelMap(service.Labels),
Swarm: convertSwarm(host.Swarm),
}
if service.UpdateStatus != nil {
docker.ServiceUpdate = &DockerServiceUpdateMeta{
State: strings.TrimSpace(service.UpdateStatus.State),
Message: strings.TrimSpace(service.UpdateStatus.Message),
CompletedAt: service.UpdateStatus.CompletedAt,
}
}
if len(service.EndpointPorts) > 0 {
ports := make([]DockerServicePortMeta, 0, len(service.EndpointPorts))
for _, port := range service.EndpointPorts {
ports = append(ports, DockerServicePortMeta{
Name: strings.TrimSpace(port.Name),
Protocol: strings.TrimSpace(port.Protocol),
TargetPort: port.TargetPort,
PublishedPort: port.PublishedPort,
PublishMode: strings.TrimSpace(port.PublishMode),
})
}
docker.EndpointPorts = ports
}
resource := Resource{
Type: ResourceTypeDockerService,
Technology: "docker",
Name: strings.TrimSpace(service.Name),
Status: statusFromDockerService(service),
LastSeen: host.LastSeen,
UpdatedAt: now,
Docker: docker,
Tags: labelsToTags(docker.Labels),
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{
strings.TrimSpace(service.Name),
}),
ClusterName: clusterName,
}
if docker.Stack != "" {
identity.Hostnames = uniqueStrings(append(identity.Hostnames, docker.Stack+":"+strings.TrimSpace(service.Name)))
}
return resource, identity
}
func resourceFromDockerImage(image models.DockerImage, host models.DockerHost) (Resource, ResourceIdentity) {
name := firstNonEmpty(firstDockerImageReference(image), shortDigest(image.ID), image.ID)
labels := cloneLabelMap(image.Labels)
docker := &DockerData{
HostSourceID: host.ID,
Hostname: host.Hostname,
ImageID: strings.TrimSpace(image.ID),
Image: name,
RepoTags: append([]string(nil), image.RepoTags...),
RepoDigests: append([]string(nil), image.RepoDigests...),
SizeBytes: image.SizeBytes,
SharedSizeBytes: image.SharedSizeBytes,
ImageContainers: image.Containers,
Runtime: strings.TrimSpace(host.Runtime),
RuntimeVersion: strings.TrimSpace(host.RuntimeVersion),
DockerVersion: strings.TrimSpace(host.DockerVersion),
Labels: labels,
}
resource := Resource{
Type: ResourceTypeDockerImage,
Technology: firstNonEmpty(strings.TrimSpace(host.Runtime), "docker"),
Name: name,
Status: StatusOnline,
LastSeen: host.LastSeen,
UpdatedAt: time.Now().UTC(),
Docker: docker,
Tags: labelsToTags(labels),
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{name, host.Hostname + ":" + name}),
}
return resource, identity
}
func resourceFromDockerVolume(volume models.DockerVolume, host models.DockerHost) (Resource, ResourceIdentity) {
name := strings.TrimSpace(volume.Name)
labels := cloneLabelMap(volume.Labels)
docker := &DockerData{
HostSourceID: host.ID,
Hostname: host.Hostname,
VolumeName: name,
Driver: strings.TrimSpace(volume.Driver),
Mountpoint: strings.TrimSpace(volume.Mountpoint),
Scope: strings.TrimSpace(volume.Scope),
CreatedAt: strings.TrimSpace(volume.CreatedAt),
SizeBytes: volume.SizeBytes,
RefCount: volume.RefCount,
Runtime: strings.TrimSpace(host.Runtime),
Labels: labels,
Options: cloneLabelMap(volume.Options),
}
resource := Resource{
Type: ResourceTypeDockerVolume,
Technology: firstNonEmpty(strings.TrimSpace(host.Runtime), "docker"),
Name: name,
Status: StatusOnline,
LastSeen: host.LastSeen,
UpdatedAt: time.Now().UTC(),
Docker: docker,
Tags: labelsToTags(labels),
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{name, host.Hostname + ":" + name}),
}
return resource, identity
}
func resourceFromDockerNetwork(network models.DockerNetwork, host models.DockerHost) (Resource, ResourceIdentity) {
name := strings.TrimSpace(network.Name)
labels := cloneLabelMap(network.Labels)
subnets := make([]DockerNetworkSubnetMeta, 0, len(network.Subnets))
for _, subnet := range network.Subnets {
subnets = append(subnets, DockerNetworkSubnetMeta{
Subnet: strings.TrimSpace(subnet.Subnet),
Gateway: strings.TrimSpace(subnet.Gateway),
})
}
docker := &DockerData{
HostSourceID: host.ID,
Hostname: host.Hostname,
NetworkID: strings.TrimSpace(network.ID),
Driver: strings.TrimSpace(network.Driver),
Scope: strings.TrimSpace(network.Scope),
EnableIPv4: network.EnableIPv4,
EnableIPv6: network.EnableIPv6,
Internal: network.Internal,
Attachable: network.Attachable,
Ingress: network.Ingress,
ConfigOnly: network.ConfigOnly,
Runtime: strings.TrimSpace(host.Runtime),
Labels: labels,
Options: cloneLabelMap(network.Options),
Subnets: subnets,
}
resource := Resource{
Type: ResourceTypeDockerNetwork,
Technology: firstNonEmpty(strings.TrimSpace(host.Runtime), "docker"),
Name: name,
Status: StatusOnline,
LastSeen: host.LastSeen,
UpdatedAt: time.Now().UTC(),
Docker: docker,
Tags: labelsToTags(labels),
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{name, host.Hostname + ":" + name}),
}
return resource, identity
}
func resourceFromDockerTask(task models.DockerTask, host models.DockerHost) (Resource, ResourceIdentity) {
name := firstNonEmpty(strings.TrimSpace(task.ServiceName), strings.TrimSpace(task.ContainerName), shortDigest(task.ID), task.ID)
if task.Slot > 0 && task.ServiceName != "" {
name = fmt.Sprintf("%s.%d", task.ServiceName, task.Slot)
}
docker := &DockerData{
HostSourceID: host.ID,
Hostname: host.Hostname,
TaskID: strings.TrimSpace(task.ID),
ServiceID: strings.TrimSpace(task.ServiceID),
ServiceName: strings.TrimSpace(task.ServiceName),
Slot: task.Slot,
NodeID: strings.TrimSpace(task.NodeID),
NodeName: strings.TrimSpace(task.NodeName),
DesiredState: strings.TrimSpace(task.DesiredState),
CurrentState: strings.TrimSpace(task.CurrentState),
Error: strings.TrimSpace(task.Error),
Message: strings.TrimSpace(task.Message),
ContainerID: strings.TrimSpace(task.ContainerID),
ContainerState: strings.TrimSpace(task.CurrentState),
StartedAt: task.StartedAt,
CompletedAt: task.CompletedAt,
Swarm: convertSwarm(host.Swarm),
}
resource := Resource{
Type: ResourceTypeDockerTask,
Technology: "docker",
Name: name,
Status: statusFromDockerTask(task),
LastSeen: host.LastSeen,
UpdatedAt: time.Now().UTC(),
Docker: docker,
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{name, strings.TrimSpace(task.ID)}),
ClusterName: dockerSwarmClusterKeyFromMeta(docker.Swarm),
}
return resource, identity
}
func resourceFromDockerSwarmNode(node models.DockerNode, host models.DockerHost) (Resource, ResourceIdentity) {
name := firstNonEmpty(strings.TrimSpace(node.Hostname), shortDigest(node.ID), node.ID)
clusterName := dockerSwarmClusterKeyFromMeta(convertSwarm(host.Swarm))
labels := cloneLabelMap(node.Labels)
docker := &DockerData{
HostSourceID: host.ID,
Hostname: strings.TrimSpace(host.Hostname),
NodeID: strings.TrimSpace(node.ID),
NodeName: strings.TrimSpace(node.Hostname),
NodeRole: strings.TrimSpace(node.Role),
Availability: strings.TrimSpace(node.Availability),
CurrentState: strings.TrimSpace(node.State),
Message: strings.TrimSpace(node.Message),
Address: strings.TrimSpace(node.Address),
ManagerReachability: strings.TrimSpace(node.ManagerReachability),
ManagerAddress: strings.TrimSpace(node.ManagerAddress),
Leader: node.Leader,
EngineVersion: strings.TrimSpace(node.EngineVersion),
RuntimeVersion: strings.TrimSpace(node.EngineVersion),
OS: strings.TrimSpace(node.OS),
Architecture: strings.TrimSpace(node.Architecture),
NanoCPUs: node.NanoCPUs,
MemoryBytes: node.MemoryBytes,
Runtime: "docker",
Labels: labels,
EngineLabels: cloneLabelMap(node.EngineLabels),
Swarm: convertSwarm(host.Swarm),
}
resource := Resource{
Type: ResourceTypeDockerSwarmNode,
Technology: "docker",
Name: name,
Status: statusFromDockerSwarmNode(node),
LastSeen: host.LastSeen,
UpdatedAt: time.Now().UTC(),
Docker: docker,
Tags: labelsToTags(labels),
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{name, strings.TrimSpace(node.ID)}),
}
if clusterName != "" {
identity.ClusterName = clusterName
identity.Hostnames = uniqueStrings(append(identity.Hostnames, clusterName+":"+name))
}
return resource, identity
}
func resourceFromDockerSecret(secret models.DockerSecret, host models.DockerHost) (Resource, ResourceIdentity) {
name := firstNonEmpty(strings.TrimSpace(secret.Name), shortDigest(secret.ID), secret.ID)
clusterName := dockerSwarmClusterKeyFromMeta(convertSwarm(host.Swarm))
labels := cloneLabelMap(secret.Labels)
docker := &DockerData{
HostSourceID: host.ID,
Hostname: strings.TrimSpace(host.Hostname),
SecretID: strings.TrimSpace(secret.ID),
SecretName: strings.TrimSpace(secret.Name),
Driver: strings.TrimSpace(secret.DriverName),
TemplatingDriver: strings.TrimSpace(secret.TemplatingDriver),
ObjectCreatedAt: timePtr(secret.CreatedAt),
ObjectUpdatedAt: secret.UpdatedAt,
Runtime: "docker",
Labels: labels,
Swarm: convertSwarm(host.Swarm),
}
resource := Resource{
Type: ResourceTypeDockerSecret,
Technology: "docker",
Name: name,
Status: StatusOnline,
LastSeen: host.LastSeen,
UpdatedAt: time.Now().UTC(),
Docker: docker,
Tags: labelsToTags(labels),
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{name, strings.TrimSpace(secret.ID)}),
}
if clusterName != "" {
identity.ClusterName = clusterName
identity.Hostnames = uniqueStrings(append(identity.Hostnames, clusterName+":"+name))
}
return resource, identity
}
func resourceFromDockerConfig(config models.DockerConfig, host models.DockerHost) (Resource, ResourceIdentity) {
name := firstNonEmpty(strings.TrimSpace(config.Name), shortDigest(config.ID), config.ID)
clusterName := dockerSwarmClusterKeyFromMeta(convertSwarm(host.Swarm))
labels := cloneLabelMap(config.Labels)
docker := &DockerData{
HostSourceID: host.ID,
Hostname: strings.TrimSpace(host.Hostname),
ConfigID: strings.TrimSpace(config.ID),
ConfigName: strings.TrimSpace(config.Name),
TemplatingDriver: strings.TrimSpace(config.TemplatingDriver),
ObjectCreatedAt: timePtr(config.CreatedAt),
ObjectUpdatedAt: config.UpdatedAt,
Runtime: "docker",
Labels: labels,
Swarm: convertSwarm(host.Swarm),
}
resource := Resource{
Type: ResourceTypeDockerConfig,
Technology: "docker",
Name: name,
Status: StatusOnline,
LastSeen: host.LastSeen,
UpdatedAt: time.Now().UTC(),
Docker: docker,
Tags: labelsToTags(labels),
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{name, strings.TrimSpace(config.ID)}),
}
if clusterName != "" {
identity.ClusterName = clusterName
identity.Hostnames = uniqueStrings(append(identity.Hostnames, clusterName+":"+name))
}
return resource, identity
}
func firstDockerImageReference(image models.DockerImage) string {
for _, candidate := range image.RepoTags {
candidate = strings.TrimSpace(candidate)
if candidate != "" && candidate != "<none>:<none>" {
return candidate
}
}
for _, candidate := range image.RepoDigests {
if value := strings.TrimSpace(candidate); value != "" {
return value
}
}
return ""
}
func shortDigest(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "sha256:")
if len(value) > 12 {
return value[:12]
}
return value
}
func dockerStorageUsageMeta(bucket models.DockerStorageUsageBucket) *DockerStorageUsageMeta {
if bucket.TotalCount == 0 && bucket.ActiveCount == 0 && bucket.TotalSizeBytes == 0 && bucket.ReclaimableBytes == 0 {
return nil
}
return &DockerStorageUsageMeta{
TotalCount: bucket.TotalCount,
ActiveCount: bucket.ActiveCount,
TotalSizeBytes: bucket.TotalSizeBytes,
ReclaimableBytes: bucket.ReclaimableBytes,
}
}
func resourceFromKubernetesCluster(cluster models.KubernetesCluster, linkedHosts []*models.Host, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
metrics := metricsFromKubernetesCluster(cluster, linkedHosts)
resource := Resource{
Type: ResourceTypeK8sCluster,
Name: clusterName,
Status: statusFromKubernetesCluster(cluster),
LastSeen: cluster.LastSeen,
UpdatedAt: time.Now().UTC(),
Metrics: metrics,
Kubernetes: &K8sData{
ClusterID: cluster.ID,
ClusterName: clusterName,
SourceName: cluster.Name,
SourceStatus: cluster.Status,
AgentID: cluster.AgentID,
Context: cluster.Context,
Server: cluster.Server,
Version: cluster.Version,
PendingUninstall: cluster.PendingUninstall,
AgentVersion: cluster.AgentVersion,
IntervalSeconds: cluster.IntervalSeconds,
MetricCapabilities: cloneKubernetesMetricCapabilities(capabilities),
},
Tags: nil,
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{
cluster.Name,
cluster.DisplayName,
cluster.CustomDisplayName,
cluster.Context,
extractHostname(cluster.Server),
}),
ClusterName: clusterName,
}
return resource, identity
}
func resourceFromKubernetesNode(cluster models.KubernetesCluster, node models.KubernetesNode, linkedHost *models.Host, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
metrics := metricsFromKubernetesNode(cluster, node, linkedHost)
uptimeSeconds := int64(0)
var temperature *float64
if linkedHost != nil {
uptimeSeconds = linkedHost.UptimeSeconds
temperature = maxCPUTemp(linkedHost.Sensors)
}
resource := Resource{
Type: ResourceTypeK8sNode,
Name: node.Name,
Status: statusFromKubernetesNode(node),
LastSeen: cluster.LastSeen,
UpdatedAt: time.Now().UTC(),
Metrics: metrics,
Kubernetes: &K8sData{
ClusterID: cluster.ID,
ClusterName: clusterName,
AgentID: cluster.AgentID,
AgentVersion: cluster.AgentVersion,
Context: cluster.Context,
Server: cluster.Server,
Version: cluster.Version,
NodeUID: node.UID,
NodeName: node.Name,
Ready: node.Ready,
Unschedulable: node.Unschedulable,
Roles: append([]string(nil), node.Roles...),
KubeletVersion: node.KubeletVersion,
ContainerRuntimeVersion: node.ContainerRuntimeVersion,
OSImage: node.OSImage,
KernelVersion: node.KernelVersion,
Architecture: node.Architecture,
CapacityCPU: node.CapacityCPU,
CapacityMemoryBytes: node.CapacityMemoryBytes,
CapacityPods: node.CapacityPods,
AllocCPU: node.AllocCPU,
AllocMemoryBytes: node.AllocMemoryBytes,
AllocPods: node.AllocPods,
UptimeSeconds: uptimeSeconds,
Temperature: temperature,
MetricCapabilities: cloneKubernetesMetricCapabilities(capabilities),
},
Tags: append([]string(nil), node.Roles...),
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{
node.Name,
clusterName + ":" + node.Name,
}),
ClusterName: clusterName,
}
return resource, identity
}
func resourceFromKubernetesPod(cluster models.KubernetesCluster, pod models.KubernetesPod, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(pod.Labels)
primaryImage := ""
now := time.Now().UTC()
if len(pod.Containers) > 0 {
primaryImage = pod.Containers[0].Image
}
metrics := metricsFromKubernetesPod(cluster, pod)
resource := Resource{
Type: ResourceTypePod,
Technology: "kubernetes",
Name: pod.Name,
Status: statusFromKubernetesPod(pod),
LastSeen: cluster.LastSeen,
UpdatedAt: now,
Metrics: metrics,
Kubernetes: &K8sData{
ClusterID: cluster.ID,
ClusterName: clusterName,
ResourceUID: pod.UID,
ResourceKind: "Pod",
AgentID: cluster.AgentID,
Context: cluster.Context,
Server: cluster.Server,
Version: cluster.Version,
Namespace: pod.Namespace,
PodUID: pod.UID,
NodeName: pod.NodeName,
PodPhase: pod.Phase,
PodReason: pod.Reason,
PodMessage: pod.Message,
PodContainers: cloneK8sPodContainers(pod.Containers),
QoSClass: strings.TrimSpace(pod.QoSClass),
UptimeSeconds: func() int64 {
if pod.StartTime != nil {
start := pod.StartTime.UTC()
if !start.IsZero() && !start.After(now) {
return int64(now.Sub(start).Seconds())
}
}
created := pod.CreatedAt.UTC()
if !created.IsZero() && !created.After(now) {
return int64(now.Sub(created).Seconds())
}
return 0
}(),
Restarts: pod.Restarts,
OwnerKind: pod.OwnerKind,
OwnerName: pod.OwnerName,
Image: primaryImage,
Labels: labels,
CreatedAt: zeroTimeToPtr(pod.CreatedAt),
MetricCapabilities: cloneKubernetesMetricCapabilities(capabilities),
},
Tags: labelsToTags(labels),
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{
pod.Name,
pod.Namespace + "/" + pod.Name,
}),
ClusterName: clusterName,
}
return resource, identity
}
func resourceFromKubernetesDeployment(cluster models.KubernetesCluster, deployment models.KubernetesDeployment, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(deployment.Labels)
resource := Resource{
Type: ResourceTypeK8sDeployment,
Name: deployment.Name,
Status: statusFromKubernetesDeployment(deployment),
LastSeen: cluster.LastSeen,
UpdatedAt: time.Now().UTC(),
Metrics: metricsFromKubernetesDeployment(cluster, deployment),
Kubernetes: &K8sData{
ClusterID: cluster.ID,
ClusterName: clusterName,
ResourceUID: deployment.UID,
ResourceKind: "Deployment",
AgentID: cluster.AgentID,
Context: cluster.Context,
Server: cluster.Server,
Version: cluster.Version,
Namespace: deployment.Namespace,
DeploymentUID: deployment.UID,
DesiredReplicas: deployment.DesiredReplicas,
UpdatedReplicas: deployment.UpdatedReplicas,
ReadyReplicas: deployment.ReadyReplicas,
AvailableReplicas: deployment.AvailableReplicas,
ObservedGeneration: deployment.ObservedGeneration,
Labels: labels,
CreatedAt: zeroTimeToPtr(deployment.CreatedAt),
MetricCapabilities: cloneKubernetesMetricCapabilities(capabilities),
},
Tags: labelsToTags(labels),
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{
deployment.Name,
deployment.Namespace + "/" + deployment.Name,
}),
ClusterName: clusterName,
}
return resource, identity
}
func resourceFromKubernetesReplicaSet(cluster models.KubernetesCluster, replicaSet models.KubernetesReplicaSet, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(replicaSet.Labels)
data := baseKubernetesData(cluster, clusterName, "ReplicaSet", capabilities)
data.ReplicaSetUID = replicaSet.UID
data.ResourceUID = replicaSet.UID
data.Namespace = replicaSet.Namespace
data.DesiredReplicas = replicaSet.DesiredReplicas
data.ReadyReplicas = replicaSet.ReadyReplicas
data.AvailableReplicas = replicaSet.AvailableReplicas
data.FullyLabeledReplicas = replicaSet.FullyLabeledReplicas
data.ObservedGeneration = replicaSet.ObservedGeneration
data.OwnerKind = replicaSet.OwnerKind
data.OwnerName = replicaSet.OwnerName
data.Labels = labels
return namespacedKubernetesResource(cluster, clusterName, replicaSet.Namespace, replicaSet.Name, ResourceTypeK8sReplicaSet, statusFromKubernetesReplicaSet(replicaSet), data, labels)
}
// namespacedKubernetesResource assembles the Resource scaffold and namespaced
// identity shared by kubernetes adapters once the kind-specific K8sData fields
// are populated.
func namespacedKubernetesResource(cluster models.KubernetesCluster, clusterName, namespace, name string, resourceType ResourceType, status ResourceStatus, data K8sData, labels map[string]string) (Resource, ResourceIdentity) {
resource := Resource{
Type: resourceType,
Technology: "kubernetes",
Name: name,
Status: status,
LastSeen: cluster.LastSeen,
UpdatedAt: time.Now().UTC(),
Kubernetes: &data,
Tags: labelsToTags(labels),
}
return resource, namespacedKubernetesIdentity(clusterName, namespace, name)
}
func baseKubernetesData(cluster models.KubernetesCluster, clusterName, resourceKind string, capabilities *K8sMetricCapabilities) K8sData {
return K8sData{
ClusterID: cluster.ID,
ClusterName: clusterName,
ResourceKind: resourceKind,
AgentID: cluster.AgentID,
Context: cluster.Context,
Server: cluster.Server,
Version: cluster.Version,
MetricCapabilities: cloneKubernetesMetricCapabilities(capabilities),
}
}
func resourceFromKubernetesNamespace(cluster models.KubernetesCluster, namespace models.KubernetesNamespace, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(namespace.Labels)
data := baseKubernetesData(cluster, clusterName, "Namespace", capabilities)
data.NamespaceUID = namespace.UID
data.ResourceUID = namespace.UID
data.Namespace = namespace.Name
data.Phase = namespace.Phase
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(namespace.CreatedAt)
resource := Resource{
Type: ResourceTypeK8sNamespace,
Technology: "kubernetes",
Name: namespace.Name,
Status: statusFromKubernetesPhase(namespace.Phase),
LastSeen: cluster.LastSeen,
UpdatedAt: time.Now().UTC(),
Kubernetes: &data,
Tags: labelsToTags(labels),
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{namespace.Name, clusterName + ":" + namespace.Name}),
ClusterName: clusterName,
}
return resource, identity
}
func resourceFromKubernetesService(cluster models.KubernetesCluster, service models.KubernetesService, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(service.Labels)
data := baseKubernetesData(cluster, clusterName, "Service", capabilities)
data.ServiceUID = service.UID
data.ResourceUID = service.UID
data.Namespace = service.Namespace
data.ServiceType = service.ServiceType
data.ClusterIP = service.ClusterIP
data.ExternalIPs = append([]string(nil), service.ExternalIPs...)
data.ServicePorts = k8sServicePorts(service.Ports)
data.Selector = cloneLabelMap(service.Selector)
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(service.CreatedAt)
return namespacedKubernetesResource(cluster, clusterName, service.Namespace, service.Name, ResourceTypeK8sService, StatusOnline, data, labels)
}
func resourceFromKubernetesStatefulSet(cluster models.KubernetesCluster, statefulSet models.KubernetesStatefulSet, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(statefulSet.Labels)
data := baseKubernetesData(cluster, clusterName, "StatefulSet", capabilities)
data.StatefulSetUID = statefulSet.UID
data.ResourceUID = statefulSet.UID
data.Namespace = statefulSet.Namespace
data.DesiredReplicas = statefulSet.DesiredReplicas
data.ReadyReplicas = statefulSet.ReadyReplicas
data.CurrentReplicas = statefulSet.CurrentReplicas
data.UpdatedReplicas = statefulSet.UpdatedReplicas
data.AvailableReplicas = statefulSet.AvailableReplicas
data.ServiceName = statefulSet.ServiceName
data.Labels = labels
return namespacedKubernetesResource(cluster, clusterName, statefulSet.Namespace, statefulSet.Name, ResourceTypeK8sStatefulSet, statusFromKubernetesStatefulSet(statefulSet), data, labels)
}
func resourceFromKubernetesDaemonSet(cluster models.KubernetesCluster, daemonSet models.KubernetesDaemonSet, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(daemonSet.Labels)
data := baseKubernetesData(cluster, clusterName, "DaemonSet", capabilities)
data.DaemonSetUID = daemonSet.UID
data.ResourceUID = daemonSet.UID
data.Namespace = daemonSet.Namespace
data.DesiredNumberScheduled = daemonSet.DesiredNumberScheduled
data.CurrentNumberScheduled = daemonSet.CurrentNumberScheduled
data.NumberReady = daemonSet.NumberReady
data.UpdatedReplicas = daemonSet.UpdatedNumberScheduled
data.NumberAvailable = daemonSet.NumberAvailable
data.NumberUnavailable = daemonSet.NumberUnavailable
data.NumberMisscheduled = daemonSet.NumberMisscheduled
data.Labels = labels
return namespacedKubernetesResource(cluster, clusterName, daemonSet.Namespace, daemonSet.Name, ResourceTypeK8sDaemonSet, statusFromKubernetesDaemonSet(daemonSet), data, labels)
}
func resourceFromKubernetesJob(cluster models.KubernetesCluster, job models.KubernetesJob, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(job.Labels)
data := baseKubernetesData(cluster, clusterName, "Job", capabilities)
data.JobUID = job.UID
data.ResourceUID = job.UID
data.Namespace = job.Namespace
data.DesiredReplicas = job.DesiredCompletions
data.Succeeded = job.Succeeded
data.Failed = job.Failed
data.Active = job.Active
data.StartTime = job.StartTime
data.CompletionTime = job.CompletionTime
data.Labels = labels
return namespacedKubernetesResource(cluster, clusterName, job.Namespace, job.Name, ResourceTypeK8sJob, statusFromKubernetesJob(job), data, labels)
}
func resourceFromKubernetesCronJob(cluster models.KubernetesCluster, cronJob models.KubernetesCronJob, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(cronJob.Labels)
data := baseKubernetesData(cluster, clusterName, "CronJob", capabilities)
data.CronJobUID = cronJob.UID
data.ResourceUID = cronJob.UID
data.Namespace = cronJob.Namespace
data.Schedule = cronJob.Schedule
data.Suspend = cronJob.Suspend
data.Active = int32(cronJob.Active)
data.LastScheduleTime = cronJob.LastScheduleTime
data.LastSuccessfulTime = cronJob.LastSuccessfulTime
data.Labels = labels
return namespacedKubernetesResource(cluster, clusterName, cronJob.Namespace, cronJob.Name, ResourceTypeK8sCronJob, statusFromKubernetesCronJob(cronJob), data, labels)
}
func resourceFromKubernetesIngress(cluster models.KubernetesCluster, ingress models.KubernetesIngress, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(ingress.Labels)
data := baseKubernetesData(cluster, clusterName, "Ingress", capabilities)
data.IngressUID = ingress.UID
data.ResourceUID = ingress.UID
data.Namespace = ingress.Namespace
data.ClassName = ingress.ClassName
data.Hosts = append([]string(nil), ingress.Hosts...)
data.Addresses = append([]string(nil), ingress.Addresses...)
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(ingress.CreatedAt)
return namespacedKubernetesResource(cluster, clusterName, ingress.Namespace, ingress.Name, ResourceTypeK8sIngress, StatusOnline, data, labels)
}
func resourceFromKubernetesEndpointSlice(cluster models.KubernetesCluster, slice models.KubernetesEndpointSlice, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(slice.Labels)
ports := make([]K8sEndpointPort, 0, len(slice.Ports))
for _, port := range slice.Ports {
ports = append(ports, K8sEndpointPort{
Name: port.Name,
Protocol: port.Protocol,
Port: port.Port,
AppProtocol: port.AppProtocol,
})
}
data := baseKubernetesData(cluster, clusterName, "EndpointSlice", capabilities)
data.EndpointSliceUID = slice.UID
data.ResourceUID = slice.UID
data.Namespace = slice.Namespace
data.AddressType = slice.AddressType
data.ServiceName = slice.ServiceName
data.EndpointPorts = ports
data.EndpointCount = slice.EndpointCount
data.ReadyEndpointCount = slice.ReadyEndpointCount
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(slice.CreatedAt)
return namespacedKubernetesResource(cluster, clusterName, slice.Namespace, slice.Name, ResourceTypeK8sEndpointSlice, statusFromKubernetesEndpointSlice(slice), data, labels)
}
func resourceFromKubernetesNetworkPolicy(cluster models.KubernetesCluster, policy models.KubernetesNetworkPolicy, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(policy.Labels)
data := baseKubernetesData(cluster, clusterName, "NetworkPolicy", capabilities)
data.NetworkPolicyUID = policy.UID
data.ResourceUID = policy.UID
data.Namespace = policy.Namespace
data.PolicyTypes = append([]string(nil), policy.PolicyTypes...)
data.IngressRuleCount = policy.IngressRuleCount
data.EgressRuleCount = policy.EgressRuleCount
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(policy.CreatedAt)
return namespacedKubernetesResource(cluster, clusterName, policy.Namespace, policy.Name, ResourceTypeK8sNetworkPolicy, StatusOnline, data, labels)
}
func resourceFromKubernetesPersistentVolume(cluster models.KubernetesCluster, volume models.KubernetesPersistentVolume, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(volume.Labels)
data := baseKubernetesData(cluster, clusterName, "PersistentVolume", capabilities)
data.PersistentVolumeUID = volume.UID
data.ResourceUID = volume.UID
data.Phase = volume.Phase
data.StorageClass = volume.StorageClass
data.CapacityBytes = volume.CapacityBytes
data.AccessModes = append([]string(nil), volume.AccessModes...)
data.ReclaimPolicy = volume.ReclaimPolicy
data.ClaimNamespace = volume.ClaimNamespace
data.ClaimName = volume.ClaimName
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(volume.CreatedAt)
resource := Resource{
Type: ResourceTypeK8sPV,
Technology: "kubernetes",
Name: volume.Name,
Status: statusFromKubernetesPhase(volume.Phase),
LastSeen: cluster.LastSeen,
UpdatedAt: time.Now().UTC(),
Kubernetes: &data,
Tags: labelsToTags(labels),
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{volume.Name, clusterName + ":" + volume.Name}),
ClusterName: clusterName,
}
return resource, identity
}
func resourceFromKubernetesPersistentVolumeClaim(cluster models.KubernetesCluster, claim models.KubernetesPersistentVolumeClaim, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(claim.Labels)
data := baseKubernetesData(cluster, clusterName, "PersistentVolumeClaim", capabilities)
data.PersistentVolumeClaimUID = claim.UID
data.ResourceUID = claim.UID
data.Namespace = claim.Namespace
data.Phase = claim.Phase
data.StorageClass = claim.StorageClass
data.RequestedBytes = claim.RequestedBytes
data.CapacityBytes = claim.CapacityBytes
data.AccessModes = append([]string(nil), claim.AccessModes...)
data.VolumeName = claim.VolumeName
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(claim.CreatedAt)
return namespacedKubernetesResource(cluster, clusterName, claim.Namespace, claim.Name, ResourceTypeK8sPVC, statusFromKubernetesPhase(claim.Phase), data, labels)
}
func resourceFromKubernetesStorageClass(cluster models.KubernetesCluster, class models.KubernetesStorageClass, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(class.Labels)
data := baseKubernetesData(cluster, clusterName, "StorageClass", capabilities)
data.StorageClassUID = class.UID
data.ResourceUID = class.UID
data.StorageClass = class.Name
data.Provisioner = class.Provisioner
data.ReclaimPolicy = class.ReclaimPolicy
data.VolumeBindingMode = class.VolumeBindingMode
data.AllowVolumeExpansion = cloneBoolPtr(class.AllowVolumeExpansion)
data.ParameterKeys = append([]string(nil), class.ParameterKeys...)
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(class.CreatedAt)
resource := Resource{
Type: ResourceTypeK8sStorageClass,
Technology: "kubernetes",
Name: class.Name,
Status: StatusOnline,
LastSeen: cluster.LastSeen,
UpdatedAt: time.Now().UTC(),
Kubernetes: &data,
Tags: labelsToTags(labels),
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{class.Name, clusterName + ":" + class.Name}),
ClusterName: clusterName,
}
return resource, identity
}
func resourceFromKubernetesConfigMap(cluster models.KubernetesCluster, configMap models.KubernetesConfigMap, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(configMap.Labels)
data := baseKubernetesData(cluster, clusterName, "ConfigMap", capabilities)
data.ConfigMapUID = configMap.UID
data.ResourceUID = configMap.UID
data.Namespace = configMap.Namespace
data.DataKeys = append([]string(nil), configMap.DataKeys...)
data.BinaryDataKeys = append([]string(nil), configMap.BinaryDataKeys...)
data.Immutable = configMap.Immutable
data.MetadataOnly = configMap.MetadataOnly
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(configMap.CreatedAt)
return namespacedKubernetesResource(cluster, clusterName, configMap.Namespace, configMap.Name, ResourceTypeK8sConfigMap, StatusOnline, data, labels)
}
func resourceFromKubernetesSecret(cluster models.KubernetesCluster, secret models.KubernetesSecret, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(secret.Labels)
data := baseKubernetesData(cluster, clusterName, "Secret", capabilities)
data.SecretUID = secret.UID
data.ResourceUID = secret.UID
data.Namespace = secret.Namespace
data.SecretType = secret.Type
data.DataKeys = append([]string(nil), secret.DataKeys...)
data.Immutable = secret.Immutable
data.MetadataOnly = secret.MetadataOnly
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(secret.CreatedAt)
return namespacedKubernetesResource(cluster, clusterName, secret.Namespace, secret.Name, ResourceTypeK8sSecret, StatusOnline, data, labels)
}
func resourceFromKubernetesServiceAccount(cluster models.KubernetesCluster, account models.KubernetesServiceAccount, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(account.Labels)
data := baseKubernetesData(cluster, clusterName, "ServiceAccount", capabilities)
data.ServiceAccountUID = account.UID
data.ResourceUID = account.UID
data.Namespace = account.Namespace
data.AutomountServiceAccountToken = cloneBoolPtr(account.AutomountServiceAccountToken)
data.SecretCount = account.SecretCount
data.ImagePullSecrets = append([]string(nil), account.ImagePullSecrets...)
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(account.CreatedAt)
return namespacedKubernetesResource(cluster, clusterName, account.Namespace, account.Name, ResourceTypeK8sServiceAccount, StatusOnline, data, labels)
}
func resourceFromKubernetesRole(cluster models.KubernetesCluster, role models.KubernetesRole, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(role.Labels)
data := baseKubernetesData(cluster, clusterName, "Role", capabilities)
data.RoleUID = role.UID
data.ResourceUID = role.UID
data.Namespace = role.Namespace
data.RuleCount = role.RuleCount
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(role.CreatedAt)
return namespacedKubernetesResource(cluster, clusterName, role.Namespace, role.Name, ResourceTypeK8sRole, StatusOnline, data, labels)
}
func resourceFromKubernetesClusterRole(cluster models.KubernetesCluster, role models.KubernetesClusterRole, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(role.Labels)
data := baseKubernetesData(cluster, clusterName, "ClusterRole", capabilities)
data.ClusterRoleUID = role.UID
data.ResourceUID = role.UID
data.RuleCount = role.RuleCount
data.AggregationLabels = cloneStringMap(role.AggregationLabels)
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(role.CreatedAt)
resource := Resource{
Type: ResourceTypeK8sClusterRole,
Technology: "kubernetes",
Name: role.Name,
Status: StatusOnline,
LastSeen: cluster.LastSeen,
UpdatedAt: time.Now().UTC(),
Kubernetes: &data,
Tags: labelsToTags(labels),
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{role.Name, clusterName + ":" + role.Name}),
ClusterName: clusterName,
}
return resource, identity
}
func resourceFromKubernetesRoleBinding(cluster models.KubernetesCluster, binding models.KubernetesRoleBinding, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(binding.Labels)
data := baseKubernetesData(cluster, clusterName, "RoleBinding", capabilities)
data.RoleBindingUID = binding.UID
data.ResourceUID = binding.UID
data.Namespace = binding.Namespace
data.RoleKind = binding.RoleKind
data.RoleName = binding.RoleName
data.SubjectCount = binding.SubjectCount
data.SubjectKinds = append([]string(nil), binding.SubjectKinds...)
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(binding.CreatedAt)
return namespacedKubernetesResource(cluster, clusterName, binding.Namespace, binding.Name, ResourceTypeK8sRoleBinding, StatusOnline, data, labels)
}
func resourceFromKubernetesClusterRoleBinding(cluster models.KubernetesCluster, binding models.KubernetesClusterRoleBinding, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(binding.Labels)
data := baseKubernetesData(cluster, clusterName, "ClusterRoleBinding", capabilities)
data.ClusterRoleBindingUID = binding.UID
data.ResourceUID = binding.UID
data.RoleKind = binding.RoleKind
data.RoleName = binding.RoleName
data.SubjectCount = binding.SubjectCount
data.SubjectKinds = append([]string(nil), binding.SubjectKinds...)
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(binding.CreatedAt)
resource := Resource{
Type: ResourceTypeK8sClusterRoleBinding,
Technology: "kubernetes",
Name: binding.Name,
Status: StatusOnline,
LastSeen: cluster.LastSeen,
UpdatedAt: time.Now().UTC(),
Kubernetes: &data,
Tags: labelsToTags(labels),
}
identity := ResourceIdentity{
Hostnames: uniqueStrings([]string{binding.Name, clusterName + ":" + binding.Name}),
ClusterName: clusterName,
}
return resource, identity
}
func resourceFromKubernetesResourceQuota(cluster models.KubernetesCluster, quota models.KubernetesResourceQuota, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(quota.Labels)
data := baseKubernetesData(cluster, clusterName, "ResourceQuota", capabilities)
data.ResourceQuotaUID = quota.UID
data.ResourceUID = quota.UID
data.Namespace = quota.Namespace
data.Hard = cloneStringMap(quota.Hard)
data.Used = cloneStringMap(quota.Used)
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(quota.CreatedAt)
return namespacedKubernetesResource(cluster, clusterName, quota.Namespace, quota.Name, ResourceTypeK8sResourceQuota, StatusOnline, data, labels)
}
func resourceFromKubernetesLimitRange(cluster models.KubernetesCluster, limitRange models.KubernetesLimitRange, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(limitRange.Labels)
data := baseKubernetesData(cluster, clusterName, "LimitRange", capabilities)
data.LimitRangeUID = limitRange.UID
data.ResourceUID = limitRange.UID
data.Namespace = limitRange.Namespace
data.LimitTypes = append([]string(nil), limitRange.LimitTypes...)
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(limitRange.CreatedAt)
return namespacedKubernetesResource(cluster, clusterName, limitRange.Namespace, limitRange.Name, ResourceTypeK8sLimitRange, StatusOnline, data, labels)
}
func resourceFromKubernetesPodDisruptionBudget(cluster models.KubernetesCluster, budget models.KubernetesPodDisruptionBudget, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(budget.Labels)
data := baseKubernetesData(cluster, clusterName, "PodDisruptionBudget", capabilities)
data.PodDisruptionBudgetUID = budget.UID
data.ResourceUID = budget.UID
data.Namespace = budget.Namespace
data.MinAvailable = budget.MinAvailable
data.MaxUnavailable = budget.MaxUnavailable
data.DesiredHealthy = budget.DesiredHealthy
data.CurrentHealthy = budget.CurrentHealthy
data.DisruptionsAllowed = budget.DisruptionsAllowed
data.ExpectedPods = budget.ExpectedPods
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(budget.CreatedAt)
return namespacedKubernetesResource(cluster, clusterName, budget.Namespace, budget.Name, ResourceTypeK8sPDB, statusFromKubernetesPodDisruptionBudget(budget), data, labels)
}
func resourceFromKubernetesHorizontalPodAutoscaler(cluster models.KubernetesCluster, autoscaler models.KubernetesHorizontalPodAutoscaler, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
labels := cloneLabelMap(autoscaler.Labels)
data := baseKubernetesData(cluster, clusterName, "HorizontalPodAutoscaler", capabilities)
data.HorizontalPodAutoscalerUID = autoscaler.UID
data.ResourceUID = autoscaler.UID
data.Namespace = autoscaler.Namespace
data.TargetKind = autoscaler.TargetKind
data.TargetName = autoscaler.TargetName
data.MinReplicas = autoscaler.MinReplicas
data.MaxReplicas = autoscaler.MaxReplicas
data.CurrentReplicas = autoscaler.CurrentReplicas
data.DesiredReplicas = autoscaler.DesiredReplicas
data.MetricTypes = append([]string(nil), autoscaler.MetricTypes...)
data.Labels = labels
data.CreatedAt = zeroTimeToPtr(autoscaler.CreatedAt)
return namespacedKubernetesResource(cluster, clusterName, autoscaler.Namespace, autoscaler.Name, ResourceTypeK8sHPA, statusFromKubernetesHorizontalPodAutoscaler(autoscaler), data, labels)
}
func resourceFromKubernetesEvent(cluster models.KubernetesCluster, event models.KubernetesEvent, capabilities *K8sMetricCapabilities) (Resource, ResourceIdentity) {
clusterName := kubernetesClusterDisplayName(cluster)
data := baseKubernetesData(cluster, clusterName, "Event", capabilities)
data.EventUID = event.UID
data.ResourceUID = event.UID
data.Namespace = event.Namespace
data.EventType = event.EventType
data.Reason = event.Reason
data.Message = event.Message
data.InvolvedKind = event.InvolvedKind
data.InvolvedName = event.InvolvedName
data.Count = event.Count
data.FirstSeen = event.FirstSeen
data.EventTime = event.EventTime
if event.LastSeen != nil {
data.CreatedAt = event.LastSeen
}
resource := Resource{
Type: ResourceTypeK8sEvent,
Technology: "kubernetes",
Name: firstNonEmpty(event.Name, event.Reason, event.InvolvedName),
Status: statusFromKubernetesEvent(event),
LastSeen: cluster.LastSeen,
UpdatedAt: time.Now().UTC(),
Kubernetes: &data,
}
return resource, namespacedKubernetesIdentity(clusterName, event.Namespace, resource.Name)
}
func k8sServicePorts(in []models.KubernetesServicePort) []K8sServicePort {
if len(in) == 0 {
return nil
}
out := make([]K8sServicePort, 0, len(in))
for _, port := range in {
out = append(out, K8sServicePort{
Name: strings.TrimSpace(port.Name),
Protocol: strings.TrimSpace(port.Protocol),
Port: port.Port,
TargetPort: strings.TrimSpace(port.TargetPort),
NodePort: port.NodePort,
})
}
return out
}
func namespacedKubernetesIdentity(clusterName, namespace, name string) ResourceIdentity {
namespace = strings.TrimSpace(namespace)
name = strings.TrimSpace(name)
values := []string{name}
if namespace != "" && name != "" {
values = append(values, namespace+"/"+name)
}
if clusterName != "" && name != "" {
values = append(values, clusterName+":"+name)
}
return ResourceIdentity{
Hostnames: uniqueStrings(values),
ClusterName: clusterName,
}
}
func kubernetesClusterDisplayName(cluster models.KubernetesCluster) string {
if v := strings.TrimSpace(cluster.CustomDisplayName); v != "" {
return v
}
if v := strings.TrimSpace(cluster.DisplayName); v != "" {
return v
}
if v := strings.TrimSpace(cluster.Name); v != "" {
return v
}
return strings.TrimSpace(cluster.ID)
}
func cloneK8sPodContainers(in []models.KubernetesPodContainer) []K8sPodContainer {
if len(in) == 0 {
return nil
}
out := make([]K8sPodContainer, len(in))
for i, c := range in {
out[i] = K8sPodContainer{
Name: c.Name,
Image: c.Image,
Ready: c.Ready,
RestartCount: c.RestartCount,
State: c.State,
Reason: c.Reason,
Message: c.Message,
}
}
return out
}
func cloneLabelMap(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
if strings.TrimSpace(k) == "" {
continue
}
out[k] = v
}
if len(out) == 0 {
return nil
}
return out
}
func labelsToTags(labels map[string]string) []string {
if len(labels) == 0 {
return nil
}
out := make([]string, 0, len(labels))
for k, v := range labels {
key := strings.TrimSpace(k)
if key == "" {
continue
}
if strings.TrimSpace(v) == "" {
out = append(out, key)
continue
}
out = append(out, key+":"+v)
}
return uniqueStrings(out)
}
func convertInterfaces(interfaces []models.HostNetworkInterface) []NetworkInterface {
out := make([]NetworkInterface, 0, len(interfaces))
for _, iface := range interfaces {
out = append(out, NetworkInterface{
Name: iface.Name,
MAC: iface.MAC,
Addresses: iface.Addresses,
RXBytes: iface.RXBytes,
TXBytes: iface.TXBytes,
SpeedMbps: iface.SpeedMbps,
})
}
return out
}
func convertDisks(disks []models.Disk) []DiskInfo {
out := make([]DiskInfo, 0, len(disks))
for _, disk := range disks {
out = append(out, DiskInfo{
Device: disk.Device,
Mountpoint: disk.Mountpoint,
Filesystem: disk.Type,
Total: disk.Total,
Used: disk.Used,
Free: disk.Free,
Usage: disk.Usage,
})
}
return out
}
func hasReportableDockerSwarmInfo(info *models.DockerSwarmInfo) bool {
if info == nil {
return false
}
state := strings.ToLower(strings.TrimSpace(info.LocalState))
if state == "inactive" {
return info.ControlAvailable ||
strings.TrimSpace(info.ClusterID) != "" ||
strings.TrimSpace(info.ClusterName) != "" ||
strings.TrimSpace(info.Error) != ""
}
return strings.TrimSpace(info.NodeID) != "" ||
state != "" ||
info.ControlAvailable ||
strings.TrimSpace(info.ClusterID) != "" ||
strings.TrimSpace(info.ClusterName) != "" ||
strings.TrimSpace(info.Error) != ""
}
func convertSwarm(info *models.DockerSwarmInfo) *DockerSwarmInfo {
if !hasReportableDockerSwarmInfo(info) {
return nil
}
return &DockerSwarmInfo{
NodeID: info.NodeID,
NodeRole: info.NodeRole,
LocalState: info.LocalState,
ControlAvailable: info.ControlAvailable,
ClusterID: info.ClusterID,
ClusterName: info.ClusterName,
Scope: info.Scope,
Error: info.Error,
}
}
func collectInterfaceIDs(interfaces []models.HostNetworkInterface) ([]string, []string) {
var ips []string
var macs []string
// Agents report interfaces sorted by name, which places docker0/br-* bridges
// ahead of eth*/en*, and consumers treat the first IP as the host's primary
// address. Collect physical-looking interfaces first so bridge and overlay
// addresses never lead the list. Mirrors hostagent.isLikelyVirtualInterfaceName.
for pass := 0; pass < 2; pass++ {
for _, iface := range interfaces {
if (pass == 0) == isLikelyVirtualInterfaceName(iface.Name) {
continue
}
if iface.MAC != "" {
macs = append(macs, iface.MAC)
}
for _, addr := range iface.Addresses {
ip := addr
if strings.Contains(ip, "/") {
ip = strings.Split(ip, "/")[0]
}
ips = append(ips, ip)
}
}
}
return ips, macs
}
func isLikelyVirtualInterfaceName(name string) bool {
name = strings.ToLower(strings.TrimSpace(name))
switch {
case name == "" || name == "lo":
return true
case strings.HasPrefix(name, "docker"):
return true
case strings.HasPrefix(name, "veth"):
return true
case strings.HasPrefix(name, "br-"):
return true
case strings.HasPrefix(name, "cni"):
return true
case strings.HasPrefix(name, "flannel"):
return true
case strings.HasPrefix(name, "virbr"):
return true
case strings.HasPrefix(name, "zt"):
return true
default:
return false
}
}
func extractHostname(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err == nil && parsed.Host != "" {
host := parsed.Host
if strings.Contains(host, ":") {
host = strings.Split(host, ":")[0]
}
return host
}
if strings.Contains(raw, "/") {
raw = strings.Split(raw, "/")[0]
}
if strings.Contains(raw, ":") {
raw = strings.Split(raw, ":")[0]
}
return raw
}
// maxCPUTemp returns the highest CPU temperature from host sensor readings.
// It looks for cpu_package first, then falls back to max of any cpu_core_* key.
func maxCPUTemp(sensors models.HostSensorSummary) *float64 {
temps := sensors.TemperatureCelsius
if len(temps) == 0 {
return nil
}
// Prefer cpu_package if available.
if v, ok := temps["cpu_package"]; ok {
return &v
}
// Fall back to max of any cpu-related key.
var best float64
found := false
for k, v := range temps {
if strings.HasPrefix(k, "cpu") {
if !found || v > best {
best = v
found = true
}
}
}
if found {
return &best
}
return nil
}
// maxNodeTemp returns the best CPU temperature from a proxmox node temperature snapshot.
func maxNodeTemp(temperature *models.Temperature) *float64 {
if temperature == nil || !temperature.Available {
return nil
}
if temperature.CPUMax > 0 {
v := temperature.CPUMax
return &v
}
if temperature.CPUPackage > 0 {
v := temperature.CPUPackage
return &v
}
var best float64
found := false
for _, core := range temperature.Cores {
if !found || core.Temp > best {
best = core.Temp
found = true
}
}
if found {
return &best
}
return nil
}
func maxPMGUptime(nodes []models.PMGNodeStatus) int64 {
var best int64
for _, node := range nodes {
if node.Uptime > best {
best = node.Uptime
}
}
return best
}
func aggregatePMGQueue(nodes []models.PMGNodeStatus) models.PMGQueueStatus {
var out models.PMGQueueStatus
for _, node := range nodes {
if node.QueueStatus == nil {
continue
}
out.Active += node.QueueStatus.Active
out.Deferred += node.QueueStatus.Deferred
out.Hold += node.QueueStatus.Hold
out.Incoming += node.QueueStatus.Incoming
out.Total += node.QueueStatus.Total
if node.QueueStatus.OldestAge > out.OldestAge {
out.OldestAge = node.QueueStatus.OldestAge
}
if node.QueueStatus.UpdatedAt.After(out.UpdatedAt) {
out.UpdatedAt = node.QueueStatus.UpdatedAt
}
}
return out
}
func uniqueStrings(values []string) []string {
seen := make(map[string]struct{})
out := make([]string, 0, len(values))
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" {
continue
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}