mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
397cad802c
Connected systems showed a PBS machine twice (API row plus host-agent row) whenever the PBS connection was configured with an address the agent never reports, because PBS identity was limited to the configured name and host. The PBS poll now captures the hostname the node reports about itself (GET /nodes, the previously unused client GetNodeName) on models.PBSInstance.NodeName, and /api/connections includes it in the PBS connection's host aliases, so directPlatformHostAttachment merges the agent running on that machine into the PBS row the same way PVE composes from API-reported node identity. Node-name fetch failure stays partial data and never fails the poll. Reported by Johannes Strasser (rc.5).
5877 lines
208 KiB
Go
5877 lines
208 KiB
Go
package models
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"net"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/proxmoxidentity"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/diskinventory"
|
|
)
|
|
|
|
// State represents the current state of all monitored resources
|
|
type State struct {
|
|
mu sync.RWMutex
|
|
Nodes []Node `json:"nodes"`
|
|
VMs []VM `json:"vms"`
|
|
Containers []Container `json:"containers"`
|
|
DockerHosts []DockerHost `json:"dockerHosts"`
|
|
RemovedDockerHosts []RemovedDockerHost `json:"removedDockerHosts"`
|
|
KubernetesClusters []KubernetesCluster `json:"kubernetesClusters"`
|
|
RemovedKubernetesClusters []RemovedKubernetesCluster `json:"removedKubernetesClusters"`
|
|
Hosts []Host `json:"hosts"`
|
|
RemovedHostAgents []RemovedHostAgent `json:"removedHostAgents"`
|
|
Storage []Storage `json:"storage"`
|
|
CephClusters []CephCluster `json:"cephClusters"`
|
|
PhysicalDisks []PhysicalDisk `json:"physicalDisks"`
|
|
PBSInstances []PBSInstance `json:"pbs"`
|
|
PMGInstances []PMGInstance `json:"pmg"`
|
|
PBSBackups []PBSBackup `json:"pbsBackups"`
|
|
PMGBackups []PMGBackup `json:"pmgBackups"`
|
|
Backups Backups `json:"backups"`
|
|
ReplicationJobs []ReplicationJob `json:"replicationJobs"`
|
|
Metrics []Metric `json:"metrics"`
|
|
PVEBackups PVEBackups `json:"pveBackups"`
|
|
Performance Performance `json:"performance"`
|
|
ConnectionHealth map[string]bool `json:"connectionHealth"`
|
|
Stats Stats `json:"stats"`
|
|
ActiveAlerts []Alert `json:"activeAlerts"`
|
|
RecentlyResolved []ResolvedAlert `json:"recentlyResolved"`
|
|
LastUpdate time.Time `json:"lastUpdate"`
|
|
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"`
|
|
PVETagColors map[string]string `json:"pveTagColors,omitempty"`
|
|
PVETagStyles map[string]PVETagStyle `json:"pveTagStyles,omitempty"`
|
|
|
|
// pbsGuestConfirmations records, per PVE connection, the (storage, type,
|
|
// vmid, time) tuples that connection's own pbs-type storage listings
|
|
// reported. When direct PBS polling is authoritative the raw storage
|
|
// contents are not kept as StorageBackups, but which storage view listed
|
|
// a snapshot is corroborating evidence for attributing a PBS snapshot
|
|
// whose VMID exists on several clusters (#1639). A listing proves the
|
|
// connection can see the snapshot, not that it authored it, so the
|
|
// storage name is retained and views that overlap another connection's
|
|
// are discarded as decisive evidence. Internal evidence only: never
|
|
// serialized and never part of snapshots.
|
|
pbsGuestConfirmations map[string]map[PBSGuestConfirmation]struct{}
|
|
}
|
|
|
|
// PBSGuestConfirmation identifies one PBS snapshot as seen through a PVE
|
|
// connection's own pbs-type storage content listing. Storage is the PVE
|
|
// storage the listing came from, BackupType uses PBS nomenclature ("vm" or
|
|
// "ct") and Time is the snapshot's backup time in unix seconds, which
|
|
// matches PBSBackup.BackupTime exactly because both derive from the snapshot
|
|
// identifier.
|
|
type PBSGuestConfirmation struct {
|
|
Storage string
|
|
BackupType string
|
|
VMID int
|
|
Time int64
|
|
}
|
|
|
|
// PVETagStyle is the frontend-facing Proxmox tag style for one PVE instance.
|
|
type PVETagStyle struct {
|
|
Colors map[string]string `json:"colors"`
|
|
CaseSensitive bool `json:"caseSensitive"`
|
|
}
|
|
|
|
var (
|
|
// ErrHostAgentNotFound indicates a requested host agent ID does not exist in state.
|
|
ErrHostAgentNotFound = errors.New("host agent not found")
|
|
// ErrNodeNotFound indicates a requested node ID does not exist in state.
|
|
ErrNodeNotFound = errors.New("node not found")
|
|
)
|
|
|
|
// Alert represents an active alert (simplified for State)
|
|
type Alert struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Level string `json:"level"`
|
|
ResourceID string `json:"resourceId"`
|
|
ResourceName string `json:"resourceName"`
|
|
Node string `json:"node"`
|
|
NodeDisplayName string `json:"nodeDisplayName,omitempty"`
|
|
Instance string `json:"instance"`
|
|
Message string `json:"message"`
|
|
Value float64 `json:"value"`
|
|
Threshold float64 `json:"threshold"`
|
|
StartTime time.Time `json:"startTime"`
|
|
Acknowledged bool `json:"acknowledged"`
|
|
AckTime *time.Time `json:"ackTime,omitempty"`
|
|
AckUser string `json:"ackUser,omitempty"`
|
|
// Metadata carries alert-engine annotations (notably resourceType) so the
|
|
// frontend can classify an alert without re-deriving resource identity.
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
}
|
|
|
|
// ResolvedAlert represents a recently resolved alert
|
|
type ResolvedAlert struct {
|
|
Alert
|
|
ResolvedTime time.Time `json:"resolvedTime"`
|
|
}
|
|
|
|
// Node represents a Proxmox VE node
|
|
type Node struct {
|
|
ID string `json:"id"`
|
|
NodeIdentity string `json:"nodeIdentity,omitempty"`
|
|
Name string `json:"name"`
|
|
NativeNameAliases []string `json:"nativeNameAliases,omitempty"`
|
|
DisplayName string `json:"displayName,omitempty"`
|
|
Instance string `json:"instance"`
|
|
Host string `json:"host"` // Full host URL from config
|
|
GuestURL string `json:"guestURL"` // Optional guest-accessible URL (for navigation)
|
|
Status string `json:"status"`
|
|
Type string `json:"type"`
|
|
CPU float64 `json:"cpu"`
|
|
Memory Memory `json:"memory"`
|
|
Disk Disk `json:"disk"`
|
|
Uptime int64 `json:"uptime"`
|
|
LoadAverage []float64 `json:"loadAverage"`
|
|
KernelVersion string `json:"kernelVersion"`
|
|
PVEVersion string `json:"pveVersion"`
|
|
CPUInfo CPUInfo `json:"cpuInfo"`
|
|
Temperature *Temperature `json:"temperature,omitempty"` // CPU/NVMe temperatures
|
|
TemperatureMonitoringEnabled *bool `json:"temperatureMonitoringEnabled,omitempty"` // Per-node temperature monitoring override
|
|
LastSeen time.Time `json:"lastSeen"`
|
|
ConnectionHealth string `json:"connectionHealth"`
|
|
IsClusterMember bool `json:"isClusterMember"` // True if part of a cluster
|
|
ClusterName string `json:"clusterName"` // Name of cluster (empty if standalone)
|
|
ProviderScopedIdentity bool `json:"providerScopedIdentity,omitempty"`
|
|
// TLSFingerprint is the TOFU-captured TLS certificate fingerprint recorded
|
|
// in configuration for this node's API endpoint, when known. It is machine
|
|
// identity evidence for state aggregation only, never a polling or trust
|
|
// setting, and is not published to clients.
|
|
TLSFingerprint string `json:"-"`
|
|
|
|
// Package updates - polled less frequently (every 30 mins)
|
|
PendingUpdates int `json:"pendingUpdates"` // Number of pending apt updates
|
|
PendingUpdatesCheckedAt time.Time `json:"pendingUpdatesCheckedAt,omitempty"` // When updates were last checked
|
|
|
|
// Linking: When a host agent is running on this PVE node, link them together
|
|
LinkedAgentID string `json:"linkedAgentId,omitempty"` // ID of the host agent running on this node
|
|
}
|
|
|
|
func (n Node) NormalizeCollections() Node {
|
|
if n.LoadAverage == nil {
|
|
n.LoadAverage = []float64{}
|
|
}
|
|
if n.Temperature != nil {
|
|
temp := n.Temperature.NormalizeCollections()
|
|
n.Temperature = &temp
|
|
}
|
|
return n
|
|
}
|
|
|
|
// VM represents a virtual machine
|
|
type VM struct {
|
|
ID string `json:"id"`
|
|
VMID int `json:"vmid"`
|
|
Name string `json:"name"`
|
|
Node string `json:"node"`
|
|
Pool string `json:"pool,omitempty"`
|
|
Instance string `json:"instance"`
|
|
Status string `json:"status"`
|
|
Type string `json:"type"`
|
|
CPU float64 `json:"cpu"`
|
|
CPUs int `json:"cpus"`
|
|
Memory Memory `json:"memory"`
|
|
Disk Disk `json:"disk"`
|
|
Disks []Disk `json:"disks,omitempty"`
|
|
DiskStatusReason string `json:"diskStatusReason,omitempty"` // Why disk stats are unavailable
|
|
GuestAgentStatus string `json:"guestAgentStatus,omitempty"` // QEMU guest-agent runtime status
|
|
GuestAgentExpected bool `json:"guestAgentExpected,omitempty"`
|
|
IPAddresses []string `json:"ipAddresses,omitempty"`
|
|
OSName string `json:"osName,omitempty"`
|
|
OSVersion string `json:"osVersion,omitempty"`
|
|
AgentVersion string `json:"agentVersion,omitempty"`
|
|
NetworkInterfaces []GuestNetworkInterface `json:"networkInterfaces,omitempty"`
|
|
NetworkIn int64 `json:"networkIn"`
|
|
NetworkOut int64 `json:"networkOut"`
|
|
DiskRead int64 `json:"diskRead"`
|
|
DiskWrite int64 `json:"diskWrite"`
|
|
IORateValidity IORateValidity `json:"-"`
|
|
Uptime int64 `json:"uptime"`
|
|
Template bool `json:"template"`
|
|
OnBoot *bool `json:"onBoot,omitempty"`
|
|
LastBackup time.Time `json:"lastBackup,omitempty"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
Lock string `json:"lock,omitempty"`
|
|
LastSeen time.Time `json:"lastSeen"`
|
|
}
|
|
|
|
func (v VM) NormalizeCollections() VM {
|
|
if v.Disks == nil {
|
|
v.Disks = []Disk{}
|
|
}
|
|
if v.IPAddresses == nil {
|
|
v.IPAddresses = []string{}
|
|
}
|
|
if v.NetworkInterfaces == nil {
|
|
v.NetworkInterfaces = []GuestNetworkInterface{}
|
|
}
|
|
for i := range v.NetworkInterfaces {
|
|
v.NetworkInterfaces[i] = v.NetworkInterfaces[i].NormalizeCollections()
|
|
}
|
|
if v.Tags == nil {
|
|
v.Tags = []string{}
|
|
}
|
|
return v
|
|
}
|
|
|
|
// Container represents an LXC container
|
|
type Container struct {
|
|
ID string `json:"id"`
|
|
VMID int `json:"vmid"`
|
|
Name string `json:"name"`
|
|
Node string `json:"node"`
|
|
Pool string `json:"pool,omitempty"`
|
|
Instance string `json:"instance"`
|
|
Status string `json:"status"`
|
|
Type string `json:"type"`
|
|
CPU float64 `json:"cpu"`
|
|
CPUs int `json:"cpus"`
|
|
Memory Memory `json:"memory"`
|
|
Disk Disk `json:"disk"`
|
|
Disks []Disk `json:"disks,omitempty"`
|
|
NetworkIn int64 `json:"networkIn"`
|
|
NetworkOut int64 `json:"networkOut"`
|
|
DiskRead int64 `json:"diskRead"`
|
|
DiskWrite int64 `json:"diskWrite"`
|
|
IORateValidity IORateValidity `json:"-"`
|
|
Uptime int64 `json:"uptime"`
|
|
Template bool `json:"template"`
|
|
OnBoot *bool `json:"onBoot,omitempty"`
|
|
LastBackup time.Time `json:"lastBackup,omitempty"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
Lock string `json:"lock,omitempty"`
|
|
LastSeen time.Time `json:"lastSeen"`
|
|
IPAddresses []string `json:"ipAddresses,omitempty"`
|
|
NetworkInterfaces []GuestNetworkInterface `json:"networkInterfaces,omitempty"`
|
|
OSName string `json:"osName,omitempty"`
|
|
// OCI container support (Proxmox VE 9.1+)
|
|
IsOCI bool `json:"isOci,omitempty"` // True if this is an OCI container
|
|
OSTemplate string `json:"osTemplate,omitempty"` // Template or OCI image used (e.g., "docker:alpine:latest")
|
|
|
|
// Docker detection - automatically detected by checking for Docker socket inside the container
|
|
HasDocker bool `json:"hasDocker,omitempty"` // True if Docker is installed inside this LXC
|
|
DockerCheckedAt time.Time `json:"dockerCheckedAt,omitempty"` // When Docker presence was last checked
|
|
}
|
|
|
|
func (c Container) NormalizeCollections() Container {
|
|
if c.Disks == nil {
|
|
c.Disks = []Disk{}
|
|
}
|
|
if c.IPAddresses == nil {
|
|
c.IPAddresses = []string{}
|
|
}
|
|
if c.NetworkInterfaces == nil {
|
|
c.NetworkInterfaces = []GuestNetworkInterface{}
|
|
}
|
|
for i := range c.NetworkInterfaces {
|
|
c.NetworkInterfaces[i] = c.NetworkInterfaces[i].NormalizeCollections()
|
|
}
|
|
if c.Tags == nil {
|
|
c.Tags = []string{}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// Host represents a generic infrastructure host reporting via external agents.
|
|
type Host struct {
|
|
ID string `json:"id"`
|
|
Hostname string `json:"hostname"`
|
|
DisplayName string `json:"displayName,omitempty"`
|
|
Platform string `json:"platform,omitempty"`
|
|
OSName string `json:"osName,omitempty"`
|
|
OSVersion string `json:"osVersion,omitempty"`
|
|
KernelVersion string `json:"kernelVersion,omitempty"`
|
|
Architecture string `json:"architecture,omitempty"`
|
|
CPUCount int `json:"cpuCount,omitempty"`
|
|
CPUUsage float64 `json:"cpuUsage,omitempty"`
|
|
Memory Memory `json:"memory"`
|
|
LoadAverage []float64 `json:"loadAverage,omitempty"`
|
|
Disks []Disk `json:"disks,omitempty"`
|
|
ZFSPools []HostZFSPool `json:"zfsPools,omitempty"`
|
|
DiskIO []DiskIO `json:"diskIO,omitempty"`
|
|
NetworkInterfaces []HostNetworkInterface `json:"networkInterfaces,omitempty"`
|
|
Sensors HostSensorSummary `json:"sensors,omitempty"`
|
|
RAID []HostRAIDArray `json:"raid,omitempty"`
|
|
Unraid *HostUnraidStorage `json:"unraid,omitempty"`
|
|
Ceph *HostCephCluster `json:"ceph,omitempty"`
|
|
Libvirt *HostLibvirtInventory `json:"libvirt,omitempty"`
|
|
XCPNG *HostXCPNGInventory `json:"xcpng,omitempty"`
|
|
Status string `json:"status"`
|
|
UptimeSeconds int64 `json:"uptimeSeconds,omitempty"`
|
|
IntervalSeconds int `json:"intervalSeconds,omitempty"`
|
|
LastSeen time.Time `json:"lastSeen"`
|
|
AgentVersion string `json:"agentVersion,omitempty"`
|
|
MachineID string `json:"machineId,omitempty"`
|
|
CommandsEnabled bool `json:"commandsEnabled,omitempty"` // Whether AI command execution is enabled
|
|
OperationReceiptVersion int `json:"-"`
|
|
ReportIP string `json:"reportIp,omitempty"` // User-specified IP for multi-NIC systems
|
|
TokenID string `json:"tokenId,omitempty"`
|
|
TokenName string `json:"tokenName,omitempty"`
|
|
TokenHint string `json:"tokenHint,omitempty"`
|
|
TokenLastUsedAt *time.Time `json:"tokenLastUsedAt,omitempty"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
DiskExclude []string `json:"diskExclude,omitempty"` // Agent's --disk-exclude patterns
|
|
AppliedConfig *AgentConfigFingerprint `json:"appliedConfig,omitempty"`
|
|
AgentUpdate *AgentUpdateStatus `json:"agentUpdate,omitempty"`
|
|
AgentModules []AgentModuleStatus `json:"agentModules,omitempty"`
|
|
// IntegrationSource names the platform integration ("vmware", "truenas", ...)
|
|
// that supplies this host's telemetry when no Pulse Agent reports for it.
|
|
// Empty means Pulse-Agent-backed. Only unified-fabric snapshots populate it;
|
|
// hosts built from agent reports leave it empty by construction.
|
|
IntegrationSource string `json:"integrationSource,omitempty"`
|
|
PackageUpdates *HostPackageUpdateStatus `json:"packageUpdates,omitempty"`
|
|
StorageCleanup *HostStorageCleanupStatus `json:"storageCleanup,omitempty"`
|
|
IsLegacy bool `json:"isLegacy,omitempty"`
|
|
// IdentityConflict is set while multiple distinct machines appear to be
|
|
// reporting under this host's identity (e.g. template-cloned machines
|
|
// sharing the same /etc/machine-id). Nil when no conflict is active.
|
|
IdentityConflict *HostIdentityConflict `json:"identityConflict,omitempty"`
|
|
|
|
// Computed I/O rates (bytes/sec), populated from cumulative counters by rate tracker
|
|
NetInRate float64 `json:"netInRate,omitempty"`
|
|
NetOutRate float64 `json:"netOutRate,omitempty"`
|
|
DiskReadRate float64 `json:"diskReadRate,omitempty"`
|
|
DiskWriteRate float64 `json:"diskWriteRate,omitempty"`
|
|
|
|
// Linking: When this host agent is running on a known PVE node/VM/container
|
|
LinkedNodeID string `json:"linkedNodeId,omitempty"` // ID of the PVE node this agent is running on
|
|
LinkedVMID string `json:"linkedVmId,omitempty"` // ID of the VM this agent is running inside
|
|
LinkedContainerID string `json:"linkedContainerId,omitempty"` // ID of the container this agent is running inside
|
|
}
|
|
|
|
// HostPackageUpdateStatus is the server-side normalized package posture
|
|
// reported by a Unified Agent.
|
|
type HostPackageUpdateStatus struct {
|
|
Supported bool `json:"supported"`
|
|
Manager string `json:"manager,omitempty"`
|
|
InventoryHash string `json:"inventoryHash,omitempty"`
|
|
PendingCount int `json:"pendingCount"`
|
|
Packages []HostPackageUpdate `json:"packages,omitempty"`
|
|
CheckedAt time.Time `json:"checkedAt,omitempty"`
|
|
ObservedAt time.Time `json:"observedAt,omitempty"`
|
|
RebootRequired bool `json:"rebootRequired,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type HostPackageUpdate struct {
|
|
Name string `json:"name"`
|
|
InstalledVersion string `json:"installedVersion,omitempty"`
|
|
AvailableVersion string `json:"availableVersion,omitempty"`
|
|
}
|
|
|
|
type HostStorageCleanupStatus struct {
|
|
Supported bool `json:"supported"`
|
|
Provider string `json:"provider,omitempty"`
|
|
Fingerprint string `json:"fingerprint,omitempty"`
|
|
ReclaimableBytes int64 `json:"reclaimableBytes"`
|
|
CheckedAt time.Time `json:"checkedAt,omitempty"`
|
|
ObservedAt time.Time `json:"observedAt,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type AgentConfigFingerprint struct {
|
|
Version string `json:"version"`
|
|
Hash string `json:"hash"`
|
|
}
|
|
|
|
type AgentUpdateStatus struct {
|
|
State string `json:"state"`
|
|
AutoUpdate bool `json:"autoUpdate"`
|
|
UpdatedFrom string `json:"updatedFrom,omitempty"`
|
|
AvailableVersion string `json:"availableVersion,omitempty"`
|
|
LastCheckedAt *time.Time `json:"lastCheckedAt,omitempty"`
|
|
LastAttemptAt *time.Time `json:"lastAttemptAt,omitempty"`
|
|
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
|
|
LastError string `json:"lastError,omitempty"`
|
|
}
|
|
|
|
type AgentModuleStatus struct {
|
|
Name string `json:"name"`
|
|
Enabled bool `json:"enabled"`
|
|
State string `json:"state"`
|
|
LastError string `json:"lastError,omitempty"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
func (h Host) NormalizeCollections() Host {
|
|
if h.LoadAverage == nil {
|
|
h.LoadAverage = []float64{}
|
|
}
|
|
if h.Disks == nil {
|
|
h.Disks = []Disk{}
|
|
}
|
|
if h.ZFSPools == nil {
|
|
h.ZFSPools = []HostZFSPool{}
|
|
}
|
|
for i := range h.ZFSPools {
|
|
h.ZFSPools[i] = h.ZFSPools[i].NormalizeCollections()
|
|
}
|
|
if h.DiskIO == nil {
|
|
h.DiskIO = []DiskIO{}
|
|
}
|
|
if h.NetworkInterfaces == nil {
|
|
h.NetworkInterfaces = []HostNetworkInterface{}
|
|
}
|
|
for i := range h.NetworkInterfaces {
|
|
h.NetworkInterfaces[i] = h.NetworkInterfaces[i].NormalizeCollections()
|
|
}
|
|
h.Sensors = h.Sensors.NormalizeCollections()
|
|
if h.RAID == nil {
|
|
h.RAID = []HostRAIDArray{}
|
|
}
|
|
for i := range h.RAID {
|
|
h.RAID[i] = h.RAID[i].NormalizeCollections()
|
|
}
|
|
if h.Unraid != nil {
|
|
unraid := h.Unraid.NormalizeCollections()
|
|
h.Unraid = &unraid
|
|
}
|
|
if h.Ceph != nil {
|
|
ceph := h.Ceph.NormalizeCollections()
|
|
h.Ceph = &ceph
|
|
}
|
|
if h.Libvirt != nil {
|
|
libvirt := h.Libvirt.NormalizeCollections()
|
|
h.Libvirt = &libvirt
|
|
}
|
|
if h.XCPNG != nil {
|
|
xcpng := h.XCPNG.NormalizeCollections()
|
|
h.XCPNG = &xcpng
|
|
}
|
|
if h.Tags == nil {
|
|
h.Tags = []string{}
|
|
}
|
|
if h.DiskExclude == nil {
|
|
h.DiskExclude = []string{}
|
|
}
|
|
if h.IdentityConflict != nil {
|
|
conflict := h.IdentityConflict.NormalizeCollections()
|
|
h.IdentityConflict = &conflict
|
|
}
|
|
return h
|
|
}
|
|
|
|
// HostLibvirtInventory is the normalized server-side view of a local
|
|
// read-only libvirt inventory reported by the Unified Agent.
|
|
type HostLibvirtInventory struct {
|
|
Domains []HostLibvirtDomain `json:"domains"`
|
|
CollectedAt time.Time `json:"collectedAt"`
|
|
}
|
|
|
|
func (i HostLibvirtInventory) NormalizeCollections() HostLibvirtInventory {
|
|
if i.Domains == nil {
|
|
i.Domains = []HostLibvirtDomain{}
|
|
}
|
|
return i
|
|
}
|
|
|
|
// HostLibvirtDomain carries both cumulative source counters and the
|
|
// server-derived point-in-time rates used by the unified VM resource.
|
|
type HostLibvirtDomain struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
State string `json:"state"`
|
|
VCPUs int `json:"vcpus,omitempty"`
|
|
CPUTimeNanoseconds uint64 `json:"cpuTimeNanoseconds,omitempty"`
|
|
CPUUsagePercent float64 `json:"cpuUsagePercent,omitempty"`
|
|
CPUUsageValid bool `json:"-"`
|
|
MemoryCurrentBytes int64 `json:"memoryCurrentBytes,omitempty"`
|
|
MemoryMaximumBytes int64 `json:"memoryMaximumBytes,omitempty"`
|
|
NetworkRXBytes uint64 `json:"networkRxBytes,omitempty"`
|
|
NetworkTXBytes uint64 `json:"networkTxBytes,omitempty"`
|
|
DiskReadBytes uint64 `json:"diskReadBytes,omitempty"`
|
|
DiskWriteBytes uint64 `json:"diskWriteBytes,omitempty"`
|
|
NetworkInRate float64 `json:"networkInRate,omitempty"`
|
|
NetworkOutRate float64 `json:"networkOutRate,omitempty"`
|
|
DiskReadRate float64 `json:"diskReadRate,omitempty"`
|
|
DiskWriteRate float64 `json:"diskWriteRate,omitempty"`
|
|
IORatesValid bool `json:"-"`
|
|
}
|
|
|
|
// HostXCPNGInventory is the normalized server-side view of the XCP-ng pool
|
|
// visible to a Unified Agent running in an XCP-ng control domain.
|
|
type HostXCPNGInventory struct {
|
|
PoolUUID string `json:"poolUuid"`
|
|
PoolName string `json:"poolName,omitempty"`
|
|
MasterUUID string `json:"masterUuid,omitempty"`
|
|
LocalHostUUID string `json:"localHostUuid,omitempty"`
|
|
VMs []HostXCPNGVM `json:"vms"`
|
|
CollectedAt time.Time `json:"collectedAt"`
|
|
}
|
|
|
|
func (i HostXCPNGInventory) NormalizeCollections() HostXCPNGInventory {
|
|
if i.VMs == nil {
|
|
i.VMs = []HostXCPNGVM{}
|
|
}
|
|
return i
|
|
}
|
|
|
|
type HostXCPNGVM struct {
|
|
UUID string `json:"uuid"`
|
|
Name string `json:"name"`
|
|
PowerState string `json:"powerState"`
|
|
VCPUs int `json:"vcpus,omitempty"`
|
|
MemoryActual int64 `json:"memoryActualBytes,omitempty"`
|
|
MemoryStaticMax int64 `json:"memoryStaticMaxBytes,omitempty"`
|
|
ResidentHostUUID string `json:"residentHostUuid,omitempty"`
|
|
}
|
|
|
|
// HostIdentityConflict records evidence that reports from more than one
|
|
// physical machine are being folded into a single host agent record. The
|
|
// usual cause is template-deployed machines that still share the same
|
|
// /etc/machine-id, which the agent uses as its identity.
|
|
type HostIdentityConflict struct {
|
|
Hostnames []string `json:"hostnames"`
|
|
ReportIPs []string `json:"reportIps,omitempty"`
|
|
FirstSeen time.Time `json:"firstSeen"`
|
|
LastSeen time.Time `json:"lastSeen"`
|
|
}
|
|
|
|
func (c HostIdentityConflict) NormalizeCollections() HostIdentityConflict {
|
|
if c.Hostnames == nil {
|
|
c.Hostnames = []string{}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// HostNetworkInterface describes a host network adapter summary.
|
|
type HostNetworkInterface struct {
|
|
Name string `json:"name"`
|
|
MAC string `json:"mac,omitempty"`
|
|
Addresses []string `json:"addresses"`
|
|
RXBytes uint64 `json:"rxBytes,omitempty"`
|
|
TXBytes uint64 `json:"txBytes,omitempty"`
|
|
SpeedMbps *int64 `json:"speedMbps,omitempty"`
|
|
}
|
|
|
|
func (i HostNetworkInterface) NormalizeCollections() HostNetworkInterface {
|
|
if i.Addresses == nil {
|
|
i.Addresses = []string{}
|
|
}
|
|
return i
|
|
}
|
|
|
|
// HostSensorSummary captures optional per-host sensor readings.
|
|
type HostSensorSummary struct {
|
|
TemperatureCelsius map[string]float64 `json:"temperatureCelsius,omitempty"`
|
|
FanRPM map[string]float64 `json:"fanRpm,omitempty"`
|
|
PowerWatts map[string]float64 `json:"powerWatts,omitempty"`
|
|
Additional map[string]float64 `json:"additional,omitempty"`
|
|
Custom []HostCustomSensorMetric `json:"custom,omitempty"`
|
|
GPU []HostGPUSensor `json:"gpu,omitempty"`
|
|
ThermalState *HostThermalState `json:"thermalState,omitempty"`
|
|
SMART []HostDiskSMART `json:"smart,omitempty"` // S.M.A.R.T. disk data
|
|
}
|
|
|
|
type HostCustomSensorMetric struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Group string `json:"group,omitempty"`
|
|
Subgroup string `json:"subgroup,omitempty"`
|
|
Kind string `json:"kind,omitempty"`
|
|
Unit string `json:"unit,omitempty"`
|
|
Value *float64 `json:"value,omitempty"`
|
|
Status string `json:"status"`
|
|
ObservedAt time.Time `json:"observedAt"`
|
|
EventAt *time.Time `json:"eventAt,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
AlertOnError bool `json:"alertOnError,omitempty"`
|
|
Stale bool `json:"stale,omitempty"`
|
|
}
|
|
|
|
type HostGPUSensor struct {
|
|
ID string `json:"id,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
TemperatureCelsius *float64 `json:"temperatureCelsius,omitempty"`
|
|
UtilizationPercent *float64 `json:"utilizationPercent,omitempty"`
|
|
MemoryUsedBytes *int64 `json:"memoryUsedBytes,omitempty"`
|
|
MemoryTotalBytes *int64 `json:"memoryTotalBytes,omitempty"`
|
|
}
|
|
|
|
type HostThermalState struct {
|
|
Source string `json:"source,omitempty"`
|
|
Pressure string `json:"pressure,omitempty"`
|
|
ThermalWarningLevel *int `json:"thermalWarningLevel,omitempty"`
|
|
PerformanceWarningLevel *int `json:"performanceWarningLevel,omitempty"`
|
|
CPUPowerStatus *int `json:"cpuPowerStatus,omitempty"`
|
|
LimitsPercent map[string]int `json:"limitsPercent,omitempty"`
|
|
}
|
|
|
|
func (s HostSensorSummary) NormalizeCollections() HostSensorSummary {
|
|
if s.TemperatureCelsius == nil {
|
|
s.TemperatureCelsius = map[string]float64{}
|
|
}
|
|
if s.FanRPM == nil {
|
|
s.FanRPM = map[string]float64{}
|
|
}
|
|
if s.PowerWatts == nil {
|
|
s.PowerWatts = map[string]float64{}
|
|
}
|
|
if s.Additional == nil {
|
|
s.Additional = map[string]float64{}
|
|
}
|
|
if s.Custom == nil {
|
|
s.Custom = []HostCustomSensorMetric{}
|
|
}
|
|
if s.GPU == nil {
|
|
s.GPU = []HostGPUSensor{}
|
|
}
|
|
if s.SMART == nil {
|
|
s.SMART = []HostDiskSMART{}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// HostDiskSMART represents S.M.A.R.T. data for a disk from a host agent.
|
|
type HostDiskSMART struct {
|
|
Device string `json:"device"` // Block device name (e.g., sda, nvme0n1)
|
|
Model string `json:"model,omitempty"` // Disk model
|
|
Serial string `json:"serial,omitempty"` // Serial number
|
|
WWN string `json:"wwn,omitempty"` // World Wide Name
|
|
Type string `json:"type,omitempty"` // Transport type: sata, sas, nvme
|
|
Controller string `json:"controller,omitempty"` // Stable controller association when reported
|
|
Target string `json:"target,omitempty"` // Controller target/HCTL or smartctl member target
|
|
SizeBytes int64 `json:"sizeBytes,omitempty"` // Capacity in bytes (0 when unknown)
|
|
Temperature int `json:"temperature"` // Temperature in Celsius
|
|
Health string `json:"health,omitempty"` // PASSED, FAILED, UNKNOWN
|
|
Standby bool `json:"standby,omitempty"` // True if disk was in standby
|
|
Pool string `json:"pool,omitempty"` // ZFS pool this disk belongs to (empty if not a ZFS member)
|
|
IO *DiskIO `json:"io,omitempty"`
|
|
Collection *diskinventory.CollectionStatus `json:"collection,omitempty"`
|
|
Attributes *SMARTAttributes `json:"attributes,omitempty"`
|
|
}
|
|
|
|
// SMARTAttributes holds normalized SMART attributes for both SATA and NVMe disks.
|
|
type SMARTAttributes struct {
|
|
PowerOnHours *int64 `json:"powerOnHours,omitempty"`
|
|
PowerCycles *int64 `json:"powerCycles,omitempty"`
|
|
ReallocatedSectors *int64 `json:"reallocatedSectors,omitempty"`
|
|
PendingSectors *int64 `json:"pendingSectors,omitempty"`
|
|
OfflineUncorrectable *int64 `json:"offlineUncorrectable,omitempty"`
|
|
UDMACRCErrors *int64 `json:"udmaCrcErrors,omitempty"`
|
|
PercentageUsed *int `json:"percentageUsed,omitempty"`
|
|
AvailableSpare *int `json:"availableSpare,omitempty"`
|
|
MediaErrors *int64 `json:"mediaErrors,omitempty"`
|
|
UnsafeShutdowns *int64 `json:"unsafeShutdowns,omitempty"`
|
|
}
|
|
|
|
// HostRAIDArray represents an mdadm RAID array on a host.
|
|
type HostRAIDArray struct {
|
|
Device string `json:"device"`
|
|
Name string `json:"name,omitempty"`
|
|
Level string `json:"level"`
|
|
State string `json:"state"`
|
|
TotalDevices int `json:"totalDevices"`
|
|
ActiveDevices int `json:"activeDevices"`
|
|
WorkingDevices int `json:"workingDevices"`
|
|
FailedDevices int `json:"failedDevices"`
|
|
SpareDevices int `json:"spareDevices"`
|
|
UUID string `json:"uuid,omitempty"`
|
|
Devices []HostRAIDDevice `json:"devices"`
|
|
RebuildPercent float64 `json:"rebuildPercent"`
|
|
RebuildSpeed string `json:"rebuildSpeed,omitempty"`
|
|
// Operation is the in-progress sync action from /proc/mdstat
|
|
// ("recovery", "resync", "check", or "reshape"); empty when idle.
|
|
Operation string `json:"operation,omitempty"`
|
|
}
|
|
|
|
func (a HostRAIDArray) NormalizeCollections() HostRAIDArray {
|
|
if a.Devices == nil {
|
|
a.Devices = []HostRAIDDevice{}
|
|
}
|
|
return a
|
|
}
|
|
|
|
// HostRAIDDevice represents a device in a RAID array.
|
|
type HostRAIDDevice struct {
|
|
Device string `json:"device"`
|
|
State string `json:"state"`
|
|
Slot int `json:"slot"`
|
|
}
|
|
|
|
// HostUnraidStorage represents best-effort Unraid array topology from the agent.
|
|
type HostUnraidStorage struct {
|
|
ArrayStarted bool `json:"arrayStarted"`
|
|
ArrayState string `json:"arrayState,omitempty"`
|
|
SyncAction string `json:"syncAction,omitempty"`
|
|
SyncProgress float64 `json:"syncProgress,omitempty"`
|
|
SyncErrors int64 `json:"syncErrors,omitempty"`
|
|
NumProtected int `json:"numProtected,omitempty"`
|
|
NumDisabled int `json:"numDisabled,omitempty"`
|
|
NumInvalid int `json:"numInvalid,omitempty"`
|
|
NumMissing int `json:"numMissing,omitempty"`
|
|
Disks []HostUnraidDisk `json:"disks,omitempty"`
|
|
}
|
|
|
|
func (s HostUnraidStorage) NormalizeCollections() HostUnraidStorage {
|
|
if s.Disks == nil {
|
|
s.Disks = []HostUnraidDisk{}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// HostUnraidDisk represents a disk's role and state inside an Unraid array.
|
|
type HostUnraidDisk struct {
|
|
Name string `json:"name"`
|
|
Device string `json:"device,omitempty"`
|
|
Role string `json:"role,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
RawStatus string `json:"rawStatus,omitempty"`
|
|
Model string `json:"model,omitempty"`
|
|
Serial string `json:"serial,omitempty"`
|
|
Filesystem string `json:"filesystem,omitempty"`
|
|
Transport string `json:"transport,omitempty"`
|
|
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
|
UsedBytes int64 `json:"usedBytes,omitempty"`
|
|
FreeBytes int64 `json:"freeBytes,omitempty"`
|
|
Temperature int `json:"temperature,omitempty"`
|
|
SpunDown bool `json:"spunDown,omitempty"`
|
|
ReadCount int64 `json:"readCount,omitempty"`
|
|
WriteCount int64 `json:"writeCount,omitempty"`
|
|
ErrorCount int64 `json:"errorCount,omitempty"`
|
|
Slot int `json:"slot,omitempty"`
|
|
}
|
|
|
|
// HostCephCluster represents Ceph cluster status collected directly by the host agent.
|
|
// This is separate from CephCluster which comes from the Proxmox API.
|
|
type HostCephCluster struct {
|
|
FSID string `json:"fsid"`
|
|
Health HostCephHealth `json:"health"`
|
|
MonMap HostCephMonitorMap `json:"monMap,omitempty"`
|
|
MgrMap HostCephManagerMap `json:"mgrMap,omitempty"`
|
|
OSDMap HostCephOSDMap `json:"osdMap"`
|
|
PGMap HostCephPGMap `json:"pgMap"`
|
|
Pools []HostCephPool `json:"pools,omitempty"`
|
|
Services []HostCephService `json:"services,omitempty"`
|
|
CollectedAt time.Time `json:"collectedAt"`
|
|
}
|
|
|
|
func (c HostCephCluster) NormalizeCollections() HostCephCluster {
|
|
c.Health = c.Health.NormalizeCollections()
|
|
c.MonMap = c.MonMap.NormalizeCollections()
|
|
if c.Pools == nil {
|
|
c.Pools = []HostCephPool{}
|
|
}
|
|
if c.Services == nil {
|
|
c.Services = []HostCephService{}
|
|
}
|
|
for i := range c.Services {
|
|
c.Services[i] = c.Services[i].NormalizeCollections()
|
|
}
|
|
return c
|
|
}
|
|
|
|
// HostCephHealth represents Ceph cluster health status.
|
|
type HostCephHealth struct {
|
|
Status string `json:"status"` // HEALTH_OK, HEALTH_WARN, HEALTH_ERR
|
|
Checks map[string]HostCephCheck `json:"checks,omitempty"`
|
|
Summary []HostCephHealthSummary `json:"summary,omitempty"`
|
|
}
|
|
|
|
func (h HostCephHealth) NormalizeCollections() HostCephHealth {
|
|
if h.Checks == nil {
|
|
h.Checks = map[string]HostCephCheck{}
|
|
}
|
|
for key, check := range h.Checks {
|
|
h.Checks[key] = check.NormalizeCollections()
|
|
}
|
|
if h.Summary == nil {
|
|
h.Summary = []HostCephHealthSummary{}
|
|
}
|
|
return h
|
|
}
|
|
|
|
// HostCephCheck represents a health check detail.
|
|
type HostCephCheck struct {
|
|
Severity string `json:"severity"`
|
|
Message string `json:"message,omitempty"`
|
|
Detail []string `json:"detail,omitempty"`
|
|
}
|
|
|
|
func (c HostCephCheck) NormalizeCollections() HostCephCheck {
|
|
if c.Detail == nil {
|
|
c.Detail = []string{}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// HostCephHealthSummary represents a health summary message.
|
|
type HostCephHealthSummary struct {
|
|
Severity string `json:"severity"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// HostCephMonitorMap represents Ceph monitor information.
|
|
type HostCephMonitorMap struct {
|
|
Epoch int `json:"epoch"`
|
|
NumMons int `json:"numMons"`
|
|
Monitors []HostCephMonitor `json:"monitors,omitempty"`
|
|
}
|
|
|
|
func (m HostCephMonitorMap) NormalizeCollections() HostCephMonitorMap {
|
|
if m.Monitors == nil {
|
|
m.Monitors = []HostCephMonitor{}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// HostCephMonitor represents a single Ceph monitor.
|
|
type HostCephMonitor struct {
|
|
Name string `json:"name"`
|
|
Rank int `json:"rank"`
|
|
Addr string `json:"addr,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
}
|
|
|
|
// HostCephManagerMap represents Ceph manager information.
|
|
type HostCephManagerMap struct {
|
|
Available bool `json:"available"`
|
|
NumMgrs int `json:"numMgrs"`
|
|
ActiveMgr string `json:"activeMgr,omitempty"`
|
|
Standbys int `json:"standbys"`
|
|
}
|
|
|
|
// HostCephOSDMap represents OSD status summary.
|
|
type HostCephOSDMap struct {
|
|
Epoch int `json:"epoch"`
|
|
NumOSDs int `json:"numOsds"`
|
|
NumUp int `json:"numUp"`
|
|
NumIn int `json:"numIn"`
|
|
NumDown int `json:"numDown,omitempty"`
|
|
NumOut int `json:"numOut,omitempty"`
|
|
}
|
|
|
|
// HostCephPGMap represents placement group statistics.
|
|
type HostCephPGMap struct {
|
|
NumPGs int `json:"numPgs"`
|
|
BytesTotal uint64 `json:"bytesTotal"`
|
|
BytesUsed uint64 `json:"bytesUsed"`
|
|
BytesAvailable uint64 `json:"bytesAvailable"`
|
|
DataBytes uint64 `json:"dataBytes,omitempty"`
|
|
UsagePercent float64 `json:"usagePercent"`
|
|
DegradedRatio float64 `json:"degradedRatio,omitempty"`
|
|
MisplacedRatio float64 `json:"misplacedRatio,omitempty"`
|
|
ReadBytesPerSec uint64 `json:"readBytesPerSec,omitempty"`
|
|
WriteBytesPerSec uint64 `json:"writeBytesPerSec,omitempty"`
|
|
ReadOpsPerSec uint64 `json:"readOpsPerSec,omitempty"`
|
|
WriteOpsPerSec uint64 `json:"writeOpsPerSec,omitempty"`
|
|
}
|
|
|
|
// HostCephPool represents a Ceph pool.
|
|
type HostCephPool struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
BytesUsed uint64 `json:"bytesUsed"`
|
|
BytesAvailable uint64 `json:"bytesAvailable"`
|
|
Objects uint64 `json:"objects"`
|
|
PercentUsed float64 `json:"percentUsed"`
|
|
}
|
|
|
|
// HostCephService represents a Ceph service summary.
|
|
type HostCephService struct {
|
|
Type string `json:"type"` // mon, mgr, osd, mds, rgw
|
|
Running int `json:"running"`
|
|
Total int `json:"total"`
|
|
Daemons []string `json:"daemons,omitempty"`
|
|
}
|
|
|
|
func (s HostCephService) NormalizeCollections() HostCephService {
|
|
if s.Daemons == nil {
|
|
s.Daemons = []string{}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// DiskIO represents I/O statistics for a block device.
|
|
// Counters are cumulative since boot.
|
|
type DiskIO struct {
|
|
Device string `json:"device"`
|
|
ReadBytes uint64 `json:"readBytes,omitempty"`
|
|
WriteBytes uint64 `json:"writeBytes,omitempty"`
|
|
ReadOps uint64 `json:"readOps,omitempty"`
|
|
WriteOps uint64 `json:"writeOps,omitempty"`
|
|
ReadTime uint64 `json:"readTimeMs,omitempty"`
|
|
WriteTime uint64 `json:"writeTimeMs,omitempty"`
|
|
IOTime uint64 `json:"ioTimeMs,omitempty"`
|
|
}
|
|
|
|
// DockerHost represents a Docker host reporting metrics via the external agent.
|
|
type DockerHost struct {
|
|
ID string `json:"id"`
|
|
AgentID string `json:"agentId"`
|
|
Hostname string `json:"hostname"`
|
|
DisplayName string `json:"displayName"`
|
|
CustomDisplayName string `json:"customDisplayName,omitempty"` // User-defined custom name
|
|
MachineID string `json:"machineId,omitempty"`
|
|
OS string `json:"os,omitempty"`
|
|
KernelVersion string `json:"kernelVersion,omitempty"`
|
|
Architecture string `json:"architecture,omitempty"`
|
|
Runtime string `json:"runtime,omitempty"`
|
|
RuntimeVersion string `json:"runtimeVersion,omitempty"`
|
|
DockerVersion string `json:"dockerVersion,omitempty"`
|
|
CPUs int `json:"cpus"`
|
|
TotalMemoryBytes int64 `json:"totalMemoryBytes"`
|
|
UptimeSeconds int64 `json:"uptimeSeconds"`
|
|
CPUUsage float64 `json:"cpuUsagePercent"`
|
|
LoadAverage []float64 `json:"loadAverage,omitempty"`
|
|
Memory Memory `json:"memory"`
|
|
Disks []Disk `json:"disks,omitempty"`
|
|
NetworkInterfaces []HostNetworkInterface `json:"networkInterfaces,omitempty"`
|
|
Status string `json:"status"`
|
|
LastSeen time.Time `json:"lastSeen"`
|
|
IntervalSeconds int `json:"intervalSeconds"`
|
|
AgentVersion string `json:"agentVersion,omitempty"`
|
|
Containers []DockerContainer `json:"containers"`
|
|
Images []DockerImage `json:"images,omitempty"`
|
|
Volumes []DockerVolume `json:"volumes,omitempty"`
|
|
Networks []DockerNetwork `json:"networks,omitempty"`
|
|
Services []DockerService `json:"services,omitempty"`
|
|
Tasks []DockerTask `json:"tasks,omitempty"`
|
|
Nodes []DockerNode `json:"nodes,omitempty"`
|
|
Secrets []DockerSecret `json:"secrets,omitempty"`
|
|
Configs []DockerConfig `json:"configs,omitempty"`
|
|
StorageUsage *DockerStorageUsage `json:"storageUsage,omitempty"`
|
|
Swarm *DockerSwarmInfo `json:"swarm,omitempty"`
|
|
Security *DockerHostSecurity `json:"security,omitempty"`
|
|
Temperature *float64 `json:"temperature,omitempty"` // Optional host temperature in Celsius
|
|
TokenID string `json:"tokenId,omitempty"`
|
|
TokenName string `json:"tokenName,omitempty"`
|
|
TokenHint string `json:"tokenHint,omitempty"`
|
|
TokenLastUsedAt *time.Time `json:"tokenLastUsedAt,omitempty"`
|
|
Hidden bool `json:"hidden"`
|
|
PendingUninstall bool `json:"pendingUninstall"`
|
|
Command *DockerHostCommandStatus `json:"command,omitempty"`
|
|
IsLegacy bool `json:"isLegacy,omitempty"`
|
|
// IdentityConflict is set while multiple distinct machines appear to be
|
|
// reporting under this host's identity (e.g. cloned VMs sharing the same
|
|
// /etc/machine-id). Nil when no conflict is active.
|
|
IdentityConflict *DockerHostIdentityConflict `json:"identityConflict,omitempty"`
|
|
|
|
// Computed I/O rates (bytes/sec), populated by monitoring pipeline when available.
|
|
NetInRate float64 `json:"netInRate,omitempty"`
|
|
NetOutRate float64 `json:"netOutRate,omitempty"`
|
|
DiskReadRate float64 `json:"diskReadRate,omitempty"`
|
|
DiskWriteRate float64 `json:"diskWriteRate,omitempty"`
|
|
}
|
|
|
|
func (h DockerHost) NormalizeCollections() DockerHost {
|
|
if h.LoadAverage == nil {
|
|
h.LoadAverage = []float64{}
|
|
}
|
|
if h.Disks == nil {
|
|
h.Disks = []Disk{}
|
|
}
|
|
if h.NetworkInterfaces == nil {
|
|
h.NetworkInterfaces = []HostNetworkInterface{}
|
|
}
|
|
for i := range h.NetworkInterfaces {
|
|
h.NetworkInterfaces[i] = h.NetworkInterfaces[i].NormalizeCollections()
|
|
}
|
|
if h.Containers == nil {
|
|
h.Containers = []DockerContainer{}
|
|
}
|
|
for i := range h.Containers {
|
|
h.Containers[i] = h.Containers[i].NormalizeCollections()
|
|
}
|
|
if h.Images == nil {
|
|
h.Images = []DockerImage{}
|
|
}
|
|
for i := range h.Images {
|
|
h.Images[i] = h.Images[i].NormalizeCollections()
|
|
}
|
|
if h.Volumes == nil {
|
|
h.Volumes = []DockerVolume{}
|
|
}
|
|
for i := range h.Volumes {
|
|
h.Volumes[i] = h.Volumes[i].NormalizeCollections()
|
|
}
|
|
if h.Networks == nil {
|
|
h.Networks = []DockerNetwork{}
|
|
}
|
|
for i := range h.Networks {
|
|
h.Networks[i] = h.Networks[i].NormalizeCollections()
|
|
}
|
|
if h.Services == nil {
|
|
h.Services = []DockerService{}
|
|
}
|
|
for i := range h.Services {
|
|
h.Services[i] = h.Services[i].NormalizeCollections()
|
|
}
|
|
if h.Tasks == nil {
|
|
h.Tasks = []DockerTask{}
|
|
}
|
|
if h.Nodes == nil {
|
|
h.Nodes = []DockerNode{}
|
|
}
|
|
for i := range h.Nodes {
|
|
h.Nodes[i] = h.Nodes[i].NormalizeCollections()
|
|
}
|
|
if h.Secrets == nil {
|
|
h.Secrets = []DockerSecret{}
|
|
}
|
|
for i := range h.Secrets {
|
|
h.Secrets[i] = h.Secrets[i].NormalizeCollections()
|
|
}
|
|
if h.Configs == nil {
|
|
h.Configs = []DockerConfig{}
|
|
}
|
|
for i := range h.Configs {
|
|
h.Configs[i] = h.Configs[i].NormalizeCollections()
|
|
}
|
|
if h.Security != nil {
|
|
security := h.Security.NormalizeCollections()
|
|
h.Security = &security
|
|
}
|
|
if h.IdentityConflict != nil {
|
|
conflict := h.IdentityConflict.NormalizeCollections()
|
|
h.IdentityConflict = &conflict
|
|
}
|
|
return h
|
|
}
|
|
|
|
// DockerHostIdentityConflict records evidence that reports from more than one
|
|
// physical machine are being folded into a single Docker host record. The
|
|
// usual cause is cloned VMs that still share the same /etc/machine-id, which
|
|
// the agent uses as its identity in unified mode.
|
|
type DockerHostIdentityConflict struct {
|
|
Hostnames []string `json:"hostnames"`
|
|
MachineIDs []string `json:"machineIds,omitempty"`
|
|
FirstSeen time.Time `json:"firstSeen"`
|
|
LastSeen time.Time `json:"lastSeen"`
|
|
}
|
|
|
|
func (c DockerHostIdentityConflict) NormalizeCollections() DockerHostIdentityConflict {
|
|
if c.Hostnames == nil {
|
|
c.Hostnames = []string{}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// DockerHostSecurity describes security-relevant runtime posture for a Docker host.
|
|
type DockerHostSecurity struct {
|
|
AuthorizationPlugins []string `json:"authorizationPlugins,omitempty"`
|
|
MutatingCommandsBlocked bool `json:"mutatingCommandsBlocked,omitempty"`
|
|
MutatingCommandsBlockedReason string `json:"mutatingCommandsBlockedReason,omitempty"`
|
|
}
|
|
|
|
func (s DockerHostSecurity) NormalizeCollections() DockerHostSecurity {
|
|
if s.AuthorizationPlugins == nil {
|
|
s.AuthorizationPlugins = []string{}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// RemovedDockerHost tracks a docker host that was deliberately removed and blocked from reporting.
|
|
type RemovedDockerHost struct {
|
|
ID string `json:"id"`
|
|
Hostname string `json:"hostname,omitempty"`
|
|
DisplayName string `json:"displayName,omitempty"`
|
|
Platform string `json:"platform,omitempty"` // last-known reported platform, retained for host-side cleanup guidance
|
|
RemovedAt time.Time `json:"removedAt"`
|
|
}
|
|
|
|
// RemovedHostAgent tracks a host agent that was deliberately removed and blocked from reporting.
|
|
type RemovedHostAgent struct {
|
|
ID string `json:"id"`
|
|
ReportHostID string `json:"reportHostId,omitempty"`
|
|
AgentReportedID string `json:"agentReportedId,omitempty"`
|
|
Hostname string `json:"hostname,omitempty"`
|
|
DisplayName string `json:"displayName,omitempty"`
|
|
Platform string `json:"platform,omitempty"` // last-known reported platform, retained for host-side cleanup guidance
|
|
MachineID string `json:"machineId,omitempty"`
|
|
TokenID string `json:"tokenId,omitempty"`
|
|
LinkedVMID string `json:"linkedVmId,omitempty"`
|
|
LinkedContainerID string `json:"linkedContainerId,omitempty"`
|
|
RemovedAt time.Time `json:"removedAt"`
|
|
}
|
|
|
|
// DockerContainer represents the state of a Docker container on a monitored host.
|
|
type DockerContainer struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Image string `json:"image"`
|
|
ImageDigest string `json:"imageDigest,omitempty"` // Current image digest (sha256:...)
|
|
State string `json:"state"`
|
|
Status string `json:"status"`
|
|
Health string `json:"health,omitempty"`
|
|
CPUPercent float64 `json:"cpuPercent"`
|
|
CPUCapacityPercent float64 `json:"cpuCapacityPercent,omitempty"`
|
|
MemoryUsage int64 `json:"memoryUsageBytes"`
|
|
MemoryLimit int64 `json:"memoryLimitBytes"`
|
|
MemoryPercent float64 `json:"memoryPercent"`
|
|
UptimeSeconds int64 `json:"uptimeSeconds"`
|
|
RestartCount int `json:"restartCount"`
|
|
ExitCode int `json:"exitCode"`
|
|
OOMKilled *bool `json:"oomKilled,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
StartedAt *time.Time `json:"startedAt,omitempty"`
|
|
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
|
Ports []DockerContainerPort `json:"ports,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
Networks []DockerContainerNetworkLink `json:"networks,omitempty"`
|
|
NetworkRXBytes uint64 `json:"networkRxBytes,omitempty"`
|
|
NetworkTXBytes uint64 `json:"networkTxBytes,omitempty"`
|
|
NetInRate float64 `json:"netInRate,omitempty"`
|
|
NetOutRate float64 `json:"netOutRate,omitempty"`
|
|
WritableLayerBytes int64 `json:"writableLayerBytes,omitempty"`
|
|
RootFilesystemBytes int64 `json:"rootFilesystemBytes,omitempty"`
|
|
BlockIO *DockerContainerBlockIO `json:"blockIo,omitempty"`
|
|
Mounts []DockerContainerMount `json:"mounts,omitempty"`
|
|
Podman *DockerPodmanContainer `json:"podman,omitempty"`
|
|
UpdateStatus *DockerContainerUpdateStatus `json:"updateStatus,omitempty"` // Image update detection status
|
|
}
|
|
|
|
func (c DockerContainer) NormalizeCollections() DockerContainer {
|
|
if c.Ports == nil {
|
|
c.Ports = []DockerContainerPort{}
|
|
}
|
|
if c.Labels == nil {
|
|
c.Labels = map[string]string{}
|
|
}
|
|
if c.Networks == nil {
|
|
c.Networks = []DockerContainerNetworkLink{}
|
|
}
|
|
if c.Mounts == nil {
|
|
c.Mounts = []DockerContainerMount{}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// DockerImage represents a local image on a monitored Docker-compatible host.
|
|
type DockerImage struct {
|
|
ID string `json:"id"`
|
|
RepoTags []string `json:"repoTags,omitempty"`
|
|
RepoDigests []string `json:"repoDigests,omitempty"`
|
|
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
|
SharedSizeBytes int64 `json:"sharedSizeBytes,omitempty"`
|
|
Containers int64 `json:"containers,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (i DockerImage) NormalizeCollections() DockerImage {
|
|
if i.RepoTags == nil {
|
|
i.RepoTags = []string{}
|
|
}
|
|
if i.RepoDigests == nil {
|
|
i.RepoDigests = []string{}
|
|
}
|
|
if i.Labels == nil {
|
|
i.Labels = map[string]string{}
|
|
}
|
|
return i
|
|
}
|
|
|
|
// DockerVolume represents a local volume on a monitored Docker-compatible host.
|
|
type DockerVolume struct {
|
|
Name string `json:"name"`
|
|
Driver string `json:"driver,omitempty"`
|
|
Mountpoint string `json:"mountpoint,omitempty"`
|
|
Scope string `json:"scope,omitempty"`
|
|
CreatedAt string `json:"createdAt,omitempty"`
|
|
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
|
RefCount int64 `json:"refCount,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
Options map[string]string `json:"options,omitempty"`
|
|
}
|
|
|
|
func (v DockerVolume) NormalizeCollections() DockerVolume {
|
|
if v.Labels == nil {
|
|
v.Labels = map[string]string{}
|
|
}
|
|
if v.Options == nil {
|
|
v.Options = map[string]string{}
|
|
}
|
|
return v
|
|
}
|
|
|
|
// DockerNetwork represents a local network on a monitored Docker-compatible host.
|
|
type DockerNetwork struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Driver string `json:"driver,omitempty"`
|
|
Scope string `json:"scope,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
EnableIPv4 bool `json:"enableIpv4,omitempty"`
|
|
EnableIPv6 bool `json:"enableIpv6,omitempty"`
|
|
Internal bool `json:"internal,omitempty"`
|
|
Attachable bool `json:"attachable,omitempty"`
|
|
Ingress bool `json:"ingress,omitempty"`
|
|
ConfigOnly bool `json:"configOnly,omitempty"`
|
|
Subnets []DockerNetworkSubnet `json:"subnets,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
Options map[string]string `json:"options,omitempty"`
|
|
}
|
|
|
|
func (n DockerNetwork) NormalizeCollections() DockerNetwork {
|
|
if n.Subnets == nil {
|
|
n.Subnets = []DockerNetworkSubnet{}
|
|
}
|
|
if n.Labels == nil {
|
|
n.Labels = map[string]string{}
|
|
}
|
|
if n.Options == nil {
|
|
n.Options = map[string]string{}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// DockerNetworkSubnet describes one Docker network IPAM subnet.
|
|
type DockerNetworkSubnet struct {
|
|
Subnet string `json:"subnet,omitempty"`
|
|
Gateway string `json:"gateway,omitempty"`
|
|
}
|
|
|
|
// DockerStorageUsage summarises Docker-compatible /system/df usage.
|
|
type DockerStorageUsage struct {
|
|
Images DockerStorageUsageBucket `json:"images,omitempty"`
|
|
Containers DockerStorageUsageBucket `json:"containers,omitempty"`
|
|
Volumes DockerStorageUsageBucket `json:"volumes,omitempty"`
|
|
BuildCache DockerStorageUsageBucket `json:"buildCache,omitempty"`
|
|
}
|
|
|
|
// DockerStorageUsageBucket captures one /system/df resource bucket.
|
|
type DockerStorageUsageBucket struct {
|
|
TotalCount int64 `json:"totalCount,omitempty"`
|
|
ActiveCount int64 `json:"activeCount,omitempty"`
|
|
TotalSizeBytes int64 `json:"totalSizeBytes,omitempty"`
|
|
ReclaimableBytes int64 `json:"reclaimableBytes,omitempty"`
|
|
}
|
|
|
|
// DockerContainerUpdateStatus tracks the image update status for a container.
|
|
type DockerContainerUpdateStatus struct {
|
|
UpdateAvailable bool `json:"updateAvailable"`
|
|
CurrentDigest string `json:"currentDigest,omitempty"`
|
|
LatestDigest string `json:"latestDigest,omitempty"`
|
|
LastChecked time.Time `json:"lastChecked"`
|
|
Error string `json:"error,omitempty"` // e.g., "rate limited", "auth required"
|
|
}
|
|
|
|
// DockerPodmanContainer captures Podman-specific annotations for a container.
|
|
type DockerPodmanContainer struct {
|
|
PodName string `json:"podName,omitempty"`
|
|
PodID string `json:"podId,omitempty"`
|
|
Infra bool `json:"infra,omitempty"`
|
|
ComposeProject string `json:"composeProject,omitempty"`
|
|
ComposeService string `json:"composeService,omitempty"`
|
|
ComposeWorkdir string `json:"composeWorkdir,omitempty"`
|
|
ComposeConfigHash string `json:"composeConfigHash,omitempty"`
|
|
AutoUpdatePolicy string `json:"autoUpdatePolicy,omitempty"`
|
|
AutoUpdateRestart string `json:"autoUpdateRestart,omitempty"`
|
|
UserNamespace string `json:"userNamespace,omitempty"`
|
|
}
|
|
|
|
// DockerContainerPort describes an exposed container port mapping.
|
|
type DockerContainerPort struct {
|
|
PrivatePort int `json:"privatePort"`
|
|
PublicPort int `json:"publicPort,omitempty"`
|
|
Protocol string `json:"protocol"`
|
|
IP string `json:"ip,omitempty"`
|
|
}
|
|
|
|
// DockerContainerNetworkLink summarises container network addresses per network.
|
|
type DockerContainerNetworkLink struct {
|
|
Name string `json:"name"`
|
|
IPv4 string `json:"ipv4,omitempty"`
|
|
IPv6 string `json:"ipv6,omitempty"`
|
|
}
|
|
|
|
// DockerContainerBlockIO captures aggregate block IO usage for a container.
|
|
type DockerContainerBlockIO struct {
|
|
ReadBytes uint64 `json:"readBytes,omitempty"`
|
|
WriteBytes uint64 `json:"writeBytes,omitempty"`
|
|
ReadRateBytesPerSecond *float64 `json:"readRateBytesPerSecond,omitempty"`
|
|
WriteRateBytesPerSecond *float64 `json:"writeRateBytesPerSecond,omitempty"`
|
|
}
|
|
|
|
// DockerContainerMount describes a mount exposed to a container.
|
|
type DockerContainerMount struct {
|
|
Type string `json:"type,omitempty"`
|
|
Source string `json:"source,omitempty"`
|
|
Destination string `json:"destination,omitempty"`
|
|
Mode string `json:"mode,omitempty"`
|
|
RW bool `json:"rw"`
|
|
Propagation string `json:"propagation,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Driver string `json:"driver,omitempty"`
|
|
}
|
|
|
|
// KubernetesCluster represents a Kubernetes cluster reporting telemetry via the agent.
|
|
type KubernetesCluster struct {
|
|
ID string `json:"id"`
|
|
AgentID string `json:"agentId"`
|
|
Name string `json:"name,omitempty"`
|
|
DisplayName string `json:"displayName,omitempty"`
|
|
CustomDisplayName string `json:"customDisplayName,omitempty"`
|
|
Server string `json:"server,omitempty"`
|
|
Context string `json:"context,omitempty"`
|
|
Version string `json:"version,omitempty"`
|
|
Status string `json:"status"`
|
|
LastSeen time.Time `json:"lastSeen"`
|
|
IntervalSeconds int `json:"intervalSeconds"`
|
|
AgentVersion string `json:"agentVersion,omitempty"`
|
|
|
|
Nodes []KubernetesNode `json:"nodes,omitempty"`
|
|
Namespaces []KubernetesNamespace `json:"namespaces,omitempty"`
|
|
Pods []KubernetesPod `json:"pods,omitempty"`
|
|
Deployments []KubernetesDeployment `json:"deployments,omitempty"`
|
|
ReplicaSets []KubernetesReplicaSet `json:"replicaSets,omitempty"`
|
|
StatefulSets []KubernetesStatefulSet `json:"statefulSets,omitempty"`
|
|
DaemonSets []KubernetesDaemonSet `json:"daemonSets,omitempty"`
|
|
Services []KubernetesService `json:"services,omitempty"`
|
|
Jobs []KubernetesJob `json:"jobs,omitempty"`
|
|
CronJobs []KubernetesCronJob `json:"cronJobs,omitempty"`
|
|
Ingresses []KubernetesIngress `json:"ingresses,omitempty"`
|
|
EndpointSlices []KubernetesEndpointSlice `json:"endpointSlices,omitempty"`
|
|
NetworkPolicies []KubernetesNetworkPolicy `json:"networkPolicies,omitempty"`
|
|
PersistentVolumes []KubernetesPersistentVolume `json:"persistentVolumes,omitempty"`
|
|
PersistentVolumeClaims []KubernetesPersistentVolumeClaim `json:"persistentVolumeClaims,omitempty"`
|
|
StorageClasses []KubernetesStorageClass `json:"storageClasses,omitempty"`
|
|
ConfigMaps []KubernetesConfigMap `json:"configMaps,omitempty"`
|
|
Secrets []KubernetesSecret `json:"secrets,omitempty"`
|
|
ServiceAccounts []KubernetesServiceAccount `json:"serviceAccounts,omitempty"`
|
|
Roles []KubernetesRole `json:"roles,omitempty"`
|
|
ClusterRoles []KubernetesClusterRole `json:"clusterRoles,omitempty"`
|
|
RoleBindings []KubernetesRoleBinding `json:"roleBindings,omitempty"`
|
|
ClusterRoleBindings []KubernetesClusterRoleBinding `json:"clusterRoleBindings,omitempty"`
|
|
ResourceQuotas []KubernetesResourceQuota `json:"resourceQuotas,omitempty"`
|
|
LimitRanges []KubernetesLimitRange `json:"limitRanges,omitempty"`
|
|
PodDisruptionBudgets []KubernetesPodDisruptionBudget `json:"podDisruptionBudgets,omitempty"`
|
|
HorizontalPodAutoscalers []KubernetesHorizontalPodAutoscaler `json:"horizontalPodAutoscalers,omitempty"`
|
|
Events []KubernetesEvent `json:"events,omitempty"`
|
|
|
|
// Token information
|
|
TokenID string `json:"tokenId,omitempty"`
|
|
TokenName string `json:"tokenName,omitempty"`
|
|
TokenHint string `json:"tokenHint,omitempty"`
|
|
TokenLastUsedAt *time.Time `json:"tokenLastUsedAt,omitempty"`
|
|
|
|
Hidden bool `json:"hidden"`
|
|
PendingUninstall bool `json:"pendingUninstall"`
|
|
}
|
|
|
|
func (c KubernetesCluster) NormalizeCollections() KubernetesCluster {
|
|
if c.Nodes == nil {
|
|
c.Nodes = []KubernetesNode{}
|
|
}
|
|
for i := range c.Nodes {
|
|
c.Nodes[i] = c.Nodes[i].NormalizeCollections()
|
|
}
|
|
if c.Namespaces == nil {
|
|
c.Namespaces = []KubernetesNamespace{}
|
|
}
|
|
for i := range c.Namespaces {
|
|
c.Namespaces[i] = c.Namespaces[i].NormalizeCollections()
|
|
}
|
|
if c.Pods == nil {
|
|
c.Pods = []KubernetesPod{}
|
|
}
|
|
for i := range c.Pods {
|
|
c.Pods[i] = c.Pods[i].NormalizeCollections()
|
|
}
|
|
if c.Deployments == nil {
|
|
c.Deployments = []KubernetesDeployment{}
|
|
}
|
|
for i := range c.Deployments {
|
|
c.Deployments[i] = c.Deployments[i].NormalizeCollections()
|
|
}
|
|
if c.ReplicaSets == nil {
|
|
c.ReplicaSets = []KubernetesReplicaSet{}
|
|
}
|
|
for i := range c.ReplicaSets {
|
|
c.ReplicaSets[i] = c.ReplicaSets[i].NormalizeCollections()
|
|
}
|
|
if c.StatefulSets == nil {
|
|
c.StatefulSets = []KubernetesStatefulSet{}
|
|
}
|
|
for i := range c.StatefulSets {
|
|
c.StatefulSets[i] = c.StatefulSets[i].NormalizeCollections()
|
|
}
|
|
if c.DaemonSets == nil {
|
|
c.DaemonSets = []KubernetesDaemonSet{}
|
|
}
|
|
for i := range c.DaemonSets {
|
|
c.DaemonSets[i] = c.DaemonSets[i].NormalizeCollections()
|
|
}
|
|
if c.Services == nil {
|
|
c.Services = []KubernetesService{}
|
|
}
|
|
for i := range c.Services {
|
|
c.Services[i] = c.Services[i].NormalizeCollections()
|
|
}
|
|
if c.Jobs == nil {
|
|
c.Jobs = []KubernetesJob{}
|
|
}
|
|
for i := range c.Jobs {
|
|
c.Jobs[i] = c.Jobs[i].NormalizeCollections()
|
|
}
|
|
if c.CronJobs == nil {
|
|
c.CronJobs = []KubernetesCronJob{}
|
|
}
|
|
for i := range c.CronJobs {
|
|
c.CronJobs[i] = c.CronJobs[i].NormalizeCollections()
|
|
}
|
|
if c.Ingresses == nil {
|
|
c.Ingresses = []KubernetesIngress{}
|
|
}
|
|
for i := range c.Ingresses {
|
|
c.Ingresses[i] = c.Ingresses[i].NormalizeCollections()
|
|
}
|
|
if c.EndpointSlices == nil {
|
|
c.EndpointSlices = []KubernetesEndpointSlice{}
|
|
}
|
|
for i := range c.EndpointSlices {
|
|
c.EndpointSlices[i] = c.EndpointSlices[i].NormalizeCollections()
|
|
}
|
|
if c.NetworkPolicies == nil {
|
|
c.NetworkPolicies = []KubernetesNetworkPolicy{}
|
|
}
|
|
for i := range c.NetworkPolicies {
|
|
c.NetworkPolicies[i] = c.NetworkPolicies[i].NormalizeCollections()
|
|
}
|
|
if c.PersistentVolumes == nil {
|
|
c.PersistentVolumes = []KubernetesPersistentVolume{}
|
|
}
|
|
for i := range c.PersistentVolumes {
|
|
c.PersistentVolumes[i] = c.PersistentVolumes[i].NormalizeCollections()
|
|
}
|
|
if c.PersistentVolumeClaims == nil {
|
|
c.PersistentVolumeClaims = []KubernetesPersistentVolumeClaim{}
|
|
}
|
|
for i := range c.PersistentVolumeClaims {
|
|
c.PersistentVolumeClaims[i] = c.PersistentVolumeClaims[i].NormalizeCollections()
|
|
}
|
|
if c.StorageClasses == nil {
|
|
c.StorageClasses = []KubernetesStorageClass{}
|
|
}
|
|
for i := range c.StorageClasses {
|
|
c.StorageClasses[i] = c.StorageClasses[i].NormalizeCollections()
|
|
}
|
|
if c.ConfigMaps == nil {
|
|
c.ConfigMaps = []KubernetesConfigMap{}
|
|
}
|
|
for i := range c.ConfigMaps {
|
|
c.ConfigMaps[i] = c.ConfigMaps[i].NormalizeCollections()
|
|
}
|
|
if c.Secrets == nil {
|
|
c.Secrets = []KubernetesSecret{}
|
|
}
|
|
for i := range c.Secrets {
|
|
c.Secrets[i] = c.Secrets[i].NormalizeCollections()
|
|
}
|
|
if c.ServiceAccounts == nil {
|
|
c.ServiceAccounts = []KubernetesServiceAccount{}
|
|
}
|
|
for i := range c.ServiceAccounts {
|
|
c.ServiceAccounts[i] = c.ServiceAccounts[i].NormalizeCollections()
|
|
}
|
|
if c.Roles == nil {
|
|
c.Roles = []KubernetesRole{}
|
|
}
|
|
for i := range c.Roles {
|
|
c.Roles[i] = c.Roles[i].NormalizeCollections()
|
|
}
|
|
if c.ClusterRoles == nil {
|
|
c.ClusterRoles = []KubernetesClusterRole{}
|
|
}
|
|
for i := range c.ClusterRoles {
|
|
c.ClusterRoles[i] = c.ClusterRoles[i].NormalizeCollections()
|
|
}
|
|
if c.RoleBindings == nil {
|
|
c.RoleBindings = []KubernetesRoleBinding{}
|
|
}
|
|
for i := range c.RoleBindings {
|
|
c.RoleBindings[i] = c.RoleBindings[i].NormalizeCollections()
|
|
}
|
|
if c.ClusterRoleBindings == nil {
|
|
c.ClusterRoleBindings = []KubernetesClusterRoleBinding{}
|
|
}
|
|
for i := range c.ClusterRoleBindings {
|
|
c.ClusterRoleBindings[i] = c.ClusterRoleBindings[i].NormalizeCollections()
|
|
}
|
|
if c.ResourceQuotas == nil {
|
|
c.ResourceQuotas = []KubernetesResourceQuota{}
|
|
}
|
|
for i := range c.ResourceQuotas {
|
|
c.ResourceQuotas[i] = c.ResourceQuotas[i].NormalizeCollections()
|
|
}
|
|
if c.LimitRanges == nil {
|
|
c.LimitRanges = []KubernetesLimitRange{}
|
|
}
|
|
for i := range c.LimitRanges {
|
|
c.LimitRanges[i] = c.LimitRanges[i].NormalizeCollections()
|
|
}
|
|
if c.PodDisruptionBudgets == nil {
|
|
c.PodDisruptionBudgets = []KubernetesPodDisruptionBudget{}
|
|
}
|
|
for i := range c.PodDisruptionBudgets {
|
|
c.PodDisruptionBudgets[i] = c.PodDisruptionBudgets[i].NormalizeCollections()
|
|
}
|
|
if c.HorizontalPodAutoscalers == nil {
|
|
c.HorizontalPodAutoscalers = []KubernetesHorizontalPodAutoscaler{}
|
|
}
|
|
for i := range c.HorizontalPodAutoscalers {
|
|
c.HorizontalPodAutoscalers[i] = c.HorizontalPodAutoscalers[i].NormalizeCollections()
|
|
}
|
|
if c.Events == nil {
|
|
c.Events = []KubernetesEvent{}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// RemovedKubernetesCluster tracks a Kubernetes cluster that was deliberately removed and blocked from reporting.
|
|
type RemovedKubernetesCluster struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name,omitempty"`
|
|
DisplayName string `json:"displayName,omitempty"`
|
|
RemovedAt time.Time `json:"removedAt"`
|
|
}
|
|
|
|
type KubernetesNode struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Ready bool `json:"ready"`
|
|
Unschedulable bool `json:"unschedulable,omitempty"`
|
|
KubeletVersion string `json:"kubeletVersion,omitempty"`
|
|
ContainerRuntimeVersion string `json:"containerRuntimeVersion,omitempty"`
|
|
OSImage string `json:"osImage,omitempty"`
|
|
KernelVersion string `json:"kernelVersion,omitempty"`
|
|
Architecture string `json:"architecture,omitempty"`
|
|
CapacityCPU int64 `json:"capacityCpuCores,omitempty"`
|
|
CapacityMemoryBytes int64 `json:"capacityMemoryBytes,omitempty"`
|
|
CapacityPods int64 `json:"capacityPods,omitempty"`
|
|
AllocCPU int64 `json:"allocatableCpuCores,omitempty"`
|
|
AllocMemoryBytes int64 `json:"allocatableMemoryBytes,omitempty"`
|
|
AllocPods int64 `json:"allocatablePods,omitempty"`
|
|
UsageCPUMilliCores int64 `json:"usageCpuMilliCores,omitempty"`
|
|
UsageMemoryBytes int64 `json:"usageMemoryBytes,omitempty"`
|
|
UsageCPUPercent float64 `json:"usageCpuPercent,omitempty"`
|
|
UsageMemoryPercent float64 `json:"usageMemoryPercent,omitempty"`
|
|
Roles []string `json:"roles,omitempty"`
|
|
}
|
|
|
|
func (n KubernetesNode) NormalizeCollections() KubernetesNode {
|
|
if n.Roles == nil {
|
|
n.Roles = []string{}
|
|
}
|
|
return n
|
|
}
|
|
|
|
type KubernetesPod struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
NodeName string `json:"nodeName,omitempty"`
|
|
Phase string `json:"phase,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
QoSClass string `json:"qosClass,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
StartTime *time.Time `json:"startTime,omitempty"`
|
|
Restarts int `json:"restarts,omitempty"`
|
|
UsageCPUMilliCores int `json:"usageCpuMilliCores,omitempty"`
|
|
UsageMemoryBytes int64 `json:"usageMemoryBytes,omitempty"`
|
|
UsageCPUPercent float64 `json:"usageCpuPercent,omitempty"`
|
|
UsageMemoryPercent float64 `json:"usageMemoryPercent,omitempty"`
|
|
NetworkRxBytes int64 `json:"networkRxBytes,omitempty"`
|
|
NetworkTxBytes int64 `json:"networkTxBytes,omitempty"`
|
|
NetInRate float64 `json:"netInRate,omitempty"`
|
|
NetOutRate float64 `json:"netOutRate,omitempty"`
|
|
EphemeralStorageUsedBytes int64 `json:"ephemeralStorageUsedBytes,omitempty"`
|
|
EphemeralStorageCapacityBytes int64 `json:"ephemeralStorageCapacityBytes,omitempty"`
|
|
DiskUsagePercent float64 `json:"diskUsagePercent,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
OwnerKind string `json:"ownerKind,omitempty"`
|
|
OwnerName string `json:"ownerName,omitempty"`
|
|
Containers []KubernetesPodContainer `json:"containers,omitempty"`
|
|
}
|
|
|
|
func (p KubernetesPod) NormalizeCollections() KubernetesPod {
|
|
if p.Labels == nil {
|
|
p.Labels = map[string]string{}
|
|
}
|
|
if p.Containers == nil {
|
|
p.Containers = []KubernetesPodContainer{}
|
|
}
|
|
return p
|
|
}
|
|
|
|
type KubernetesPodContainer struct {
|
|
Name string `json:"name"`
|
|
Image string `json:"image,omitempty"`
|
|
Ready bool `json:"ready"`
|
|
RestartCount int32 `json:"restartCount,omitempty"`
|
|
State string `json:"state,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
type KubernetesDeployment struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
DesiredReplicas int32 `json:"desiredReplicas,omitempty"`
|
|
UpdatedReplicas int32 `json:"updatedReplicas,omitempty"`
|
|
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
|
|
AvailableReplicas int32 `json:"availableReplicas,omitempty"`
|
|
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (d KubernetesDeployment) NormalizeCollections() KubernetesDeployment {
|
|
if d.Labels == nil {
|
|
d.Labels = map[string]string{}
|
|
}
|
|
return d
|
|
}
|
|
|
|
type KubernetesReplicaSet struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
DesiredReplicas int32 `json:"desiredReplicas,omitempty"`
|
|
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
|
|
AvailableReplicas int32 `json:"availableReplicas,omitempty"`
|
|
FullyLabeledReplicas int32 `json:"fullyLabeledReplicas,omitempty"`
|
|
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
|
OwnerKind string `json:"ownerKind,omitempty"`
|
|
OwnerName string `json:"ownerName,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (r KubernetesReplicaSet) NormalizeCollections() KubernetesReplicaSet {
|
|
if r.Labels == nil {
|
|
r.Labels = map[string]string{}
|
|
}
|
|
return r
|
|
}
|
|
|
|
type KubernetesNamespace struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Phase string `json:"phase,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (n KubernetesNamespace) NormalizeCollections() KubernetesNamespace {
|
|
if n.Labels == nil {
|
|
n.Labels = map[string]string{}
|
|
}
|
|
return n
|
|
}
|
|
|
|
type KubernetesStatefulSet struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
DesiredReplicas int32 `json:"desiredReplicas,omitempty"`
|
|
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
|
|
CurrentReplicas int32 `json:"currentReplicas,omitempty"`
|
|
UpdatedReplicas int32 `json:"updatedReplicas,omitempty"`
|
|
AvailableReplicas int32 `json:"availableReplicas,omitempty"`
|
|
ServiceName string `json:"serviceName,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (s KubernetesStatefulSet) NormalizeCollections() KubernetesStatefulSet {
|
|
if s.Labels == nil {
|
|
s.Labels = map[string]string{}
|
|
}
|
|
return s
|
|
}
|
|
|
|
type KubernetesDaemonSet struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
DesiredNumberScheduled int32 `json:"desiredNumberScheduled,omitempty"`
|
|
CurrentNumberScheduled int32 `json:"currentNumberScheduled,omitempty"`
|
|
NumberReady int32 `json:"numberReady,omitempty"`
|
|
UpdatedNumberScheduled int32 `json:"updatedNumberScheduled,omitempty"`
|
|
NumberAvailable int32 `json:"numberAvailable,omitempty"`
|
|
NumberUnavailable int32 `json:"numberUnavailable,omitempty"`
|
|
NumberMisscheduled int32 `json:"numberMisscheduled,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (d KubernetesDaemonSet) NormalizeCollections() KubernetesDaemonSet {
|
|
if d.Labels == nil {
|
|
d.Labels = map[string]string{}
|
|
}
|
|
return d
|
|
}
|
|
|
|
type KubernetesService struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
ServiceType string `json:"type,omitempty"`
|
|
ClusterIP string `json:"clusterIp,omitempty"`
|
|
ExternalIPs []string `json:"externalIps,omitempty"`
|
|
Ports []KubernetesServicePort `json:"ports,omitempty"`
|
|
Selector map[string]string `json:"selector,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (s KubernetesService) NormalizeCollections() KubernetesService {
|
|
if s.ExternalIPs == nil {
|
|
s.ExternalIPs = []string{}
|
|
}
|
|
if s.Ports == nil {
|
|
s.Ports = []KubernetesServicePort{}
|
|
}
|
|
if s.Selector == nil {
|
|
s.Selector = map[string]string{}
|
|
}
|
|
if s.Labels == nil {
|
|
s.Labels = map[string]string{}
|
|
}
|
|
return s
|
|
}
|
|
|
|
type KubernetesServicePort struct {
|
|
Name string `json:"name,omitempty"`
|
|
Protocol string `json:"protocol,omitempty"`
|
|
Port int32 `json:"port,omitempty"`
|
|
TargetPort string `json:"targetPort,omitempty"`
|
|
NodePort int32 `json:"nodePort,omitempty"`
|
|
}
|
|
|
|
type KubernetesJob struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
DesiredCompletions int32 `json:"desiredCompletions,omitempty"`
|
|
Succeeded int32 `json:"succeeded,omitempty"`
|
|
Failed int32 `json:"failed,omitempty"`
|
|
Active int32 `json:"active,omitempty"`
|
|
StartTime *time.Time `json:"startTime,omitempty"`
|
|
CompletionTime *time.Time `json:"completionTime,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (j KubernetesJob) NormalizeCollections() KubernetesJob {
|
|
if j.Labels == nil {
|
|
j.Labels = map[string]string{}
|
|
}
|
|
return j
|
|
}
|
|
|
|
type KubernetesCronJob struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
Schedule string `json:"schedule,omitempty"`
|
|
Suspend bool `json:"suspend,omitempty"`
|
|
Active int `json:"active,omitempty"`
|
|
LastScheduleTime *time.Time `json:"lastScheduleTime,omitempty"`
|
|
LastSuccessfulTime *time.Time `json:"lastSuccessfulTime,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (j KubernetesCronJob) NormalizeCollections() KubernetesCronJob {
|
|
if j.Labels == nil {
|
|
j.Labels = map[string]string{}
|
|
}
|
|
return j
|
|
}
|
|
|
|
type KubernetesIngress struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
ClassName string `json:"className,omitempty"`
|
|
Hosts []string `json:"hosts,omitempty"`
|
|
Addresses []string `json:"addresses,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (i KubernetesIngress) NormalizeCollections() KubernetesIngress {
|
|
if i.Hosts == nil {
|
|
i.Hosts = []string{}
|
|
}
|
|
if i.Addresses == nil {
|
|
i.Addresses = []string{}
|
|
}
|
|
if i.Labels == nil {
|
|
i.Labels = map[string]string{}
|
|
}
|
|
return i
|
|
}
|
|
|
|
type KubernetesEndpointSlice struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
AddressType string `json:"addressType,omitempty"`
|
|
ServiceName string `json:"serviceName,omitempty"`
|
|
Ports []KubernetesEndpointPort `json:"ports,omitempty"`
|
|
EndpointCount int `json:"endpointCount,omitempty"`
|
|
ReadyEndpointCount int `json:"readyEndpointCount,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (e KubernetesEndpointSlice) NormalizeCollections() KubernetesEndpointSlice {
|
|
if e.Ports == nil {
|
|
e.Ports = []KubernetesEndpointPort{}
|
|
}
|
|
if e.Labels == nil {
|
|
e.Labels = map[string]string{}
|
|
}
|
|
return e
|
|
}
|
|
|
|
type KubernetesEndpointPort struct {
|
|
Name string `json:"name,omitempty"`
|
|
Protocol string `json:"protocol,omitempty"`
|
|
Port int32 `json:"port,omitempty"`
|
|
AppProtocol string `json:"appProtocol,omitempty"`
|
|
}
|
|
|
|
type KubernetesNetworkPolicy struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
PolicyTypes []string `json:"policyTypes,omitempty"`
|
|
IngressRuleCount int `json:"ingressRuleCount,omitempty"`
|
|
EgressRuleCount int `json:"egressRuleCount,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (n KubernetesNetworkPolicy) NormalizeCollections() KubernetesNetworkPolicy {
|
|
if n.PolicyTypes == nil {
|
|
n.PolicyTypes = []string{}
|
|
}
|
|
if n.Labels == nil {
|
|
n.Labels = map[string]string{}
|
|
}
|
|
return n
|
|
}
|
|
|
|
type KubernetesPersistentVolume struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Phase string `json:"phase,omitempty"`
|
|
StorageClass string `json:"storageClass,omitempty"`
|
|
CapacityBytes int64 `json:"capacityBytes,omitempty"`
|
|
AccessModes []string `json:"accessModes,omitempty"`
|
|
ReclaimPolicy string `json:"reclaimPolicy,omitempty"`
|
|
ClaimNamespace string `json:"claimNamespace,omitempty"`
|
|
ClaimName string `json:"claimName,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (p KubernetesPersistentVolume) NormalizeCollections() KubernetesPersistentVolume {
|
|
if p.AccessModes == nil {
|
|
p.AccessModes = []string{}
|
|
}
|
|
if p.Labels == nil {
|
|
p.Labels = map[string]string{}
|
|
}
|
|
return p
|
|
}
|
|
|
|
type KubernetesPersistentVolumeClaim struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
Phase string `json:"phase,omitempty"`
|
|
StorageClass string `json:"storageClass,omitempty"`
|
|
RequestedBytes int64 `json:"requestedBytes,omitempty"`
|
|
CapacityBytes int64 `json:"capacityBytes,omitempty"`
|
|
AccessModes []string `json:"accessModes,omitempty"`
|
|
VolumeName string `json:"volumeName,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (p KubernetesPersistentVolumeClaim) NormalizeCollections() KubernetesPersistentVolumeClaim {
|
|
if p.AccessModes == nil {
|
|
p.AccessModes = []string{}
|
|
}
|
|
if p.Labels == nil {
|
|
p.Labels = map[string]string{}
|
|
}
|
|
return p
|
|
}
|
|
|
|
type KubernetesStorageClass struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Provisioner string `json:"provisioner,omitempty"`
|
|
ReclaimPolicy string `json:"reclaimPolicy,omitempty"`
|
|
VolumeBindingMode string `json:"volumeBindingMode,omitempty"`
|
|
AllowVolumeExpansion *bool `json:"allowVolumeExpansion,omitempty"`
|
|
ParameterKeys []string `json:"parameterKeys,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (s KubernetesStorageClass) NormalizeCollections() KubernetesStorageClass {
|
|
if s.ParameterKeys == nil {
|
|
s.ParameterKeys = []string{}
|
|
}
|
|
if s.Labels == nil {
|
|
s.Labels = map[string]string{}
|
|
}
|
|
return s
|
|
}
|
|
|
|
type KubernetesConfigMap struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
DataKeys []string `json:"dataKeys,omitempty"`
|
|
BinaryDataKeys []string `json:"binaryDataKeys,omitempty"`
|
|
Immutable bool `json:"immutable,omitempty"`
|
|
MetadataOnly bool `json:"metadataOnly,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (c KubernetesConfigMap) NormalizeCollections() KubernetesConfigMap {
|
|
if c.DataKeys == nil {
|
|
c.DataKeys = []string{}
|
|
}
|
|
if c.BinaryDataKeys == nil {
|
|
c.BinaryDataKeys = []string{}
|
|
}
|
|
if c.Labels == nil {
|
|
c.Labels = map[string]string{}
|
|
}
|
|
return c
|
|
}
|
|
|
|
type KubernetesSecret struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
Type string `json:"type,omitempty"`
|
|
DataKeys []string `json:"dataKeys,omitempty"`
|
|
Immutable bool `json:"immutable,omitempty"`
|
|
MetadataOnly bool `json:"metadataOnly,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (s KubernetesSecret) NormalizeCollections() KubernetesSecret {
|
|
if s.DataKeys == nil {
|
|
s.DataKeys = []string{}
|
|
}
|
|
if s.Labels == nil {
|
|
s.Labels = map[string]string{}
|
|
}
|
|
return s
|
|
}
|
|
|
|
type KubernetesServiceAccount struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
AutomountServiceAccountToken *bool `json:"automountServiceAccountToken,omitempty"`
|
|
SecretCount int `json:"secretCount,omitempty"`
|
|
ImagePullSecrets []string `json:"imagePullSecrets,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (s KubernetesServiceAccount) NormalizeCollections() KubernetesServiceAccount {
|
|
if s.ImagePullSecrets == nil {
|
|
s.ImagePullSecrets = []string{}
|
|
}
|
|
if s.Labels == nil {
|
|
s.Labels = map[string]string{}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// KubernetesRole mirrors the agent's namespaced RBAC Role contract. Full
|
|
// PolicyRule contents are deliberately not propagated so Pulse stays a
|
|
// "what permissions exist where" surface, not an RBAC enumeration tool.
|
|
type KubernetesRole struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
RuleCount int `json:"ruleCount,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (r KubernetesRole) NormalizeCollections() KubernetesRole {
|
|
if r.Labels == nil {
|
|
r.Labels = map[string]string{}
|
|
}
|
|
return r
|
|
}
|
|
|
|
// KubernetesClusterRole mirrors the agent's cluster-scoped RBAC ClusterRole
|
|
// contract. See KubernetesRole for the rule-count rationale.
|
|
type KubernetesClusterRole struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
RuleCount int `json:"ruleCount,omitempty"`
|
|
AggregationLabels map[string]string `json:"aggregationLabels,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (r KubernetesClusterRole) NormalizeCollections() KubernetesClusterRole {
|
|
if r.AggregationLabels == nil {
|
|
r.AggregationLabels = map[string]string{}
|
|
}
|
|
if r.Labels == nil {
|
|
r.Labels = map[string]string{}
|
|
}
|
|
return r
|
|
}
|
|
|
|
// KubernetesRoleBinding mirrors the agent's namespaced RBAC RoleBinding.
|
|
// SubjectKinds is the distinct, sorted set of subject Kinds bound by this
|
|
// rule (User / Group / ServiceAccount); individual subject names are not
|
|
// reported.
|
|
type KubernetesRoleBinding struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
RoleKind string `json:"roleKind"`
|
|
RoleName string `json:"roleName"`
|
|
SubjectCount int `json:"subjectCount,omitempty"`
|
|
SubjectKinds []string `json:"subjectKinds,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (b KubernetesRoleBinding) NormalizeCollections() KubernetesRoleBinding {
|
|
if b.SubjectKinds == nil {
|
|
b.SubjectKinds = []string{}
|
|
}
|
|
if b.Labels == nil {
|
|
b.Labels = map[string]string{}
|
|
}
|
|
return b
|
|
}
|
|
|
|
// KubernetesClusterRoleBinding mirrors the agent's cluster-scoped RBAC
|
|
// ClusterRoleBinding. See KubernetesRoleBinding for the subject-kinds rationale.
|
|
type KubernetesClusterRoleBinding struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
RoleKind string `json:"roleKind"`
|
|
RoleName string `json:"roleName"`
|
|
SubjectCount int `json:"subjectCount,omitempty"`
|
|
SubjectKinds []string `json:"subjectKinds,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (b KubernetesClusterRoleBinding) NormalizeCollections() KubernetesClusterRoleBinding {
|
|
if b.SubjectKinds == nil {
|
|
b.SubjectKinds = []string{}
|
|
}
|
|
if b.Labels == nil {
|
|
b.Labels = map[string]string{}
|
|
}
|
|
return b
|
|
}
|
|
|
|
type KubernetesResourceQuota struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
Hard map[string]string `json:"hard,omitempty"`
|
|
Used map[string]string `json:"used,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (q KubernetesResourceQuota) NormalizeCollections() KubernetesResourceQuota {
|
|
if q.Hard == nil {
|
|
q.Hard = map[string]string{}
|
|
}
|
|
if q.Used == nil {
|
|
q.Used = map[string]string{}
|
|
}
|
|
if q.Labels == nil {
|
|
q.Labels = map[string]string{}
|
|
}
|
|
return q
|
|
}
|
|
|
|
type KubernetesLimitRange struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
LimitTypes []string `json:"limitTypes,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (l KubernetesLimitRange) NormalizeCollections() KubernetesLimitRange {
|
|
if l.LimitTypes == nil {
|
|
l.LimitTypes = []string{}
|
|
}
|
|
if l.Labels == nil {
|
|
l.Labels = map[string]string{}
|
|
}
|
|
return l
|
|
}
|
|
|
|
type KubernetesPodDisruptionBudget struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
MinAvailable string `json:"minAvailable,omitempty"`
|
|
MaxUnavailable string `json:"maxUnavailable,omitempty"`
|
|
DesiredHealthy int32 `json:"desiredHealthy,omitempty"`
|
|
CurrentHealthy int32 `json:"currentHealthy,omitempty"`
|
|
DisruptionsAllowed int32 `json:"disruptionsAllowed,omitempty"`
|
|
ExpectedPods int32 `json:"expectedPods,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (p KubernetesPodDisruptionBudget) NormalizeCollections() KubernetesPodDisruptionBudget {
|
|
if p.Labels == nil {
|
|
p.Labels = map[string]string{}
|
|
}
|
|
return p
|
|
}
|
|
|
|
type KubernetesHorizontalPodAutoscaler struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace"`
|
|
TargetKind string `json:"targetKind,omitempty"`
|
|
TargetName string `json:"targetName,omitempty"`
|
|
MinReplicas int32 `json:"minReplicas,omitempty"`
|
|
MaxReplicas int32 `json:"maxReplicas,omitempty"`
|
|
CurrentReplicas int32 `json:"currentReplicas,omitempty"`
|
|
DesiredReplicas int32 `json:"desiredReplicas,omitempty"`
|
|
MetricTypes []string `json:"metricTypes,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
}
|
|
|
|
func (h KubernetesHorizontalPodAutoscaler) NormalizeCollections() KubernetesHorizontalPodAutoscaler {
|
|
if h.MetricTypes == nil {
|
|
h.MetricTypes = []string{}
|
|
}
|
|
if h.Labels == nil {
|
|
h.Labels = map[string]string{}
|
|
}
|
|
return h
|
|
}
|
|
|
|
type KubernetesEvent struct {
|
|
UID string `json:"uid"`
|
|
Name string `json:"name"`
|
|
Namespace string `json:"namespace,omitempty"`
|
|
EventType string `json:"type,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
InvolvedKind string `json:"involvedKind,omitempty"`
|
|
InvolvedName string `json:"involvedName,omitempty"`
|
|
Count int32 `json:"count,omitempty"`
|
|
FirstSeen *time.Time `json:"firstSeen,omitempty"`
|
|
LastSeen *time.Time `json:"lastSeen,omitempty"`
|
|
EventTime *time.Time `json:"eventTime,omitempty"`
|
|
}
|
|
|
|
// DockerService summarises a Docker Swarm service.
|
|
type DockerService struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Stack string `json:"stack,omitempty"`
|
|
Image string `json:"image,omitempty"`
|
|
Mode string `json:"mode,omitempty"`
|
|
DesiredTasks int `json:"desiredTasks,omitempty"`
|
|
RunningTasks int `json:"runningTasks,omitempty"`
|
|
CompletedTasks int `json:"completedTasks,omitempty"`
|
|
UpdateStatus *DockerServiceUpdate `json:"updateStatus,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
EndpointPorts []DockerServicePort `json:"endpointPorts,omitempty"`
|
|
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
|
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
|
}
|
|
|
|
func (s DockerService) NormalizeCollections() DockerService {
|
|
if s.Labels == nil {
|
|
s.Labels = map[string]string{}
|
|
}
|
|
if s.EndpointPorts == nil {
|
|
s.EndpointPorts = []DockerServicePort{}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// DockerServicePort describes a published service port.
|
|
type DockerServicePort struct {
|
|
Name string `json:"name,omitempty"`
|
|
Protocol string `json:"protocol,omitempty"`
|
|
TargetPort uint32 `json:"targetPort,omitempty"`
|
|
PublishedPort uint32 `json:"publishedPort,omitempty"`
|
|
PublishMode string `json:"publishMode,omitempty"`
|
|
}
|
|
|
|
// DockerServiceUpdate captures service update progress.
|
|
type DockerServiceUpdate struct {
|
|
State string `json:"state,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
|
}
|
|
|
|
// DockerTask summarises a Swarm task.
|
|
type DockerTask struct {
|
|
ID string `json:"id"`
|
|
ServiceID string `json:"serviceId,omitempty"`
|
|
ServiceName string `json:"serviceName,omitempty"`
|
|
Slot int `json:"slot,omitempty"`
|
|
NodeID string `json:"nodeId,omitempty"`
|
|
NodeName string `json:"nodeName,omitempty"`
|
|
DesiredState string `json:"desiredState,omitempty"`
|
|
CurrentState string `json:"currentState,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
ContainerID string `json:"containerId,omitempty"`
|
|
ContainerName string `json:"containerName,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
|
StartedAt *time.Time `json:"startedAt,omitempty"`
|
|
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
|
}
|
|
|
|
// DockerNode summarises a Docker Swarm node.
|
|
type DockerNode struct {
|
|
ID string `json:"id"`
|
|
Hostname string `json:"hostname,omitempty"`
|
|
Role string `json:"role,omitempty"`
|
|
Availability string `json:"availability,omitempty"`
|
|
State string `json:"state,omitempty"`
|
|
Message string `json:"message,omitempty"`
|
|
Address string `json:"address,omitempty"`
|
|
ManagerReachability string `json:"managerReachability,omitempty"`
|
|
ManagerAddress string `json:"managerAddress,omitempty"`
|
|
Leader bool `json:"leader,omitempty"`
|
|
EngineVersion string `json:"engineVersion,omitempty"`
|
|
OS string `json:"os,omitempty"`
|
|
Architecture string `json:"architecture,omitempty"`
|
|
NanoCPUs int64 `json:"nanoCpus,omitempty"`
|
|
MemoryBytes int64 `json:"memoryBytes,omitempty"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
EngineLabels map[string]string `json:"engineLabels,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
|
}
|
|
|
|
func (n DockerNode) NormalizeCollections() DockerNode {
|
|
if n.Labels == nil {
|
|
n.Labels = map[string]string{}
|
|
}
|
|
if n.EngineLabels == nil {
|
|
n.EngineLabels = map[string]string{}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// DockerSecret summarises Docker Swarm secret metadata.
|
|
type DockerSecret struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
DriverName string `json:"driverName,omitempty"`
|
|
TemplatingDriver string `json:"templatingDriver,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
|
}
|
|
|
|
func (s DockerSecret) NormalizeCollections() DockerSecret {
|
|
if s.Labels == nil {
|
|
s.Labels = map[string]string{}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// DockerConfig summarises Docker Swarm config metadata.
|
|
type DockerConfig struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Labels map[string]string `json:"labels,omitempty"`
|
|
TemplatingDriver string `json:"templatingDriver,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
|
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
|
}
|
|
|
|
func (c DockerConfig) NormalizeCollections() DockerConfig {
|
|
if c.Labels == nil {
|
|
c.Labels = map[string]string{}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// DockerSwarmInfo captures node-level swarm metadata.
|
|
type DockerSwarmInfo struct {
|
|
NodeID string `json:"nodeId,omitempty"`
|
|
NodeRole string `json:"nodeRole,omitempty"`
|
|
LocalState string `json:"localState,omitempty"`
|
|
ControlAvailable bool `json:"controlAvailable,omitempty"`
|
|
ClusterID string `json:"clusterId,omitempty"`
|
|
ClusterName string `json:"clusterName,omitempty"`
|
|
Scope string `json:"scope,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// DockerHostCommandStatus tracks the lifecycle of a control command issued to a Docker host.
|
|
type DockerHostCommandStatus struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Status string `json:"status"`
|
|
Message string `json:"message,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
DispatchedAt *time.Time `json:"dispatchedAt,omitempty"`
|
|
AcknowledgedAt *time.Time `json:"acknowledgedAt,omitempty"`
|
|
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
|
FailedAt *time.Time `json:"failedAt,omitempty"`
|
|
FailureReason string `json:"failureReason,omitempty"`
|
|
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
|
}
|
|
|
|
// Storage represents a storage resource
|
|
type Storage struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Node string `json:"node"`
|
|
Instance string `json:"instance"`
|
|
Nodes []string `json:"nodes,omitempty"`
|
|
NodeIDs []string `json:"nodeIds,omitempty"`
|
|
AliasIDs []string `json:"aliasIds,omitempty"`
|
|
NodeCount int `json:"nodeCount,omitempty"`
|
|
Type string `json:"type"`
|
|
Status string `json:"status"`
|
|
Pool string `json:"pool,omitempty"`
|
|
Path string `json:"path,omitempty"`
|
|
Total int64 `json:"total"`
|
|
Used int64 `json:"used"`
|
|
Free int64 `json:"free"`
|
|
Usage float64 `json:"usage"`
|
|
Content string `json:"content"`
|
|
Shared bool `json:"shared"`
|
|
Enabled bool `json:"enabled"`
|
|
Active bool `json:"active"`
|
|
LastSeen time.Time `json:"lastSeen,omitzero"` // when the owning poller last delivered this entry; zero = never seen
|
|
ZFSPool *ZFSPool `json:"zfsPool,omitempty"` // ZFS pool details if this is ZFS storage
|
|
}
|
|
|
|
func CephPoolStorageID(instanceName, poolName string) string {
|
|
instance := strings.TrimSpace(instanceName)
|
|
if instance == "" {
|
|
instance = "ceph"
|
|
}
|
|
name := strings.TrimSpace(poolName)
|
|
if name == "" {
|
|
name = "pool"
|
|
}
|
|
return fmt.Sprintf("%s-ceph-pool-%s", instance, name)
|
|
}
|
|
|
|
func CephPoolStorageAliasIDs(instanceName, poolName string, instanceAliases ...string) []string {
|
|
canonicalID := CephPoolStorageID(instanceName, poolName)
|
|
aliases := make([]string, 0, len(instanceAliases)+2)
|
|
for _, instance := range append([]string{instanceName}, instanceAliases...) {
|
|
instance = strings.TrimSpace(instance)
|
|
if instance == "" {
|
|
continue
|
|
}
|
|
candidates := []string{instance}
|
|
if strings.HasPrefix(instance, "agent:") {
|
|
if unprefixed := strings.TrimSpace(strings.TrimPrefix(instance, "agent:")); unprefixed != "" {
|
|
candidates = append(candidates, unprefixed)
|
|
}
|
|
} else {
|
|
candidates = append(candidates, "agent:"+instance)
|
|
}
|
|
for _, candidate := range candidates {
|
|
aliasID := CephPoolStorageID(candidate, poolName)
|
|
if aliasID == canonicalID || containsString(aliases, aliasID) {
|
|
continue
|
|
}
|
|
aliases = append(aliases, aliasID)
|
|
}
|
|
}
|
|
return aliases
|
|
}
|
|
|
|
func StorageFromCephPool(cluster CephCluster, pool CephPool) Storage {
|
|
name := strings.TrimSpace(pool.Name)
|
|
if name == "" {
|
|
name = fmt.Sprintf("pool-%d", pool.ID)
|
|
}
|
|
total := pool.StoredBytes + pool.AvailableBytes
|
|
free := pool.AvailableBytes
|
|
if free < 0 {
|
|
free = 0
|
|
}
|
|
usage := pool.PercentUsed
|
|
if usage == 0 && total > 0 && pool.StoredBytes > 0 {
|
|
usage = (float64(pool.StoredBytes) / float64(total)) * 100
|
|
}
|
|
|
|
status := "available"
|
|
switch strings.ToUpper(strings.TrimSpace(cluster.Health)) {
|
|
case "HEALTH_ERR", "ERR", "ERROR":
|
|
status = "unavailable"
|
|
}
|
|
|
|
return Storage{
|
|
ID: CephPoolStorageID(cluster.Instance, name),
|
|
Name: name,
|
|
Node: "cluster",
|
|
Instance: cluster.Instance,
|
|
AliasIDs: CephPoolStorageAliasIDs(cluster.Instance, name, cluster.InstanceAliases...),
|
|
Type: "ceph",
|
|
Status: status,
|
|
Pool: name,
|
|
Total: total,
|
|
Used: pool.StoredBytes,
|
|
Free: free,
|
|
Usage: usage,
|
|
Content: "ceph",
|
|
Shared: true,
|
|
Enabled: true,
|
|
Active: status == "available",
|
|
LastSeen: cluster.LastUpdated,
|
|
}
|
|
}
|
|
|
|
func CephPoolStorage(cluster CephCluster) []Storage {
|
|
if len(cluster.Pools) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]Storage, 0, len(cluster.Pools))
|
|
for _, pool := range cluster.Pools {
|
|
out = append(out, StorageFromCephPool(cluster, pool))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s Storage) NormalizeCollections() Storage {
|
|
if s.Nodes == nil {
|
|
s.Nodes = []string{}
|
|
}
|
|
if s.NodeIDs == nil {
|
|
s.NodeIDs = []string{}
|
|
}
|
|
if s.ZFSPool != nil {
|
|
pool := s.ZFSPool.NormalizeCollections()
|
|
s.ZFSPool = &pool
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ZFSPool represents a ZFS pool with health and error information
|
|
type ZFSPool struct {
|
|
Name string `json:"name"`
|
|
State string `json:"state"` // ONLINE, DEGRADED, FAULTED, OFFLINE, REMOVED, UNAVAIL
|
|
Status string `json:"status"` // Healthy, Degraded, Faulted, etc.
|
|
Scan string `json:"scan"` // Current scan status (scrub, resilver, none)
|
|
ScanDetails *ZFSScan `json:"scanDetails,omitempty"`
|
|
ReadErrors int64 `json:"readErrors"`
|
|
WriteErrors int64 `json:"writeErrors"`
|
|
ChecksumErrors int64 `json:"checksumErrors"`
|
|
Devices []ZFSDevice `json:"devices"`
|
|
Datasets []ZFSDataset `json:"datasets,omitempty"`
|
|
}
|
|
|
|
func (p ZFSPool) NormalizeCollections() ZFSPool {
|
|
if p.Devices == nil {
|
|
p.Devices = []ZFSDevice{}
|
|
}
|
|
return p
|
|
}
|
|
|
|
// ZFSDataset is the read-only dataset capacity projection reported by a linked
|
|
// host agent and attached to the provider-owned pool.
|
|
type ZFSDataset struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type,omitempty"`
|
|
Mountpoint string `json:"mountpoint,omitempty"`
|
|
UsedBytes int64 `json:"usedBytes,omitempty"`
|
|
AvailableBytes int64 `json:"availableBytes,omitempty"`
|
|
ReferencedBytes int64 `json:"referencedBytes,omitempty"`
|
|
}
|
|
|
|
type HostZFSPool struct {
|
|
Name string `json:"name"`
|
|
Datasets []ZFSDataset `json:"datasets"`
|
|
}
|
|
|
|
func (p HostZFSPool) NormalizeCollections() HostZFSPool {
|
|
if p.Datasets == nil {
|
|
p.Datasets = []ZFSDataset{}
|
|
}
|
|
return p
|
|
}
|
|
|
|
// ZFSScan is the provider-native scrub or resilver observation for a pool.
|
|
// The legacy Scan string remains for compact consumers, while this shape keeps
|
|
// progress, terminal errors, and lifecycle timestamps machine-readable.
|
|
type ZFSScan struct {
|
|
Function string `json:"function,omitempty"`
|
|
State string `json:"state,omitempty"`
|
|
Percentage float64 `json:"percentage,omitempty"`
|
|
Errors int64 `json:"errors,omitempty"`
|
|
BytesExamined int64 `json:"bytesExamined,omitempty"`
|
|
BytesToProcess int64 `json:"bytesToProcess,omitempty"`
|
|
TotalSecondsRemaining int64 `json:"totalSecondsRemaining,omitempty"`
|
|
StartedAt *time.Time `json:"startedAt,omitempty"`
|
|
EndedAt *time.Time `json:"endedAt,omitempty"`
|
|
}
|
|
|
|
// ZFSDevice represents a device in a ZFS pool
|
|
type ZFSDevice struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"` // disk, mirror, raidz, raidz2, raidz3, spare, log, cache, special, special-group
|
|
Role string `json:"role,omitempty"`
|
|
Parent string `json:"parent,omitempty"`
|
|
GUID string `json:"guid,omitempty"`
|
|
Disk string `json:"disk,omitempty"`
|
|
Path string `json:"path,omitempty"`
|
|
State string `json:"state"` // ONLINE, DEGRADED, FAULTED, OFFLINE, REMOVED, UNAVAIL
|
|
ReadErrors int64 `json:"readErrors"`
|
|
WriteErrors int64 `json:"writeErrors"`
|
|
ChecksumErrors int64 `json:"checksumErrors"`
|
|
Missing bool `json:"missing,omitempty"`
|
|
Message string `json:"message,omitempty"` // Additional message provided by Proxmox (if any)
|
|
}
|
|
|
|
// CephCluster represents the health and capacity information for a Ceph cluster
|
|
type CephCluster struct {
|
|
ID string `json:"id"`
|
|
Instance string `json:"instance"`
|
|
InstanceAliases []string `json:"instanceAliases,omitempty"`
|
|
Source string `json:"source,omitempty"`
|
|
Name string `json:"name"`
|
|
FSID string `json:"fsid,omitempty"`
|
|
Health string `json:"health"`
|
|
HealthMessage string `json:"healthMessage,omitempty"`
|
|
HealthChecks []CephHealthCheck `json:"healthChecks,omitempty"`
|
|
TotalBytes int64 `json:"totalBytes"`
|
|
UsedBytes int64 `json:"usedBytes"`
|
|
AvailableBytes int64 `json:"availableBytes"`
|
|
UsagePercent float64 `json:"usagePercent"`
|
|
NumMons int `json:"numMons"`
|
|
NumMgrs int `json:"numMgrs"`
|
|
NumOSDs int `json:"numOsds"`
|
|
NumOSDsUp int `json:"numOsdsUp"`
|
|
NumOSDsIn int `json:"numOsdsIn"`
|
|
NumPGs int `json:"numPGs"`
|
|
Pools []CephPool `json:"pools,omitempty"`
|
|
Services []CephServiceStatus `json:"services,omitempty"`
|
|
LastUpdated time.Time `json:"lastUpdated"`
|
|
}
|
|
|
|
func (c CephCluster) NormalizeCollections() CephCluster {
|
|
if c.InstanceAliases == nil {
|
|
c.InstanceAliases = []string{}
|
|
}
|
|
if c.Pools == nil {
|
|
c.Pools = []CephPool{}
|
|
}
|
|
if c.HealthChecks == nil {
|
|
c.HealthChecks = []CephHealthCheck{}
|
|
}
|
|
if c.Services == nil {
|
|
c.Services = []CephServiceStatus{}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// CephHealthCheck preserves one native Ceph health-check code and its
|
|
// operator-facing evidence without inferring a ZFS device or pool fault.
|
|
type CephHealthCheck struct {
|
|
Code string `json:"code"`
|
|
Severity string `json:"severity,omitempty"`
|
|
Summary string `json:"summary,omitempty"`
|
|
}
|
|
|
|
// CephPool represents usage statistics for a Ceph pool
|
|
type CephPool struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
StoredBytes int64 `json:"storedBytes"`
|
|
AvailableBytes int64 `json:"availableBytes"`
|
|
Objects int64 `json:"objects"`
|
|
PercentUsed float64 `json:"percentUsed"`
|
|
}
|
|
|
|
// CephServiceStatus summarises daemon health for a Ceph service type (e.g. mon, mgr)
|
|
type CephServiceStatus struct {
|
|
Type string `json:"type"`
|
|
Running int `json:"running"`
|
|
Total int `json:"total"`
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
// PhysicalDisk represents a physical disk on a node
|
|
type PhysicalDisk struct {
|
|
ID string `json:"id"` // "{instance}-{node}-{devpath}"
|
|
Node string `json:"node"`
|
|
Instance string `json:"instance"`
|
|
DevPath string `json:"devPath"` // /dev/nvme0n1, /dev/sda
|
|
Model string `json:"model"`
|
|
Vendor string `json:"vendor,omitempty"`
|
|
Serial string `json:"serial"`
|
|
WWN string `json:"wwn"` // World Wide Name
|
|
Type string `json:"type"` // nvme, sata, sas
|
|
Controller string `json:"controller,omitempty"` // Controller association when reported
|
|
Target string `json:"target,omitempty"` // Controller target/HCTL when reported
|
|
Size int64 `json:"size"` // bytes
|
|
Health string `json:"health"` // PASSED, FAILED, UNKNOWN
|
|
Wearout int `json:"wearout"` // SSD wear metric from Proxmox (0-100, -1 when unavailable)
|
|
Temperature int `json:"temperature"` // Celsius (if available)
|
|
RPM int `json:"rpm"` // 0 for SSDs
|
|
Used string `json:"used"` // Filesystem or partition usage
|
|
StorageGroup string `json:"storageGroup"` // Pool/VG/array this disk belongs to (e.g. ZFS pool name); empty if not matched
|
|
SmartAttributes *SMARTAttributes `json:"smartAttributes,omitempty"`
|
|
IO *DiskIO `json:"io,omitempty"`
|
|
Collection *diskinventory.CollectionStatus `json:"collection,omitempty"`
|
|
LastChecked time.Time `json:"lastChecked"`
|
|
}
|
|
|
|
// PBSInstance represents a Proxmox Backup Server instance
|
|
type PBSInstance struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Host string `json:"host"`
|
|
NodeName string `json:"nodeName,omitempty"` // Hostname the PBS node reports about itself (GET /nodes)
|
|
GuestURL string `json:"guestURL,omitempty"` // Optional guest-accessible URL (for navigation)
|
|
Status string `json:"status"`
|
|
Version string `json:"version"`
|
|
CPU float64 `json:"cpu"` // CPU usage percentage
|
|
Memory float64 `json:"memory"` // Memory usage percentage
|
|
MemoryUsed int64 `json:"memoryUsed"` // Memory used in bytes
|
|
MemoryTotal int64 `json:"memoryTotal"` // Total memory in bytes
|
|
Uptime int64 `json:"uptime"` // Uptime in seconds
|
|
Datastores []PBSDatastore `json:"datastores"`
|
|
BackupJobs []PBSBackupJob `json:"backupJobs"`
|
|
SyncJobs []PBSSyncJob `json:"syncJobs"`
|
|
VerifyJobs []PBSVerifyJob `json:"verifyJobs"`
|
|
PruneJobs []PBSPruneJob `json:"pruneJobs"`
|
|
GarbageJobs []PBSGarbageJob `json:"garbageJobs"`
|
|
JobHealthEvidence []PBSJobHealthEvidence `json:"jobHealthEvidence"`
|
|
ConnectionHealth string `json:"connectionHealth"`
|
|
LastSeen time.Time `json:"lastSeen"`
|
|
}
|
|
|
|
func (i PBSInstance) NormalizeCollections() PBSInstance {
|
|
if i.Datastores == nil {
|
|
i.Datastores = []PBSDatastore{}
|
|
}
|
|
for idx := range i.Datastores {
|
|
i.Datastores[idx] = i.Datastores[idx].NormalizeCollections()
|
|
}
|
|
if i.BackupJobs == nil {
|
|
i.BackupJobs = []PBSBackupJob{}
|
|
}
|
|
if i.SyncJobs == nil {
|
|
i.SyncJobs = []PBSSyncJob{}
|
|
}
|
|
if i.VerifyJobs == nil {
|
|
i.VerifyJobs = []PBSVerifyJob{}
|
|
}
|
|
if i.PruneJobs == nil {
|
|
i.PruneJobs = []PBSPruneJob{}
|
|
}
|
|
if i.GarbageJobs == nil {
|
|
i.GarbageJobs = []PBSGarbageJob{}
|
|
}
|
|
if i.JobHealthEvidence == nil {
|
|
i.JobHealthEvidence = []PBSJobHealthEvidence{}
|
|
}
|
|
return i
|
|
}
|
|
|
|
// PBSDatastore represents a PBS datastore
|
|
type PBSDatastore struct {
|
|
Name string `json:"name"`
|
|
Total int64 `json:"total"`
|
|
Used int64 `json:"used"`
|
|
Free int64 `json:"free"`
|
|
Usage float64 `json:"usage"`
|
|
Status string `json:"status"`
|
|
Error string `json:"error,omitempty"`
|
|
Namespaces []PBSNamespace `json:"namespaces"`
|
|
DeduplicationFactor float64 `json:"deduplicationFactor,omitempty"`
|
|
}
|
|
|
|
func (d PBSDatastore) NormalizeCollections() PBSDatastore {
|
|
if d.Namespaces == nil {
|
|
d.Namespaces = []PBSNamespace{}
|
|
}
|
|
return d
|
|
}
|
|
|
|
// PBSNamespace represents a PBS namespace
|
|
type PBSNamespace struct {
|
|
Path string `json:"path"`
|
|
Parent string `json:"parent,omitempty"`
|
|
Depth int `json:"depth"`
|
|
}
|
|
|
|
// PBSBackup represents a backup stored on PBS
|
|
type PBSBackup struct {
|
|
ID string `json:"id"` // Unique ID combining PBS instance, namespace, type, vmid, and time
|
|
Instance string `json:"instance"` // PBS instance name
|
|
Datastore string `json:"datastore"`
|
|
Namespace string `json:"namespace"`
|
|
BackupType string `json:"backupType"` // "vm" or "ct"
|
|
VMID string `json:"vmid"`
|
|
BackupTime time.Time `json:"backupTime"`
|
|
Size int64 `json:"size"`
|
|
Protected bool `json:"protected"`
|
|
Verified bool `json:"verified"`
|
|
VerificationRaw any `json:"verificationRaw,omitempty"`
|
|
Comment string `json:"comment,omitempty"`
|
|
Files []string `json:"files"`
|
|
Owner string `json:"owner,omitempty"` // User who created the backup
|
|
}
|
|
|
|
func (b PBSBackup) NormalizeCollections() PBSBackup {
|
|
if b.Files == nil {
|
|
b.Files = []string{}
|
|
}
|
|
return b
|
|
}
|
|
|
|
// PBSBackupJob represents a PBS backup job
|
|
type PBSBackupJob struct {
|
|
ID string `json:"id"`
|
|
Store string `json:"store"`
|
|
Type string `json:"type"`
|
|
VMID string `json:"vmid,omitempty"`
|
|
LastBackup time.Time `json:"lastBackup"`
|
|
NextRun time.Time `json:"nextRun,omitempty"`
|
|
Status string `json:"status"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// PBSSyncJob represents a PBS sync job
|
|
type PBSSyncJob struct {
|
|
ID string `json:"id"`
|
|
Store string `json:"store"`
|
|
Remote string `json:"remote"`
|
|
Status string `json:"status"`
|
|
LastSync time.Time `json:"lastSync"`
|
|
NextRun time.Time `json:"nextRun,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// PBSVerifyJob represents a PBS verification job
|
|
type PBSVerifyJob struct {
|
|
ID string `json:"id"`
|
|
Store string `json:"store"`
|
|
Status string `json:"status"`
|
|
LastVerify time.Time `json:"lastVerify"`
|
|
NextRun time.Time `json:"nextRun,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// PBSPruneJob represents a PBS prune job
|
|
type PBSPruneJob struct {
|
|
ID string `json:"id"`
|
|
Store string `json:"store"`
|
|
Status string `json:"status"`
|
|
LastPrune time.Time `json:"lastPrune"`
|
|
NextRun time.Time `json:"nextRun,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// PBSGarbageJob represents a PBS garbage collection job
|
|
type PBSGarbageJob struct {
|
|
ID string `json:"id"`
|
|
Store string `json:"store"`
|
|
Status string `json:"status"`
|
|
LastGarbage time.Time `json:"lastGarbage"`
|
|
NextRun time.Time `json:"nextRun,omitempty"`
|
|
RemovedBytes int64 `json:"removedBytes,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// PBSJobHealthEvidence is the shared PBS job-health ledger shape for backup,
|
|
// sync, verify, prune, and garbage-collection job families. PBS backup-family
|
|
// evidence is observed task-history evidence only; configured scheduled backup
|
|
// compliance belongs to a future PVE /cluster/backup source.
|
|
type PBSJobHealthEvidence struct {
|
|
ID string `json:"id"`
|
|
Family string `json:"family"`
|
|
Store string `json:"store,omitempty"`
|
|
Remote string `json:"remote,omitempty"`
|
|
Namespace string `json:"namespace,omitempty"`
|
|
Schedule string `json:"schedule,omitempty"`
|
|
Comment string `json:"comment,omitempty"`
|
|
Enabled bool `json:"enabled"`
|
|
LastRunState string `json:"last-run-state,omitempty"`
|
|
LastRunUPID string `json:"last-run-upid,omitempty"`
|
|
LastRunEndtime int64 `json:"last-run-endtime,omitempty"`
|
|
NextRun int64 `json:"next-run,omitempty"`
|
|
UPID string `json:"upid,omitempty"`
|
|
WorkerType string `json:"worker-type,omitempty"`
|
|
WorkerID string `json:"worker-id,omitempty"`
|
|
TaskStatus string `json:"task-status,omitempty"`
|
|
TaskStartTime int64 `json:"task-starttime,omitempty"`
|
|
TaskEndTime int64 `json:"task-endtime,omitempty"`
|
|
Confidence string `json:"confidence"`
|
|
EvidenceSource string `json:"evidenceSource,omitempty"`
|
|
EvidenceScope string `json:"evidenceScope,omitempty"`
|
|
Freshness PBSJobHealthFreshness `json:"freshness"`
|
|
Posture string `json:"posture,omitempty"`
|
|
PostureReason string `json:"postureReason,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// PBSJobHealthFreshness captures timing separately from evidence confidence.
|
|
type PBSJobHealthFreshness struct {
|
|
ObservedAt time.Time `json:"observedAt"`
|
|
LastRunEndTime time.Time `json:"lastRunEndTime,omitempty"`
|
|
NextRun time.Time `json:"nextRun,omitempty"`
|
|
AgeSeconds int64 `json:"ageSeconds,omitempty"`
|
|
State string `json:"state,omitempty"`
|
|
}
|
|
|
|
// PMGInstance represents a Proxmox Mail Gateway connection
|
|
type PMGInstance struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Host string `json:"host"`
|
|
GuestURL string `json:"guestURL,omitempty"` // Optional guest-accessible URL (for navigation)
|
|
Status string `json:"status"`
|
|
Version string `json:"version"`
|
|
Nodes []PMGNodeStatus `json:"nodes"`
|
|
MailStats *PMGMailStats `json:"mailStats,omitempty"`
|
|
MailCount []PMGMailCountPoint `json:"mailCount"`
|
|
SpamDistribution []PMGSpamBucket `json:"spamDistribution"`
|
|
Quarantine *PMGQuarantineTotals `json:"quarantine,omitempty"`
|
|
RelayDomains []PMGRelayDomain `json:"relayDomains"`
|
|
DomainStats []PMGDomainStat `json:"domainStats"`
|
|
DomainStatsAsOf time.Time `json:"domainStatsAsOf,omitempty"`
|
|
ConnectionHealth string `json:"connectionHealth"`
|
|
LastSeen time.Time `json:"lastSeen"`
|
|
LastUpdated time.Time `json:"lastUpdated"`
|
|
}
|
|
|
|
func (i PMGInstance) NormalizeCollections() PMGInstance {
|
|
if i.Nodes == nil {
|
|
i.Nodes = []PMGNodeStatus{}
|
|
}
|
|
if i.MailCount == nil {
|
|
i.MailCount = []PMGMailCountPoint{}
|
|
}
|
|
if i.SpamDistribution == nil {
|
|
i.SpamDistribution = []PMGSpamBucket{}
|
|
}
|
|
if i.RelayDomains == nil {
|
|
i.RelayDomains = []PMGRelayDomain{}
|
|
}
|
|
if i.DomainStats == nil {
|
|
i.DomainStats = []PMGDomainStat{}
|
|
}
|
|
return i
|
|
}
|
|
|
|
// PMGDomainStat describes mail statistics for a domain over a fixed time window
|
|
// (currently: the last 24 hours at poll time).
|
|
type PMGDomainStat struct {
|
|
Domain string `json:"domain"`
|
|
MailCount float64 `json:"mailCount"`
|
|
SpamCount float64 `json:"spamCount"`
|
|
VirusCount float64 `json:"virusCount"`
|
|
Bytes float64 `json:"bytes,omitempty"`
|
|
}
|
|
|
|
// PMGRelayDomain represents a relay domain configured in Proxmox Mail Gateway.
|
|
type PMGRelayDomain struct {
|
|
Domain string `json:"domain"`
|
|
Comment string `json:"comment,omitempty"`
|
|
}
|
|
|
|
// PMGNodeStatus represents the status of a PMG cluster node
|
|
type PMGNodeStatus struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Role string `json:"role,omitempty"`
|
|
Uptime int64 `json:"uptime,omitempty"`
|
|
LoadAvg string `json:"loadAvg,omitempty"`
|
|
QueueStatus *PMGQueueStatus `json:"queueStatus,omitempty"` // Postfix queue status for this node
|
|
}
|
|
|
|
// PMGBackup represents a configuration backup generated by a PMG node.
|
|
type PMGBackup struct {
|
|
ID string `json:"id"`
|
|
Instance string `json:"instance"`
|
|
Node string `json:"node"`
|
|
Filename string `json:"filename"`
|
|
BackupTime time.Time `json:"backupTime"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
|
|
// Backups aggregates backup collections by source type.
|
|
type Backups struct {
|
|
PVE PVEBackups `json:"pve"`
|
|
PBS []PBSBackup `json:"pbs"`
|
|
PMG []PMGBackup `json:"pmg"`
|
|
}
|
|
|
|
func (b Backups) NormalizeCollections() Backups {
|
|
b.PVE = b.PVE.NormalizeCollections()
|
|
if b.PBS == nil {
|
|
b.PBS = []PBSBackup{}
|
|
}
|
|
for i := range b.PBS {
|
|
b.PBS[i] = b.PBS[i].NormalizeCollections()
|
|
}
|
|
if b.PMG == nil {
|
|
b.PMG = []PMGBackup{}
|
|
}
|
|
return b
|
|
}
|
|
|
|
// PMGMailStats summarizes aggregated mail statistics for a timeframe
|
|
type PMGMailStats struct {
|
|
Timeframe string `json:"timeframe"`
|
|
CountTotal float64 `json:"countTotal"`
|
|
CountIn float64 `json:"countIn"`
|
|
CountOut float64 `json:"countOut"`
|
|
SpamIn float64 `json:"spamIn"`
|
|
SpamOut float64 `json:"spamOut"`
|
|
VirusIn float64 `json:"virusIn"`
|
|
VirusOut float64 `json:"virusOut"`
|
|
BouncesIn float64 `json:"bouncesIn"`
|
|
BouncesOut float64 `json:"bouncesOut"`
|
|
BytesIn float64 `json:"bytesIn"`
|
|
BytesOut float64 `json:"bytesOut"`
|
|
GreylistCount float64 `json:"greylistCount"`
|
|
JunkIn float64 `json:"junkIn"`
|
|
AverageProcessTimeMs float64 `json:"averageProcessTimeMs"`
|
|
RBLRejects float64 `json:"rblRejects"`
|
|
PregreetRejects float64 `json:"pregreetRejects"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
}
|
|
|
|
// PMGMailCountPoint represents a point-in-time mail counter snapshot
|
|
type PMGMailCountPoint struct {
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Count float64 `json:"count"`
|
|
CountIn float64 `json:"countIn"`
|
|
CountOut float64 `json:"countOut"`
|
|
SpamIn float64 `json:"spamIn"`
|
|
SpamOut float64 `json:"spamOut"`
|
|
VirusIn float64 `json:"virusIn"`
|
|
VirusOut float64 `json:"virusOut"`
|
|
RBLRejects float64 `json:"rblRejects"`
|
|
Pregreet float64 `json:"pregreet"`
|
|
BouncesIn float64 `json:"bouncesIn"`
|
|
BouncesOut float64 `json:"bouncesOut"`
|
|
Greylist float64 `json:"greylist"`
|
|
Index int `json:"index"`
|
|
Timeframe string `json:"timeframe"`
|
|
WindowStart time.Time `json:"windowStart,omitempty"`
|
|
WindowEnd time.Time `json:"windowEnd,omitempty"`
|
|
}
|
|
|
|
// PMGSpamBucket represents spam distribution counts by score
|
|
type PMGSpamBucket struct {
|
|
Score string `json:"score"`
|
|
Count float64 `json:"count"`
|
|
}
|
|
|
|
// PMGQuarantineTotals summarizes quarantine counts per category
|
|
type PMGQuarantineTotals struct {
|
|
Spam int `json:"spam"`
|
|
Virus int `json:"virus"`
|
|
Attachment int `json:"attachment"`
|
|
Blacklisted int `json:"blacklisted"`
|
|
}
|
|
|
|
// PMGQueueStatus represents the Postfix mail queue status for a PMG instance
|
|
type PMGQueueStatus struct {
|
|
Active int `json:"active"` // Messages currently being delivered
|
|
Deferred int `json:"deferred"` // Messages waiting for retry
|
|
Hold int `json:"hold"` // Messages on hold
|
|
Incoming int `json:"incoming"` // Messages in incoming queue
|
|
Total int `json:"total"` // Total messages in all queues
|
|
OldestAge int64 `json:"oldestAge"` // Age of oldest message in seconds (0 if queue empty)
|
|
UpdatedAt time.Time `json:"updatedAt"` // When this queue data was collected
|
|
}
|
|
|
|
// Memory represents memory usage
|
|
type Memory struct {
|
|
Total int64 `json:"total"`
|
|
Used int64 `json:"used"`
|
|
Free int64 `json:"free"`
|
|
// Reclaimable buff/cache (available - truly free); used + cache + free ≈ total.
|
|
Cache int64 `json:"cache,omitempty"`
|
|
// UsageUnavailable is true when total capacity is known but no cache-aware
|
|
// source can establish current usage. Consumers must not interpret the
|
|
// zero-valued Used, Free, or Usage fields as a measurement in that state.
|
|
UsageUnavailable bool `json:"usageUnavailable,omitempty"`
|
|
Usage float64 `json:"usage"`
|
|
Balloon int64 `json:"balloon,omitempty"`
|
|
SwapUsed int64 `json:"swapUsed,omitempty"`
|
|
SwapTotal int64 `json:"swapTotal,omitempty"`
|
|
}
|
|
|
|
// HasKnownUsage reports whether this memory value is safe for live
|
|
// projections, history, and threshold evaluation.
|
|
func (m Memory) HasKnownUsage() bool {
|
|
if m.UsageUnavailable || math.IsNaN(m.Usage) || math.IsInf(m.Usage, 0) || m.Usage < 0 || m.Usage > 100 {
|
|
return false
|
|
}
|
|
if m.Total == 0 {
|
|
return m.Usage > 0
|
|
}
|
|
return m.Total > 0 &&
|
|
m.Used >= 0 &&
|
|
m.Used <= m.Total &&
|
|
m.Free >= 0 &&
|
|
m.Free <= m.Total
|
|
}
|
|
|
|
// UnavailableMemory preserves a known capacity without inventing usage from
|
|
// total-free when cache-aware evidence is missing.
|
|
func UnavailableMemory(total int64) Memory {
|
|
if total < 0 {
|
|
total = 0
|
|
}
|
|
return Memory{
|
|
Total: total,
|
|
UsageUnavailable: true,
|
|
}
|
|
}
|
|
|
|
type GuestNetworkInterface struct {
|
|
Name string `json:"name"`
|
|
MAC string `json:"mac,omitempty"`
|
|
Addresses []string `json:"addresses"`
|
|
RXBytes int64 `json:"rxBytes,omitempty"`
|
|
TXBytes int64 `json:"txBytes,omitempty"`
|
|
}
|
|
|
|
func (i GuestNetworkInterface) NormalizeCollections() GuestNetworkInterface {
|
|
if i.Addresses == nil {
|
|
i.Addresses = []string{}
|
|
}
|
|
return i
|
|
}
|
|
|
|
// Disk represents disk usage
|
|
type Disk struct {
|
|
Total int64 `json:"total"`
|
|
Used int64 `json:"used"`
|
|
Free int64 `json:"free"`
|
|
Usage float64 `json:"usage"`
|
|
Mountpoint string `json:"mountpoint,omitempty"`
|
|
Type string `json:"type,omitempty"`
|
|
Device string `json:"device,omitempty"`
|
|
}
|
|
|
|
// CPUInfo represents CPU information
|
|
type CPUInfo struct {
|
|
Model string `json:"model"`
|
|
Cores int `json:"cores"`
|
|
Sockets int `json:"sockets"`
|
|
MHz string `json:"mhz"`
|
|
}
|
|
|
|
// Temperature represents temperature sensors data
|
|
type Temperature struct {
|
|
CPUPackage float64 `json:"cpuPackage,omitempty"` // CPU package temperature (primary metric)
|
|
CPUMax float64 `json:"cpuMax,omitempty"` // Highest core temperature
|
|
CPUMin float64 `json:"cpuMin,omitempty"` // Minimum recorded CPU temperature (since monitoring started)
|
|
CPUMaxRecord float64 `json:"cpuMaxRecord,omitempty"` // Maximum recorded CPU temperature (since monitoring started)
|
|
MinRecorded time.Time `json:"minRecorded,omitempty"` // When minimum temperature was recorded
|
|
MaxRecorded time.Time `json:"maxRecorded,omitempty"` // When maximum temperature was recorded
|
|
Cores []CoreTemp `json:"cores"` // Individual core temperatures
|
|
GPU []GPUTemp `json:"gpu"` // GPU temperatures
|
|
NVMe []NVMeTemp `json:"nvme"` // NVMe drive temperatures
|
|
SMART []DiskTemp `json:"smart"` // Physical disk temperatures from SMART data
|
|
Available bool `json:"available"` // Whether any temperature data is available
|
|
HasCPU bool `json:"hasCPU"` // Whether CPU temperature data is available
|
|
HasGPU bool `json:"hasGPU"` // Whether GPU temperature data is available
|
|
HasNVMe bool `json:"hasNVMe"` // Whether NVMe temperature data is available
|
|
HasSMART bool `json:"hasSMART"` // Whether SMART disk temperature data is available
|
|
// True when the SSH payload was raw `sensors -j` output instead of the
|
|
// pulse-sensors wrapper payload. Pre-v6.0.0-rc.6 setup scripts lock the
|
|
// authorized_keys entry to `sensors -j`, so SMART (SATA/SAS) disk temps
|
|
// can never arrive until the node setup script is re-run.
|
|
LegacySensorsFormat bool `json:"legacySensorsFormat,omitempty"`
|
|
LastUpdate time.Time `json:"lastUpdate"` // When this data was collected
|
|
}
|
|
|
|
func (t Temperature) NormalizeCollections() Temperature {
|
|
if t.Cores == nil {
|
|
t.Cores = []CoreTemp{}
|
|
}
|
|
if t.GPU == nil {
|
|
t.GPU = []GPUTemp{}
|
|
}
|
|
if t.NVMe == nil {
|
|
t.NVMe = []NVMeTemp{}
|
|
}
|
|
if t.SMART == nil {
|
|
t.SMART = []DiskTemp{}
|
|
}
|
|
return t
|
|
}
|
|
|
|
// CoreTemp represents a CPU core temperature
|
|
type CoreTemp struct {
|
|
Core int `json:"core"`
|
|
Temp float64 `json:"temp"`
|
|
}
|
|
|
|
// GPUTemp represents a GPU temperature sensor
|
|
type GPUTemp struct {
|
|
Device string `json:"device"` // GPU device identifier (e.g., "amdgpu-pci-0400")
|
|
Edge float64 `json:"edge,omitempty"` // Edge temperature
|
|
Junction float64 `json:"junction,omitempty"` // Junction/hotspot temperature
|
|
Mem float64 `json:"mem,omitempty"` // Memory temperature
|
|
}
|
|
|
|
// NVMeTemp represents an NVMe drive temperature
|
|
type NVMeTemp struct {
|
|
Device string `json:"device"`
|
|
Temp float64 `json:"temp"`
|
|
}
|
|
|
|
// DiskTemp represents a physical disk temperature from SMART data
|
|
type DiskTemp struct {
|
|
Device string `json:"device"` // Device path (e.g., /dev/sda)
|
|
Serial string `json:"serial,omitempty"` // Disk serial number
|
|
WWN string `json:"wwn,omitempty"` // World Wide Name
|
|
Model string `json:"model,omitempty"` // Disk model
|
|
Type string `json:"type,omitempty"` // Transport type (sata, sas, nvme)
|
|
Temperature int `json:"temperature"` // Temperature in Celsius
|
|
LastUpdated time.Time `json:"lastUpdated"` // When this reading was taken
|
|
StandbySkipped bool `json:"standbySkipped,omitempty"` // True if disk was in standby and not queried
|
|
}
|
|
|
|
// Metric represents a time-series metric
|
|
type Metric struct {
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Type string `json:"type"`
|
|
ID string `json:"id"`
|
|
Values map[string]interface{} `json:"values"`
|
|
}
|
|
|
|
func (m Metric) NormalizeCollections() Metric {
|
|
if m.Values == nil {
|
|
m.Values = map[string]interface{}{}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// PVEBackups represents PVE backup information
|
|
type PVEBackups struct {
|
|
BackupTasks []BackupTask `json:"backupTasks"`
|
|
StorageBackups []StorageBackup `json:"storageBackups"`
|
|
GuestSnapshots []GuestSnapshot `json:"guestSnapshots"`
|
|
}
|
|
|
|
func (b PVEBackups) NormalizeCollections() PVEBackups {
|
|
if b.BackupTasks == nil {
|
|
b.BackupTasks = []BackupTask{}
|
|
}
|
|
if b.StorageBackups == nil {
|
|
b.StorageBackups = []StorageBackup{}
|
|
}
|
|
if b.GuestSnapshots == nil {
|
|
b.GuestSnapshots = []GuestSnapshot{}
|
|
}
|
|
return b
|
|
}
|
|
|
|
// BackupTask represents a PVE backup task
|
|
type BackupTask struct {
|
|
ID string `json:"id"`
|
|
Node string `json:"node"`
|
|
Instance string `json:"instance"` // Unique instance identifier
|
|
Type string `json:"type"`
|
|
VMID int `json:"vmid"`
|
|
Status string `json:"status"`
|
|
StartTime time.Time `json:"startTime"`
|
|
EndTime time.Time `json:"endTime,omitempty"`
|
|
ObservedAt time.Time `json:"observedAt,omitempty"`
|
|
Size int64 `json:"size,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// StorageBackup represents a backup file in storage
|
|
type StorageBackup struct {
|
|
ID string `json:"id"`
|
|
Storage string `json:"storage"`
|
|
Node string `json:"node"`
|
|
Instance string `json:"instance"` // Unique instance identifier (for nodes with duplicate names)
|
|
Type string `json:"type"`
|
|
VMID int `json:"vmid"`
|
|
Time time.Time `json:"time"`
|
|
CTime int64 `json:"ctime"` // Unix timestamp for compatibility
|
|
Size int64 `json:"size"`
|
|
Format string `json:"format"`
|
|
Notes string `json:"notes,omitempty"`
|
|
Protected bool `json:"protected"`
|
|
Volid string `json:"volid"` // Volume ID for compatibility
|
|
IsPBS bool `json:"isPBS"` // Indicates if backup is on PBS storage
|
|
Verified bool `json:"verified"` // PBS verification status
|
|
Verification string `json:"verification,omitempty"` // Verification details
|
|
}
|
|
|
|
// GuestSnapshot represents a VM/CT snapshot
|
|
type GuestSnapshot struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Node string `json:"node"`
|
|
Instance string `json:"instance"` // Unique instance identifier (for nodes with duplicate names)
|
|
Type string `json:"type"`
|
|
VMID int `json:"vmid"`
|
|
Time time.Time `json:"time"`
|
|
Description string `json:"description,omitempty"`
|
|
Parent string `json:"parent,omitempty"`
|
|
VMState bool `json:"vmstate"`
|
|
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
|
}
|
|
|
|
// ReplicationJob represents the status of a Proxmox storage replication job.
|
|
type ReplicationJob struct {
|
|
ID string `json:"id"`
|
|
Instance string `json:"instance"`
|
|
JobID string `json:"jobId"`
|
|
JobNumber int `json:"jobNumber,omitempty"`
|
|
Guest string `json:"guest,omitempty"`
|
|
GuestID int `json:"guestId,omitempty"`
|
|
GuestName string `json:"guestName,omitempty"`
|
|
GuestType string `json:"guestType,omitempty"`
|
|
GuestNode string `json:"guestNode,omitempty"`
|
|
SourceNode string `json:"sourceNode,omitempty"`
|
|
SourceStorage string `json:"sourceStorage,omitempty"`
|
|
TargetNode string `json:"targetNode,omitempty"`
|
|
TargetStorage string `json:"targetStorage,omitempty"`
|
|
Schedule string `json:"schedule,omitempty"`
|
|
Type string `json:"type,omitempty"`
|
|
Enabled bool `json:"enabled"`
|
|
State string `json:"state,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
LastSyncStatus string `json:"lastSyncStatus,omitempty"`
|
|
LastSyncTime *time.Time `json:"lastSyncTime,omitempty"`
|
|
LastSyncUnix int64 `json:"lastSyncUnix,omitempty"`
|
|
LastSyncDurationSeconds int `json:"lastSyncDurationSeconds,omitempty"`
|
|
LastSyncDurationHuman string `json:"lastSyncDurationHuman,omitempty"`
|
|
NextSyncTime *time.Time `json:"nextSyncTime,omitempty"`
|
|
NextSyncUnix int64 `json:"nextSyncUnix,omitempty"`
|
|
DurationSeconds int `json:"durationSeconds,omitempty"`
|
|
DurationHuman string `json:"durationHuman,omitempty"`
|
|
FailCount int `json:"failCount,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
Comment string `json:"comment,omitempty"`
|
|
RemoveJob string `json:"removeJob,omitempty"`
|
|
RateLimitMbps *float64 `json:"rateLimitMbps,omitempty"`
|
|
LastPolled time.Time `json:"lastPolled"`
|
|
}
|
|
|
|
// Performance represents performance metrics
|
|
type Performance struct {
|
|
APICallDuration map[string]float64 `json:"apiCallDuration"`
|
|
LastPollDuration float64 `json:"lastPollDuration"`
|
|
PollingStartTime time.Time `json:"pollingStartTime"`
|
|
TotalAPICalls int `json:"totalApiCalls"`
|
|
FailedAPICalls int `json:"failedApiCalls"`
|
|
}
|
|
|
|
func (p Performance) NormalizeCollections() Performance {
|
|
if p.APICallDuration == nil {
|
|
p.APICallDuration = map[string]float64{}
|
|
}
|
|
return p
|
|
}
|
|
|
|
// Stats represents runtime statistics
|
|
type Stats struct {
|
|
StartTime time.Time `json:"startTime"`
|
|
Uptime int64 `json:"uptime"`
|
|
PollingCycles int `json:"pollingCycles"`
|
|
WebSocketClients int `json:"webSocketClients"`
|
|
Version string `json:"version"`
|
|
}
|
|
|
|
// NewState creates a new State instance
|
|
func NewState() *State {
|
|
pveBackups := PVEBackups{
|
|
BackupTasks: make([]BackupTask, 0),
|
|
StorageBackups: make([]StorageBackup, 0),
|
|
GuestSnapshots: make([]GuestSnapshot, 0),
|
|
}
|
|
|
|
state := &State{
|
|
Nodes: make([]Node, 0),
|
|
VMs: make([]VM, 0),
|
|
Containers: make([]Container, 0),
|
|
DockerHosts: make([]DockerHost, 0),
|
|
RemovedDockerHosts: make([]RemovedDockerHost, 0),
|
|
KubernetesClusters: make([]KubernetesCluster, 0),
|
|
RemovedKubernetesClusters: make([]RemovedKubernetesCluster, 0),
|
|
Hosts: make([]Host, 0),
|
|
RemovedHostAgents: make([]RemovedHostAgent, 0),
|
|
Storage: make([]Storage, 0),
|
|
CephClusters: make([]CephCluster, 0),
|
|
PhysicalDisks: make([]PhysicalDisk, 0),
|
|
PBSInstances: make([]PBSInstance, 0),
|
|
PMGInstances: make([]PMGInstance, 0),
|
|
PBSBackups: make([]PBSBackup, 0),
|
|
PMGBackups: make([]PMGBackup, 0),
|
|
Backups: Backups{
|
|
PVE: pveBackups,
|
|
PBS: make([]PBSBackup, 0),
|
|
PMG: make([]PMGBackup, 0),
|
|
},
|
|
ReplicationJobs: make([]ReplicationJob, 0),
|
|
Metrics: make([]Metric, 0),
|
|
PVEBackups: pveBackups,
|
|
ConnectionHealth: make(map[string]bool),
|
|
ActiveAlerts: make([]Alert, 0),
|
|
RecentlyResolved: make([]ResolvedAlert, 0),
|
|
PVETagColors: make(map[string]string),
|
|
PVETagStyles: make(map[string]PVETagStyle),
|
|
Performance: Performance{}.NormalizeCollections(),
|
|
LastUpdate: time.Now(),
|
|
}
|
|
|
|
state.syncBackupsLocked()
|
|
return state
|
|
}
|
|
|
|
// MergeTagColors merges Proxmox tag color entries into the shared state.
|
|
func (s *State) MergeTagColors(colors map[string]string) {
|
|
if len(colors) == 0 {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.PVETagColors == nil {
|
|
s.PVETagColors = make(map[string]string, len(colors))
|
|
}
|
|
for tag, color := range colors {
|
|
s.PVETagColors[strings.ToLower(strings.TrimSpace(tag))] = strings.TrimSpace(color)
|
|
}
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// MergePVETagStyle merges the Proxmox tag style for one PVE instance into the
|
|
// shared state while preserving the legacy aggregate color map.
|
|
func (s *State) MergePVETagStyle(instance string, style PVETagStyle) {
|
|
instance = strings.TrimSpace(instance)
|
|
if instance == "" && len(style.Colors) == 0 && !style.CaseSensitive {
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.PVETagColors == nil {
|
|
s.PVETagColors = make(map[string]string, len(style.Colors))
|
|
}
|
|
if s.PVETagStyles == nil {
|
|
s.PVETagStyles = make(map[string]PVETagStyle)
|
|
}
|
|
|
|
normalizedColors := make(map[string]string, len(style.Colors))
|
|
for tag, color := range style.Colors {
|
|
tag = strings.TrimSpace(tag)
|
|
if tag == "" {
|
|
continue
|
|
}
|
|
if !style.CaseSensitive {
|
|
tag = strings.ToLower(tag)
|
|
}
|
|
color = strings.TrimSpace(color)
|
|
normalizedColors[tag] = color
|
|
}
|
|
|
|
if instance != "" {
|
|
s.PVETagStyles[instance] = PVETagStyle{
|
|
Colors: normalizedColors,
|
|
CaseSensitive: style.CaseSensitive,
|
|
}
|
|
s.PVETagColors = make(map[string]string)
|
|
for _, instanceStyle := range s.PVETagStyles {
|
|
for tag, color := range instanceStyle.Colors {
|
|
s.PVETagColors[tag] = color
|
|
}
|
|
}
|
|
} else {
|
|
for tag, color := range normalizedColors {
|
|
s.PVETagColors[tag] = color
|
|
}
|
|
}
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// syncBackupsLocked updates the aggregated backups structure.
|
|
func (s *State) syncBackupsLocked() {
|
|
s.Backups = Backups{
|
|
PVE: clonePVEBackups(s.PVEBackups),
|
|
PBS: clonePBSBackups(s.PBSBackups),
|
|
PMG: clonePMGBackups(s.PMGBackups),
|
|
}
|
|
}
|
|
|
|
// UpdateActiveAlerts updates the active alerts in the state.
|
|
// Always sets a non-nil slice so callers can distinguish "synced with zero alerts" from "never synced".
|
|
func (s *State) UpdateActiveAlerts(alerts []Alert) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
cloned := cloneAlerts(alerts)
|
|
if cloned == nil {
|
|
cloned = []Alert{}
|
|
}
|
|
s.ActiveAlerts = cloned
|
|
}
|
|
|
|
// UpdateRecentlyResolved updates the recently resolved alerts in the state
|
|
func (s *State) UpdateRecentlyResolved(resolved []ResolvedAlert) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
cloned := cloneResolvedAlerts(resolved)
|
|
if cloned == nil {
|
|
cloned = []ResolvedAlert{}
|
|
}
|
|
s.RecentlyResolved = cloned
|
|
}
|
|
|
|
// UpdateNodes updates the nodes in the state
|
|
func (s *State) UpdateNodes(nodes []Node) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
stateNodes := cloneNodes(nodes)
|
|
if stateNodes == nil {
|
|
stateNodes = []Node{}
|
|
}
|
|
|
|
// Sort nodes by name to ensure consistent ordering
|
|
sort.Slice(stateNodes, func(i, j int) bool {
|
|
return stateNodes[i].Name < stateNodes[j].Name
|
|
})
|
|
|
|
s.Nodes = stateNodes
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
func normalizeNodeIdentityPart(value string) string {
|
|
return strings.ToLower(strings.TrimSpace(value))
|
|
}
|
|
|
|
func normalizeIPAddress(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
if strings.Contains(value, "/") {
|
|
if ip, _, err := net.ParseCIDR(value); err == nil && ip != nil {
|
|
return ip.String()
|
|
}
|
|
value = strings.Split(value, "/")[0]
|
|
}
|
|
ip := net.ParseIP(value)
|
|
if ip == nil {
|
|
return ""
|
|
}
|
|
return ip.String()
|
|
}
|
|
|
|
func extractHostEndpoint(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
parsed, err := url.Parse(raw)
|
|
if err == nil && parsed.Host != "" {
|
|
return strings.ToLower(strings.TrimSpace(parsed.Hostname()))
|
|
}
|
|
if strings.Contains(raw, "/") {
|
|
raw = strings.Split(raw, "/")[0]
|
|
}
|
|
if host, port, err := net.SplitHostPort(raw); err == nil {
|
|
_ = port
|
|
return strings.ToLower(strings.TrimSpace(host))
|
|
}
|
|
return strings.ToLower(strings.Trim(strings.TrimSpace(raw), "[]"))
|
|
}
|
|
|
|
func shortHostname(value string) string {
|
|
value = strings.TrimSpace(strings.ToLower(value))
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
if idx := strings.IndexRune(value, '.'); idx > 0 {
|
|
return value[:idx]
|
|
}
|
|
return value
|
|
}
|
|
|
|
func nodeEndpointMergeAliases(node Node) []string {
|
|
name := normalizeNodeIdentityPart(node.Name)
|
|
if name == "" {
|
|
return nil
|
|
}
|
|
endpoint := extractHostEndpoint(node.Host)
|
|
if endpoint == "" {
|
|
return nil
|
|
}
|
|
if ip := normalizeIPAddress(endpoint); ip != "" {
|
|
return []string{"endpoint-ip:" + ip + ":" + name}
|
|
}
|
|
|
|
aliases := []string{"endpoint-host:" + endpoint + ":" + name}
|
|
if short := shortHostname(endpoint); short != "" && short != endpoint {
|
|
aliases = append(aliases, "endpoint-host:"+short+":"+name)
|
|
}
|
|
return aliases
|
|
}
|
|
|
|
func nodeLogicalKey(node Node) string {
|
|
if identity := normalizeNodeIdentityPart(node.NodeIdentity); identity != "" {
|
|
return "node-identity:" + identity
|
|
}
|
|
name := normalizeNodeIdentityPart(node.Name)
|
|
if name == "" {
|
|
return ""
|
|
}
|
|
|
|
if cluster := normalizeNodeIdentityPart(node.ClusterName); cluster != "" {
|
|
// Cluster names are display labels, not globally unique provider
|
|
// identities. Scope the primary key by connection instance and allow
|
|
// the stronger endpoint/link aliases below to merge duplicate views of
|
|
// the same physical node.
|
|
if instance := normalizeNodeIdentityPart(node.Instance); instance != "" {
|
|
return "cluster:" + cluster + ":instance:" + instance + ":" + name
|
|
}
|
|
return "cluster:" + cluster + ":" + name
|
|
}
|
|
if instance := normalizeNodeIdentityPart(node.Instance); instance != "" {
|
|
return "instance:" + instance + ":" + name
|
|
}
|
|
if host := normalizeNodeIdentityPart(node.Host); host != "" {
|
|
return "endpoint:" + host + ":" + name
|
|
}
|
|
|
|
return "name:" + name
|
|
}
|
|
|
|
func registerNodeAliases(
|
|
aliasToKey map[string]string,
|
|
ambiguousAliases map[string]struct{},
|
|
aliases []string,
|
|
key string,
|
|
) {
|
|
for _, alias := range aliases {
|
|
if alias == "" || key == "" {
|
|
continue
|
|
}
|
|
if _, ambiguous := ambiguousAliases[alias]; ambiguous {
|
|
continue
|
|
}
|
|
if existing, ok := aliasToKey[alias]; ok && existing != key {
|
|
delete(aliasToKey, alias)
|
|
ambiguousAliases[alias] = struct{}{}
|
|
continue
|
|
}
|
|
aliasToKey[alias] = key
|
|
}
|
|
}
|
|
|
|
// normalizeNodeTLSFingerprint reduces a TLS certificate fingerprint to a
|
|
// comparable form. Comparison-only: unknown stays empty, no validation.
|
|
func normalizeNodeTLSFingerprint(raw string) string {
|
|
normalized := strings.ToLower(strings.TrimSpace(raw))
|
|
normalized = strings.TrimPrefix(normalized, "sha256:")
|
|
normalized = strings.ReplaceAll(normalized, ":", "")
|
|
return strings.ReplaceAll(normalized, " ", "")
|
|
}
|
|
|
|
// nodeProviderIdentityConflicts reports whether two classified cluster nodes
|
|
// belong to different provider scopes. Cluster names are display labels and
|
|
// can legitimately repeat; two non-empty connection instances therefore stay
|
|
// distinct even when the label and member hostname are identical, unless
|
|
// matching TOFU-captured TLS fingerprints prove both views reach the same
|
|
// machine (the same cluster added twice). Contradicting or unknown
|
|
// fingerprints keep the fail-safe split: two distinct clusters must never be
|
|
// silently folded into one. Empty cluster identity still permits a
|
|
// standalone/not-yet-classified view to merge into its canonical cluster view
|
|
// by stronger endpoint or linked-agent proof.
|
|
func nodeProviderIdentityConflicts(a, b Node) bool {
|
|
clusterA := normalizeNodeIdentityPart(a.ClusterName)
|
|
clusterB := normalizeNodeIdentityPart(b.ClusterName)
|
|
if clusterA == "" || clusterB == "" {
|
|
return false
|
|
}
|
|
if clusterA != clusterB {
|
|
return true
|
|
}
|
|
instanceA := normalizeNodeIdentityPart(a.Instance)
|
|
instanceB := normalizeNodeIdentityPart(b.Instance)
|
|
if instanceA == "" || instanceB == "" || instanceA == instanceB {
|
|
return false
|
|
}
|
|
fpA := normalizeNodeTLSFingerprint(a.TLSFingerprint)
|
|
fpB := normalizeNodeTLSFingerprint(b.TLSFingerprint)
|
|
return fpA == "" || fpA != fpB
|
|
}
|
|
|
|
// nodeCrossViewMergeProven reports whether two node views carry positive
|
|
// same-machine evidence: the same connection instance, a matching non-empty
|
|
// cluster identity that survives the provider-conflict check, or matching
|
|
// TOFU-captured TLS fingerprints. Weak-evidence folds across instances - a
|
|
// bare-hostname endpoint alias or a shared agent identity - require this
|
|
// proof, because node names repeat across sites, private address ranges
|
|
// collide, and cloned template deployments reuse /etc/machine-id, which is
|
|
// exactly the identity host agents key on. A cluster connection's first polls
|
|
// can also land before membership detection classifies its nodes, and that
|
|
// empty-cluster-name window must not let another site's same-named node fold
|
|
// into (and overwrite) an established slot.
|
|
func nodeCrossViewMergeProven(a, b Node) bool {
|
|
if normalizeNodeIdentityPart(a.Instance) == normalizeNodeIdentityPart(b.Instance) {
|
|
return true
|
|
}
|
|
if nodeProviderIdentityConflicts(a, b) {
|
|
return false
|
|
}
|
|
clusterA := normalizeNodeIdentityPart(a.ClusterName)
|
|
clusterB := normalizeNodeIdentityPart(b.ClusterName)
|
|
if clusterA == "" && clusterB == "" {
|
|
// Two unclassified standalone views still dedup on the shared
|
|
// evidence alone: with no cluster identity in play there is no
|
|
// established cluster slot to steal.
|
|
return true
|
|
}
|
|
if clusterA == clusterB {
|
|
return true
|
|
}
|
|
fpA := normalizeNodeTLSFingerprint(a.TLSFingerprint)
|
|
fpB := normalizeNodeTLSFingerprint(b.TLSFingerprint)
|
|
return fpA != "" && fpA == fpB
|
|
}
|
|
|
|
func resolveNodeMergeKey(
|
|
primaryKey string,
|
|
node Node,
|
|
nodeMap map[string]Node,
|
|
aliasToKey map[string]string,
|
|
ambiguousAliases map[string]struct{},
|
|
) string {
|
|
if primaryKey == "" {
|
|
return ""
|
|
}
|
|
if _, ok := nodeMap[primaryKey]; ok {
|
|
return primaryKey
|
|
}
|
|
|
|
resolved := ""
|
|
for _, alias := range nodeEndpointMergeAliases(node) {
|
|
if _, ambiguous := ambiguousAliases[alias]; ambiguous {
|
|
continue
|
|
}
|
|
key, ok := aliasToKey[alias]
|
|
if !ok || key == "" {
|
|
continue
|
|
}
|
|
// Two clusters can reuse node names (pve01 in cluster A and cluster B),
|
|
// so an endpoint alias is only trustworthy when the cluster identities
|
|
// don't contradict each other. A hostname-based alias is weaker still -
|
|
// bare node names repeat across sites - so across instances it only
|
|
// folds with positive same-machine proof; an address-based alias keeps
|
|
// folding on the contradiction check alone so standalone views still
|
|
// reach their cluster node.
|
|
if existing, exists := nodeMap[key]; exists {
|
|
if nodeProviderIdentityConflicts(existing, node) {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(alias, "endpoint-host:") && !nodeCrossViewMergeProven(existing, node) {
|
|
continue
|
|
}
|
|
}
|
|
if resolved != "" && resolved != key {
|
|
return primaryKey
|
|
}
|
|
resolved = key
|
|
}
|
|
if resolved != "" {
|
|
return resolved
|
|
}
|
|
return primaryKey
|
|
}
|
|
|
|
func nodeStatusRank(status string) int {
|
|
switch normalizeNodeIdentityPart(status) {
|
|
case "online":
|
|
return 3
|
|
case "degraded":
|
|
return 2
|
|
case "unknown":
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func nodeConnectionHealthRank(health string) int {
|
|
switch normalizeNodeIdentityPart(health) {
|
|
case "healthy":
|
|
return 3
|
|
case "degraded":
|
|
return 2
|
|
case "unknown":
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func preferNodeForMerge(existing Node, candidate Node) Node {
|
|
existingScore := nodeStatusRank(existing.Status)*100 + nodeConnectionHealthRank(existing.ConnectionHealth)*10
|
|
if existing.LinkedAgentID != "" {
|
|
existingScore += 2
|
|
}
|
|
if existing.IsClusterMember {
|
|
existingScore++
|
|
}
|
|
|
|
candidateScore := nodeStatusRank(candidate.Status)*100 + nodeConnectionHealthRank(candidate.ConnectionHealth)*10
|
|
if candidate.LinkedAgentID != "" {
|
|
candidateScore += 2
|
|
}
|
|
if candidate.IsClusterMember {
|
|
candidateScore++
|
|
}
|
|
|
|
if candidateScore > existingScore {
|
|
return candidate
|
|
}
|
|
if candidateScore < existingScore {
|
|
return existing
|
|
}
|
|
|
|
if candidate.LastSeen.After(existing.LastSeen) {
|
|
return candidate
|
|
}
|
|
if existing.LastSeen.After(candidate.LastSeen) {
|
|
return existing
|
|
}
|
|
|
|
return existing
|
|
}
|
|
|
|
func reconcileHostNodeLinksLocked(hosts []Host, nodes []Node) {
|
|
linkedNodeByHostID := make(map[string]string)
|
|
multipleNodeLinksByHostID := make(map[string]struct{})
|
|
for _, node := range nodes {
|
|
hostID := strings.TrimSpace(node.LinkedAgentID)
|
|
nodeID := strings.TrimSpace(node.ID)
|
|
if hostID == "" || nodeID == "" {
|
|
continue
|
|
}
|
|
if existingNodeID, ok := linkedNodeByHostID[hostID]; ok && existingNodeID != nodeID {
|
|
multipleNodeLinksByHostID[hostID] = struct{}{}
|
|
continue
|
|
}
|
|
linkedNodeByHostID[hostID] = nodeID
|
|
}
|
|
|
|
for i := range hosts {
|
|
hostID := strings.TrimSpace(hosts[i].ID)
|
|
if hostID == "" {
|
|
continue
|
|
}
|
|
|
|
nodeID, hasLinkedNode := linkedNodeByHostID[hostID]
|
|
_, ambiguous := multipleNodeLinksByHostID[hostID]
|
|
switch {
|
|
case ambiguous:
|
|
hosts[i].LinkedNodeID = ""
|
|
case hasLinkedNode:
|
|
hosts[i].LinkedNodeID = nodeID
|
|
hosts[i].LinkedVMID = ""
|
|
hosts[i].LinkedContainerID = ""
|
|
case hosts[i].LinkedNodeID != "":
|
|
hosts[i].LinkedNodeID = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
// UpdateNodesForInstance updates nodes for a specific instance, merging with existing nodes
|
|
func (s *State) UpdateNodesForInstance(instanceName string, nodes []Node) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// Preserve LinkedAgentID for nodes that are being updated, including when IDs churn.
|
|
existingNodeLinks := make(map[string]string) // nodeID -> linkedAgentID
|
|
existingNodeLinksByLogicalKey := make(map[string]string) // logical node key -> linkedAgentID
|
|
agentClusterHints := make(map[string]map[string]struct{}) // linkedAgentID -> normalized cluster names of its linked nodes
|
|
agentFingerprintHints := make(map[string]map[string]struct{}) // linkedAgentID -> normalized TLS fingerprints of its linked nodes
|
|
for _, node := range s.Nodes {
|
|
if node.LinkedAgentID == "" {
|
|
continue
|
|
}
|
|
existingNodeLinks[node.ID] = node.LinkedAgentID
|
|
if key := nodeLogicalKey(node); key != "" {
|
|
existingNodeLinksByLogicalKey[key] = node.LinkedAgentID
|
|
}
|
|
if cluster := normalizeNodeIdentityPart(node.ClusterName); cluster != "" {
|
|
hints := agentClusterHints[node.LinkedAgentID]
|
|
if hints == nil {
|
|
hints = make(map[string]struct{})
|
|
agentClusterHints[node.LinkedAgentID] = hints
|
|
}
|
|
hints[cluster] = struct{}{}
|
|
}
|
|
if fingerprint := normalizeNodeTLSFingerprint(node.TLSFingerprint); fingerprint != "" {
|
|
hints := agentFingerprintHints[node.LinkedAgentID]
|
|
if hints == nil {
|
|
hints = make(map[string]struct{})
|
|
agentFingerprintHints[node.LinkedAgentID] = hints
|
|
}
|
|
hints[fingerprint] = struct{}{}
|
|
}
|
|
}
|
|
|
|
// Build map excluding nodes from this instance (they'll be replaced by the new set).
|
|
// Key by logical identity so nodes with different IDs but the same cluster+name merge.
|
|
nodeMap := make(map[string]Node)
|
|
nodeAliasToKey := make(map[string]string)
|
|
ambiguousNodeAliases := make(map[string]struct{})
|
|
linkedHostToKey := make(map[string]string)
|
|
ambiguousLinkedHosts := make(map[string]struct{})
|
|
registerLinkedHostKey := func(hostID, key string) {
|
|
hostID = strings.TrimSpace(hostID)
|
|
key = strings.TrimSpace(key)
|
|
if hostID == "" || key == "" {
|
|
return
|
|
}
|
|
if _, ambiguous := ambiguousLinkedHosts[hostID]; ambiguous {
|
|
return
|
|
}
|
|
if existing, ok := linkedHostToKey[hostID]; ok && existing != key {
|
|
delete(linkedHostToKey, hostID)
|
|
ambiguousLinkedHosts[hostID] = struct{}{}
|
|
return
|
|
}
|
|
linkedHostToKey[hostID] = key
|
|
}
|
|
for _, node := range s.Nodes {
|
|
if node.Instance == instanceName {
|
|
continue
|
|
}
|
|
|
|
key := nodeLogicalKey(node)
|
|
if key == "" {
|
|
key = "id:" + normalizeNodeIdentityPart(node.ID)
|
|
}
|
|
if key == "id:" {
|
|
key = "instance:" + normalizeNodeIdentityPart(node.Instance) + ":" + normalizeNodeIdentityPart(node.Name)
|
|
}
|
|
|
|
if existing, ok := nodeMap[key]; ok {
|
|
nodeMap[key] = preferNodeForMerge(existing, node)
|
|
} else {
|
|
nodeMap[key] = node
|
|
}
|
|
registerNodeAliases(nodeAliasToKey, ambiguousNodeAliases, nodeEndpointMergeAliases(node), key)
|
|
registerLinkedHostKey(node.LinkedAgentID, key)
|
|
}
|
|
|
|
// Deduplicate incoming nodes by logical identity so a single node cannot appear
|
|
// multiple times in the same poll cycle with different IDs.
|
|
dedupedNodes := make(map[string]Node)
|
|
for _, node := range nodes {
|
|
node = cloneNode(node)
|
|
key := nodeLogicalKey(node)
|
|
if key == "" {
|
|
key = "id:" + normalizeNodeIdentityPart(node.ID)
|
|
}
|
|
if key == "id:" {
|
|
key = "instance:" + normalizeNodeIdentityPart(instanceName) + ":" + normalizeNodeIdentityPart(node.Name)
|
|
}
|
|
|
|
if existing, ok := dedupedNodes[key]; ok {
|
|
dedupedNodes[key] = preferNodeForMerge(existing, node)
|
|
continue
|
|
}
|
|
dedupedNodes[key] = node
|
|
}
|
|
|
|
// Build hostname-to-hostAgentID map for linking new nodes to existing host agents.
|
|
// Keep all candidates so we can avoid creating links when hostname matching is ambiguous.
|
|
// Also build a set of valid host agent IDs to validate existing links.
|
|
hostAgentByHostname := make(map[string]map[string]struct{}) // lowercase hostname -> hostAgentIDs
|
|
hostAgentByIP := make(map[string]map[string]struct{}) // normalized ip -> hostAgentIDs
|
|
validHostAgentIDs := make(map[string]bool) // set of existing host agent IDs
|
|
addHostAlias := func(name, hostID string) {
|
|
name = strings.TrimSpace(strings.ToLower(name))
|
|
if name == "" || hostID == "" {
|
|
return
|
|
}
|
|
bucket := hostAgentByHostname[name]
|
|
if bucket == nil {
|
|
bucket = make(map[string]struct{})
|
|
hostAgentByHostname[name] = bucket
|
|
}
|
|
bucket[hostID] = struct{}{}
|
|
}
|
|
hostIPsByID := make(map[string]map[string]struct{}) // hostAgentID -> normalized ips it reports
|
|
addHostIP := func(address, hostID string) {
|
|
ip := normalizeIPAddress(address)
|
|
if ip == "" || hostID == "" {
|
|
return
|
|
}
|
|
bucket := hostAgentByIP[ip]
|
|
if bucket == nil {
|
|
bucket = make(map[string]struct{})
|
|
hostAgentByIP[ip] = bucket
|
|
}
|
|
bucket[hostID] = struct{}{}
|
|
ips := hostIPsByID[hostID]
|
|
if ips == nil {
|
|
ips = make(map[string]struct{})
|
|
hostIPsByID[hostID] = ips
|
|
}
|
|
ips[ip] = struct{}{}
|
|
}
|
|
for _, host := range s.Hosts {
|
|
if host.ID != "" {
|
|
validHostAgentIDs[host.ID] = true
|
|
addHostAlias(host.Hostname, host.ID)
|
|
// Also index by short hostname
|
|
if idx := strings.Index(host.Hostname, "."); idx > 0 {
|
|
addHostAlias(host.Hostname[:idx], host.ID)
|
|
}
|
|
addHostIP(host.ReportIP, host.ID)
|
|
for _, iface := range host.NetworkInterfaces {
|
|
for _, address := range iface.Addresses {
|
|
addHostIP(address, host.ID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hostname and address matches are weak evidence: two clusters can reuse
|
|
// the same node names, and sites that reuse RFC1918 ranges can present the
|
|
// same endpoint IP for different machines. Reject a candidate agent when
|
|
// the evidence we do hold contradicts the node - the agent's linked nodes
|
|
// live in a different named cluster, carry a different TLS certificate
|
|
// fingerprint, or the node's endpoint IP is absent from the IPs the agent
|
|
// reports.
|
|
hostContradictsNode := func(hostID string, node Node) bool {
|
|
if hints := agentClusterHints[hostID]; len(hints) > 0 {
|
|
if cluster := normalizeNodeIdentityPart(node.ClusterName); cluster != "" {
|
|
if _, sameCluster := hints[cluster]; !sameCluster {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
if fingerprint := normalizeNodeTLSFingerprint(node.TLSFingerprint); fingerprint != "" {
|
|
if hints := agentFingerprintHints[hostID]; len(hints) > 0 {
|
|
if _, sameMachine := hints[fingerprint]; !sameMachine {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
if nodeIP := normalizeIPAddress(extractHostEndpoint(node.Host)); nodeIP != "" {
|
|
if ips := hostIPsByID[hostID]; len(ips) > 0 {
|
|
if _, known := ips[nodeIP]; !known {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Add or update nodes from this instance
|
|
for _, node := range dedupedNodes {
|
|
// Preserve existing link if we had one, but only if the host agent still exists
|
|
if existingLink, ok := existingNodeLinks[node.ID]; ok {
|
|
if validHostAgentIDs[existingLink] {
|
|
node.LinkedAgentID = existingLink
|
|
}
|
|
// If host agent no longer exists, leave LinkedAgentID empty (stale reference cleared)
|
|
}
|
|
// Fallback: preserve link by logical identity when node IDs changed across polls.
|
|
if node.LinkedAgentID == "" {
|
|
if existingLink, ok := existingNodeLinksByLogicalKey[nodeLogicalKey(node)]; ok {
|
|
if validHostAgentIDs[existingLink] {
|
|
node.LinkedAgentID = existingLink
|
|
}
|
|
}
|
|
}
|
|
endpointCandidateCount := 0
|
|
endpointCandidates := make(map[string]struct{})
|
|
if node.LinkedAgentID == "" {
|
|
endpoint := extractHostEndpoint(node.Host)
|
|
if ip := normalizeIPAddress(endpoint); ip != "" {
|
|
if ids, ok := hostAgentByIP[ip]; ok {
|
|
// An address match alone is not machine identity across
|
|
// sites that reuse RFC1918 ranges: the cluster and
|
|
// fingerprint evidence must not contradict.
|
|
for hostID := range ids {
|
|
if !hostContradictsNode(hostID, node) {
|
|
endpointCandidates[hostID] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
} else if endpoint != "" {
|
|
if ids, ok := hostAgentByHostname[strings.ToLower(endpoint)]; ok {
|
|
for hostID := range ids {
|
|
if !hostContradictsNode(hostID, node) {
|
|
endpointCandidates[hostID] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
if short := shortHostname(endpoint); short != "" && short != strings.ToLower(endpoint) {
|
|
if ids, ok := hostAgentByHostname[short]; ok {
|
|
for hostID := range ids {
|
|
if !hostContradictsNode(hostID, node) {
|
|
endpointCandidates[hostID] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
endpointCandidateCount = len(endpointCandidates)
|
|
if endpointCandidateCount == 1 {
|
|
for hostID := range endpointCandidates {
|
|
node.LinkedAgentID = hostID
|
|
}
|
|
}
|
|
}
|
|
// If no existing link and endpoint evidence was absent, try to match by hostname.
|
|
if node.LinkedAgentID == "" && endpointCandidateCount == 0 {
|
|
nodeName := normalizeNodeIdentityPart(node.Name)
|
|
candidates := make(map[string]struct{})
|
|
if nodeName != "" {
|
|
if ids, ok := hostAgentByHostname[nodeName]; ok {
|
|
for hostID := range ids {
|
|
if !hostContradictsNode(hostID, node) {
|
|
candidates[hostID] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
if idx := strings.Index(nodeName, "."); idx > 0 {
|
|
if ids, ok := hostAgentByHostname[nodeName[:idx]]; ok {
|
|
for hostID := range ids {
|
|
if !hostContradictsNode(hostID, node) {
|
|
candidates[hostID] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if len(candidates) == 1 {
|
|
for hostID := range candidates {
|
|
node.LinkedAgentID = hostID
|
|
}
|
|
}
|
|
}
|
|
|
|
primaryKey := nodeLogicalKey(node)
|
|
targetKey := resolveNodeMergeKey(primaryKey, node, nodeMap, nodeAliasToKey, ambiguousNodeAliases)
|
|
if node.LinkedAgentID != "" {
|
|
if _, ambiguous := ambiguousLinkedHosts[node.LinkedAgentID]; !ambiguous {
|
|
if linkedKey := linkedHostToKey[node.LinkedAgentID]; linkedKey != "" {
|
|
// A shared agent identity must never collapse two different
|
|
// named clusters into one node slot - and because agents
|
|
// key on /etc/machine-id, which cloned deployments reuse
|
|
// across unrelated machines, a cross-instance fold needs
|
|
// positive same-machine proof, not just the absence of a
|
|
// contradiction.
|
|
if existing, ok := nodeMap[linkedKey]; !ok || nodeCrossViewMergeProven(existing, node) {
|
|
targetKey = linkedKey
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if targetKey == "" {
|
|
targetKey = "id:" + normalizeNodeIdentityPart(node.ID)
|
|
}
|
|
if targetKey == "id:" {
|
|
targetKey = "instance:" + normalizeNodeIdentityPart(instanceName) + ":" + normalizeNodeIdentityPart(node.Name)
|
|
}
|
|
if node.LinkedAgentID == "" {
|
|
if existing, ok := nodeMap[targetKey]; ok {
|
|
if validHostAgentIDs[existing.LinkedAgentID] {
|
|
node.LinkedAgentID = existing.LinkedAgentID
|
|
}
|
|
}
|
|
}
|
|
|
|
if existing, ok := nodeMap[targetKey]; ok {
|
|
nodeMap[targetKey] = preferNodeForMerge(existing, node)
|
|
} else {
|
|
nodeMap[targetKey] = node
|
|
}
|
|
registerNodeAliases(nodeAliasToKey, ambiguousNodeAliases, nodeEndpointMergeAliases(node), targetKey)
|
|
registerLinkedHostKey(node.LinkedAgentID, targetKey)
|
|
}
|
|
|
|
// Convert map back to slice
|
|
newNodes := make([]Node, 0, len(nodeMap))
|
|
for _, node := range nodeMap {
|
|
newNodes = append(newNodes, node)
|
|
}
|
|
|
|
// Sort nodes by name to ensure consistent ordering
|
|
sort.Slice(newNodes, func(i, j int) bool {
|
|
return newNodes[i].Name < newNodes[j].Name
|
|
})
|
|
|
|
reconcileHostNodeLinksLocked(s.Hosts, newNodes)
|
|
|
|
s.Nodes = newNodes
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// UpdateVMs updates the VMs in the state
|
|
func (s *State) UpdateVMs(vms []VM) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
cloned := cloneVMs(vms)
|
|
if cloned == nil {
|
|
cloned = []VM{}
|
|
}
|
|
s.VMs = cloned
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
func updateSliceByInstanceWithBackup[T any, K int | int64](
|
|
existing []T,
|
|
newItems []T,
|
|
instanceName string,
|
|
getID func(T) string,
|
|
getInstance func(T) string,
|
|
getVMID func(T) K,
|
|
getLastBackup func(T) time.Time,
|
|
setLastBackup func(T, time.Time) T,
|
|
clone func(T) T,
|
|
less func([]T, int, int) bool,
|
|
) []T {
|
|
existingByVMID := make(map[K]T)
|
|
for _, item := range existing {
|
|
if getInstance(item) == instanceName {
|
|
existingByVMID[getVMID(item)] = item
|
|
}
|
|
}
|
|
|
|
itemMap := make(map[string]T)
|
|
for _, item := range existing {
|
|
if getInstance(item) != instanceName {
|
|
itemMap[getID(item)] = item
|
|
}
|
|
}
|
|
for _, item := range newItems {
|
|
item = clone(item)
|
|
if existing, ok := existingByVMID[getVMID(item)]; ok && getLastBackup(item).IsZero() {
|
|
item = setLastBackup(item, getLastBackup(existing))
|
|
}
|
|
itemMap[getID(item)] = item
|
|
}
|
|
result := make([]T, 0, len(itemMap))
|
|
for _, item := range itemMap {
|
|
result = append(result, item)
|
|
}
|
|
sort.Slice(result, func(i, j int) bool { return less(result, i, j) })
|
|
return result
|
|
}
|
|
|
|
func updateSliceForInstance[T any, K int | int64](
|
|
s *State,
|
|
slicePtr *[]T,
|
|
newItems []T,
|
|
instanceName string,
|
|
getID func(T) string,
|
|
getInstance func(T) string,
|
|
getVMID func(T) K,
|
|
getLastBackup func(T) time.Time,
|
|
setLastBackup func(T, time.Time) T,
|
|
clone func(T) T,
|
|
less func([]T, int, int) bool,
|
|
) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
*slicePtr = updateSliceByInstanceWithBackup(
|
|
*slicePtr, newItems, instanceName,
|
|
getID, getInstance, getVMID, getLastBackup, setLastBackup, clone, less,
|
|
)
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// UpdateVMsForInstance updates VMs for a specific instance, merging with existing VMs
|
|
func (s *State) UpdateVMsForInstance(instanceName string, vms []VM) {
|
|
updateSliceForInstance(
|
|
s, &s.VMs, vms, instanceName,
|
|
func(vm VM) string { return vm.ID },
|
|
func(vm VM) string { return vm.Instance },
|
|
func(vm VM) int { return vm.VMID },
|
|
func(vm VM) time.Time { return vm.LastBackup },
|
|
func(vm VM, t time.Time) VM { vm.LastBackup = t; return vm },
|
|
cloneVM,
|
|
func(items []VM, i, j int) bool { return items[i].VMID < items[j].VMID },
|
|
)
|
|
}
|
|
|
|
// UpdateContainers updates the containers in the state
|
|
func (s *State) UpdateContainers(containers []Container) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
cloned := cloneContainers(containers)
|
|
if cloned == nil {
|
|
cloned = []Container{}
|
|
}
|
|
s.Containers = cloned
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// backupKey creates a composite key for backup matching using instance and VMID.
|
|
// This ensures backups are correctly matched to guests even when VMIDs are reused across instances.
|
|
func backupKey(instance string, vmid int) string {
|
|
return instance + "-" + strconv.Itoa(vmid)
|
|
}
|
|
|
|
// UpdatePBSGuestConfirmationsForInstance replaces the PVE-side PBS snapshot
|
|
// evidence for one connection. Pass an empty slice when the connection's
|
|
// storage poll saw no pbs-type storage content so stale evidence cannot
|
|
// outlive the snapshots it described.
|
|
func (s *State) UpdatePBSGuestConfirmationsForInstance(instanceName string, confirmations []PBSGuestConfirmation) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if s.pbsGuestConfirmations == nil {
|
|
s.pbsGuestConfirmations = make(map[string]map[PBSGuestConfirmation]struct{})
|
|
}
|
|
if len(confirmations) == 0 {
|
|
delete(s.pbsGuestConfirmations, instanceName)
|
|
return
|
|
}
|
|
set := make(map[PBSGuestConfirmation]struct{}, len(confirmations))
|
|
for _, confirmation := range confirmations {
|
|
confirmation.BackupType = strings.ToLower(strings.TrimSpace(confirmation.BackupType))
|
|
confirmation.Storage = strings.TrimSpace(confirmation.Storage)
|
|
if confirmation.Storage == "" {
|
|
// Without the originating storage the evidence cannot be tested
|
|
// for exclusivity or preserved across a partial poll failure.
|
|
continue
|
|
}
|
|
if (confirmation.BackupType != "vm" && confirmation.BackupType != "ct") || confirmation.VMID <= 0 || confirmation.Time <= 0 {
|
|
continue
|
|
}
|
|
set[confirmation] = struct{}{}
|
|
}
|
|
if len(set) == 0 {
|
|
delete(s.pbsGuestConfirmations, instanceName)
|
|
return
|
|
}
|
|
s.pbsGuestConfirmations[instanceName] = set
|
|
}
|
|
|
|
// PBSGuestConfirmationsForInstance returns the PVE-side PBS snapshot evidence
|
|
// currently held for one connection. Callers use it to carry forward the
|
|
// evidence of storages whose content query failed this cycle, so a partial
|
|
// poll failure cannot evict attribution and flip guests between cycles
|
|
// (#1639).
|
|
func (s *State) PBSGuestConfirmationsForInstance(instanceName string) []PBSGuestConfirmation {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
set, ok := s.pbsGuestConfirmations[instanceName]
|
|
if !ok || len(set) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]PBSGuestConfirmation, 0, len(set))
|
|
for confirmation := range set {
|
|
out = append(out, confirmation)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Storage != out[j].Storage {
|
|
return out[i].Storage < out[j].Storage
|
|
}
|
|
if out[i].BackupType != out[j].BackupType {
|
|
return out[i].BackupType < out[j].BackupType
|
|
}
|
|
if out[i].VMID != out[j].VMID {
|
|
return out[i].VMID < out[j].VMID
|
|
}
|
|
return out[i].Time < out[j].Time
|
|
})
|
|
return out
|
|
}
|
|
|
|
// SyncGuestBackupTimes updates LastBackup on VMs and Containers from storage backups and PBS backups.
|
|
// Call this after updating storage backups or PBS backups to ensure guest backup indicators are accurate.
|
|
// Matching is done by instance+VMID to prevent cross-instance VMID collisions.
|
|
// For PBS backups with namespaces, namespace matching is ranked against the guest's node and
|
|
// connection instance so clustered API entrypoints do not shadow the guest's actual placement.
|
|
func (s *State) SyncGuestBackupTimes() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
type pbsSubjectKey struct {
|
|
backupType string
|
|
vmid int
|
|
}
|
|
|
|
// Build a map of instance+VMID -> latest backup time from all backup sources
|
|
// Using composite key prevents cross-instance VMID collision issues
|
|
latestBackup := make(map[string]time.Time)
|
|
|
|
// Process PVE storage backups
|
|
for _, backup := range s.PVEBackups.StorageBackups {
|
|
if backup.VMID <= 0 || backup.Instance == "" {
|
|
continue
|
|
}
|
|
key := backupKey(backup.Instance, backup.VMID)
|
|
if existing, ok := latestBackup[key]; !ok || backup.Time.After(existing) {
|
|
latestBackup[key] = backup.Time
|
|
}
|
|
}
|
|
|
|
// Process PBS backups (VMID is string, BackupTime is the timestamp).
|
|
// Group by PBS subject type and VMID so VM and CT IDs do not cross-match.
|
|
pbsBackupsBySubject := make(map[pbsSubjectKey][]PBSBackup)
|
|
for _, backup := range s.PBSBackups {
|
|
vmid, err := strconv.Atoi(backup.VMID)
|
|
if err != nil || vmid <= 0 {
|
|
continue
|
|
}
|
|
backupType := strings.ToLower(strings.TrimSpace(backup.BackupType))
|
|
if backupType != "vm" && backupType != "ct" {
|
|
continue
|
|
}
|
|
key := pbsSubjectKey{backupType: backupType, vmid: vmid}
|
|
pbsBackupsBySubject[key] = append(pbsBackupsBySubject[key], backup)
|
|
}
|
|
|
|
// Build a set of typed VMIDs that appear on more than one PVE location.
|
|
// When a typed VMID is ambiguous, we must not fall back to VMID-only matching
|
|
// because we can't tell which guest the backup belongs to.
|
|
type pbsSubjectGuest struct {
|
|
instance string
|
|
node string
|
|
name string
|
|
}
|
|
subjectLocations := make(map[pbsSubjectKey]map[string]struct{})
|
|
subjectGuests := make(map[pbsSubjectKey][]pbsSubjectGuest)
|
|
for i := range s.VMs {
|
|
key := pbsSubjectKey{backupType: "vm", vmid: s.VMs[i].VMID}
|
|
m, ok := subjectLocations[key]
|
|
if !ok {
|
|
m = make(map[string]struct{})
|
|
subjectLocations[key] = m
|
|
}
|
|
m[backupKey(s.VMs[i].Instance, s.VMs[i].VMID)+"@"+s.VMs[i].Node] = struct{}{}
|
|
subjectGuests[key] = append(subjectGuests[key], pbsSubjectGuest{instance: s.VMs[i].Instance, node: s.VMs[i].Node, name: s.VMs[i].Name})
|
|
}
|
|
for i := range s.Containers {
|
|
key := pbsSubjectKey{backupType: "ct", vmid: s.Containers[i].VMID}
|
|
m, ok := subjectLocations[key]
|
|
if !ok {
|
|
m = make(map[string]struct{})
|
|
subjectLocations[key] = m
|
|
}
|
|
m[backupKey(s.Containers[i].Instance, s.Containers[i].VMID)+"@"+s.Containers[i].Node] = struct{}{}
|
|
subjectGuests[key] = append(subjectGuests[key], pbsSubjectGuest{instance: s.Containers[i].Instance, node: s.Containers[i].Node, name: s.Containers[i].Name})
|
|
}
|
|
subjectIsAmbiguous := make(map[pbsSubjectKey]bool)
|
|
for key, locations := range subjectLocations {
|
|
if len(locations) > 1 {
|
|
subjectIsAmbiguous[key] = true
|
|
}
|
|
}
|
|
|
|
// PVE-side confirmations say a connection's own pbs-type storage listed a
|
|
// snapshot. That is visibility, not authorship: a shared token, a synced
|
|
// datastore, or an offsite copy all make another cluster's snapshots
|
|
// appear in this cluster's listing. Only a storage view that never lists
|
|
// a snapshot some other connection also lists can be treated as
|
|
// authorship evidence; an overlapping view has demonstrated it sees other
|
|
// clusters' snapshots, so nothing it lists attributes anything (#1639).
|
|
type pbsSnapshotKey struct {
|
|
backupType string
|
|
vmid int
|
|
unixTime int64
|
|
}
|
|
type pbsStorageView struct {
|
|
instance string
|
|
storage string
|
|
}
|
|
viewsBySnapshot := make(map[pbsSnapshotKey][]pbsStorageView)
|
|
for instanceName, confirmations := range s.pbsGuestConfirmations {
|
|
for confirmation := range confirmations {
|
|
key := pbsSnapshotKey{backupType: confirmation.BackupType, vmid: confirmation.VMID, unixTime: confirmation.Time}
|
|
viewsBySnapshot[key] = append(viewsBySnapshot[key], pbsStorageView{instance: instanceName, storage: confirmation.Storage})
|
|
}
|
|
}
|
|
sharedViews := make(map[pbsStorageView]struct{})
|
|
for _, views := range viewsBySnapshot {
|
|
for i := range views {
|
|
for j := i + 1; j < len(views); j++ {
|
|
if views[i].instance == views[j].instance {
|
|
continue
|
|
}
|
|
sharedViews[views[i]] = struct{}{}
|
|
sharedViews[views[j]] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
// exclusiveConfirmer names the connection whose non-overlapping storage
|
|
// view listed a snapshot. A snapshot two connections both list makes both
|
|
// their views overlapping, so it has no exclusive confirmer at all.
|
|
exclusiveConfirmer := make(map[pbsSnapshotKey]string)
|
|
for key, views := range viewsBySnapshot {
|
|
exclusive := make(map[string]struct{}, len(views))
|
|
for _, view := range views {
|
|
if _, shared := sharedViews[view]; !shared {
|
|
exclusive[view.instance] = struct{}{}
|
|
}
|
|
}
|
|
if len(exclusive) == 1 {
|
|
for instanceName := range exclusive {
|
|
exclusiveConfirmer[key] = instanceName
|
|
}
|
|
}
|
|
}
|
|
|
|
// Learn which PVE connection each PBS submission source (owner token,
|
|
// datastore, PBS instance) belongs to from the backups that CAN be
|
|
// attributed — unique typed VMIDs, or placement/name matches. The
|
|
// learned mapping then resolves collision-VMID snapshots that carry no
|
|
// namespace or comment evidence of their own (#1639).
|
|
sourceLearner := proxmoxidentity.NewPBSSourceLearner()
|
|
for key, backups := range pbsBackupsBySubject {
|
|
guests := subjectGuests[key]
|
|
if len(guests) == 0 {
|
|
continue
|
|
}
|
|
for _, backup := range backups {
|
|
for _, guest := range guests {
|
|
sourceLearner.RegisterCandidate(backup.Instance, guest.instance)
|
|
}
|
|
}
|
|
}
|
|
for key, backups := range pbsBackupsBySubject {
|
|
guests := subjectGuests[key]
|
|
if len(guests) == 0 {
|
|
continue
|
|
}
|
|
for _, backup := range backups {
|
|
attributed := ""
|
|
if !subjectIsAmbiguous[key] {
|
|
attributed = guests[0].instance
|
|
} else {
|
|
matched := make(map[string]struct{})
|
|
for _, guest := range guests {
|
|
if proxmoxidentity.BackupGuestMatchScore(backup.Namespace, backup.Comment, backup.VMID, guest.name, guest.instance, guest.node) > 0 {
|
|
matched[guest.instance] = struct{}{}
|
|
}
|
|
}
|
|
if len(matched) == 1 {
|
|
for instance := range matched {
|
|
attributed = instance
|
|
}
|
|
}
|
|
if attributed == "" {
|
|
// An exclusive storage view is positive attribution too,
|
|
// and it makes the cluster behind it visible to the
|
|
// coverage check.
|
|
attributed = exclusiveConfirmer[pbsSnapshotKey{backupType: key.backupType, vmid: key.vmid, unixTime: backup.BackupTime.Unix()}]
|
|
}
|
|
}
|
|
if attributed != "" {
|
|
sourceLearner.Observe(backup.Instance, backup.Datastore, backup.Owner, attributed)
|
|
}
|
|
}
|
|
}
|
|
|
|
// findBestPBSBackup finds the best PBS backup for a given typed VMID and guest location.
|
|
// Placement and guest-name matches are preferred over VMID-only fallback.
|
|
// Returns zero time if no suitable backup found.
|
|
findBestPBSBackup := func(vmid int, backupType string, instance string, node string, name string) time.Time {
|
|
subjectKey := pbsSubjectKey{backupType: backupType, vmid: vmid}
|
|
backups, ok := pbsBackupsBySubject[subjectKey]
|
|
if !ok || len(backups) == 0 {
|
|
return time.Time{}
|
|
}
|
|
|
|
var bestTime time.Time
|
|
bestScore := -1
|
|
|
|
for _, backup := range backups {
|
|
score := proxmoxidentity.BackupGuestMatchScore(
|
|
backup.Namespace,
|
|
backup.Comment,
|
|
backup.VMID,
|
|
name,
|
|
instance,
|
|
node,
|
|
)
|
|
snapshotKey := pbsSnapshotKey{backupType: backupType, vmid: vmid, unixTime: backup.BackupTime.Unix()}
|
|
if score == 0 {
|
|
if !subjectIsAmbiguous[subjectKey] {
|
|
score = 1
|
|
} else {
|
|
// Two independent evidence paths, neither authoritative
|
|
// over the other. An exclusive storage view (one no other
|
|
// connection's listing overlaps) is authorship evidence;
|
|
// the learned submission-source mapping is inference from
|
|
// the batch's attributable snapshots. Where both speak
|
|
// they must agree, otherwise the snapshot stays dropped
|
|
// as it did before #1639.
|
|
confirmer, confirmed := exclusiveConfirmer[snapshotKey]
|
|
attributed, learned := sourceLearner.Resolve(backup.Instance, backup.Datastore, backup.Owner)
|
|
switch {
|
|
case confirmed && learned:
|
|
if confirmer == instance && attributed == instance {
|
|
score = 1
|
|
}
|
|
case confirmed:
|
|
if confirmer == instance {
|
|
score = 1
|
|
}
|
|
case learned:
|
|
if attributed == instance {
|
|
score = 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if score <= 0 {
|
|
continue
|
|
}
|
|
if score > bestScore || (score == bestScore && backup.BackupTime.After(bestTime)) {
|
|
bestScore = score
|
|
bestTime = backup.BackupTime
|
|
}
|
|
}
|
|
|
|
return bestTime
|
|
}
|
|
|
|
// Update VMs - recompute from current backup evidence instead of preserving stale values
|
|
for i := range s.VMs {
|
|
var lastBackup time.Time
|
|
key := backupKey(s.VMs[i].Instance, s.VMs[i].VMID)
|
|
if backupTime, ok := latestBackup[key]; ok {
|
|
lastBackup = backupTime
|
|
}
|
|
// Check if PBS has a more recent backup
|
|
pbsTime := findBestPBSBackup(s.VMs[i].VMID, "vm", s.VMs[i].Instance, s.VMs[i].Node, s.VMs[i].Name)
|
|
if !pbsTime.IsZero() {
|
|
if lastBackup.IsZero() || pbsTime.After(lastBackup) {
|
|
lastBackup = pbsTime
|
|
}
|
|
}
|
|
s.VMs[i].LastBackup = lastBackup
|
|
}
|
|
|
|
// Update Containers - recompute from current backup evidence instead of preserving stale values
|
|
for i := range s.Containers {
|
|
var lastBackup time.Time
|
|
key := backupKey(s.Containers[i].Instance, s.Containers[i].VMID)
|
|
if backupTime, ok := latestBackup[key]; ok {
|
|
lastBackup = backupTime
|
|
}
|
|
// Check if PBS has a more recent backup
|
|
pbsTime := findBestPBSBackup(s.Containers[i].VMID, "ct", s.Containers[i].Instance, s.Containers[i].Node, s.Containers[i].Name)
|
|
if !pbsTime.IsZero() {
|
|
if lastBackup.IsZero() || pbsTime.After(lastBackup) {
|
|
lastBackup = pbsTime
|
|
}
|
|
}
|
|
s.Containers[i].LastBackup = lastBackup
|
|
}
|
|
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// UpdateContainersForInstance updates containers for a specific instance, merging with existing containers
|
|
func (s *State) UpdateContainersForInstance(instanceName string, containers []Container) {
|
|
updateSliceForInstance(
|
|
s, &s.Containers, containers, instanceName,
|
|
func(ct Container) string { return ct.ID },
|
|
func(ct Container) string { return ct.Instance },
|
|
func(ct Container) int { return ct.VMID },
|
|
func(ct Container) time.Time { return ct.LastBackup },
|
|
func(ct Container, t time.Time) Container { ct.LastBackup = t; return ct },
|
|
cloneContainer,
|
|
func(items []Container, i, j int) bool { return items[i].VMID < items[j].VMID },
|
|
)
|
|
}
|
|
|
|
// UpsertDockerHost inserts or updates a Docker host in state.
|
|
func (s *State) UpsertDockerHost(host DockerHost) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
host = cloneDockerHost(host)
|
|
|
|
updated := false
|
|
for i, existing := range s.DockerHosts {
|
|
if existing.ID == host.ID {
|
|
// Preserve custom display name if it was set
|
|
if existing.CustomDisplayName != "" {
|
|
host.CustomDisplayName = existing.CustomDisplayName
|
|
}
|
|
// Preserve Hidden and PendingUninstall flags
|
|
host.Hidden = existing.Hidden
|
|
host.PendingUninstall = existing.PendingUninstall
|
|
// Preserve Command if it exists
|
|
if existing.Command != nil {
|
|
host.Command = cloneDockerHostCommandStatus(existing.Command)
|
|
}
|
|
s.DockerHosts[i] = host
|
|
updated = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !updated {
|
|
s.DockerHosts = append(s.DockerHosts, host)
|
|
}
|
|
|
|
sort.Slice(s.DockerHosts, func(i, j int) bool {
|
|
return s.DockerHosts[i].Hostname < s.DockerHosts[j].Hostname
|
|
})
|
|
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// RemoveDockerHost removes a docker host by ID and returns the removed host.
|
|
func (s *State) RemoveDockerHost(hostID string) (DockerHost, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, host := range s.DockerHosts {
|
|
if host.ID == hostID {
|
|
// Remove the host while preserving slice order
|
|
s.DockerHosts = append(s.DockerHosts[:i], s.DockerHosts[i+1:]...)
|
|
s.LastUpdate = time.Now()
|
|
return cloneDockerHost(host), true
|
|
}
|
|
}
|
|
|
|
return DockerHost{}, false
|
|
}
|
|
|
|
// ClearAllDockerHosts removes all docker hosts from the state.
|
|
// This is used during initial security setup to clear any docker hosts that may have
|
|
// connected during the brief unauthenticated window before credentials were configured.
|
|
func (s *State) ClearAllDockerHosts() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
count := len(s.DockerHosts)
|
|
s.DockerHosts = []DockerHost{}
|
|
s.LastUpdate = time.Now()
|
|
return count
|
|
}
|
|
|
|
// SetDockerHostStatus updates the status of a docker host if present.
|
|
func (s *State) SetDockerHostStatus(hostID, status string) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
changed := false
|
|
for i, host := range s.DockerHosts {
|
|
if host.ID == hostID {
|
|
if host.Status != status {
|
|
host.Status = status
|
|
s.DockerHosts[i] = host
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
changed = true
|
|
break
|
|
}
|
|
}
|
|
|
|
return changed
|
|
}
|
|
|
|
// SetDockerHostHidden updates the hidden status of a docker host and returns the updated host.
|
|
func (s *State) SetDockerHostHidden(hostID string, hidden bool) (DockerHost, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, host := range s.DockerHosts {
|
|
if host.ID == hostID {
|
|
host.Hidden = hidden
|
|
s.DockerHosts[i] = host
|
|
s.LastUpdate = time.Now()
|
|
return cloneDockerHost(host), true
|
|
}
|
|
}
|
|
|
|
return DockerHost{}, false
|
|
}
|
|
|
|
// SetDockerHostPendingUninstall updates the pending uninstall status of a docker host and returns the updated host.
|
|
func (s *State) SetDockerHostPendingUninstall(hostID string, pending bool) (DockerHost, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, host := range s.DockerHosts {
|
|
if host.ID == hostID {
|
|
host.PendingUninstall = pending
|
|
s.DockerHosts[i] = host
|
|
s.LastUpdate = time.Now()
|
|
return cloneDockerHost(host), true
|
|
}
|
|
}
|
|
|
|
return DockerHost{}, false
|
|
}
|
|
|
|
// SetDockerHostCommand updates the active command status for a docker host.
|
|
func (s *State) SetDockerHostCommand(hostID string, command *DockerHostCommandStatus) (DockerHost, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
command = cloneDockerHostCommandStatus(command)
|
|
|
|
for i, host := range s.DockerHosts {
|
|
if host.ID == hostID {
|
|
host.Command = command
|
|
s.DockerHosts[i] = host
|
|
s.LastUpdate = time.Now()
|
|
return cloneDockerHost(host), true
|
|
}
|
|
}
|
|
|
|
return DockerHost{}, false
|
|
}
|
|
|
|
// SetDockerHostCustomDisplayName updates the custom display name for a docker host.
|
|
func (s *State) SetDockerHostCustomDisplayName(hostID string, customName string) (DockerHost, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, host := range s.DockerHosts {
|
|
if host.ID == hostID {
|
|
host.CustomDisplayName = customName
|
|
s.DockerHosts[i] = host
|
|
s.LastUpdate = time.Now()
|
|
return cloneDockerHost(host), true
|
|
}
|
|
}
|
|
|
|
return DockerHost{}, false
|
|
}
|
|
|
|
// TouchDockerHost updates the last seen timestamp for a docker host.
|
|
func (s *State) TouchDockerHost(hostID string, ts time.Time) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, host := range s.DockerHosts {
|
|
if host.ID == hostID {
|
|
host.LastSeen = ts
|
|
s.DockerHosts[i] = host
|
|
s.LastUpdate = time.Now()
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// RemoveStaleDockerHosts removes docker hosts that haven't been seen since cutoff.
|
|
func (s *State) RemoveStaleDockerHosts(cutoff time.Time) []DockerHost {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
removed := make([]DockerHost, 0)
|
|
fresh := make([]DockerHost, 0, len(s.DockerHosts))
|
|
for _, host := range s.DockerHosts {
|
|
if host.LastSeen.Before(cutoff) && cutoff.After(host.LastSeen) {
|
|
removed = append(removed, host)
|
|
continue
|
|
}
|
|
fresh = append(fresh, host)
|
|
}
|
|
|
|
if len(removed) > 0 {
|
|
s.DockerHosts = fresh
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
return cloneDockerHosts(removed)
|
|
}
|
|
|
|
// GetDockerHosts returns a copy of docker hosts.
|
|
func (s *State) GetDockerHosts() []DockerHost {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
return cloneDockerHosts(s.DockerHosts)
|
|
}
|
|
|
|
// AddRemovedDockerHost records a removed docker host entry.
|
|
func (s *State) AddRemovedDockerHost(entry RemovedDockerHost) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
replaced := false
|
|
for i, existing := range s.RemovedDockerHosts {
|
|
if existing.ID == entry.ID {
|
|
s.RemovedDockerHosts[i] = entry
|
|
replaced = true
|
|
break
|
|
}
|
|
}
|
|
if !replaced {
|
|
s.RemovedDockerHosts = append(s.RemovedDockerHosts, entry)
|
|
}
|
|
sort.Slice(s.RemovedDockerHosts, func(i, j int) bool {
|
|
return s.RemovedDockerHosts[i].RemovedAt.After(s.RemovedDockerHosts[j].RemovedAt)
|
|
})
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// RemoveRemovedDockerHost deletes a removed docker host entry by ID.
|
|
func (s *State) RemoveRemovedDockerHost(hostID string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, entry := range s.RemovedDockerHosts {
|
|
if entry.ID == hostID {
|
|
s.RemovedDockerHosts = append(s.RemovedDockerHosts[:i], s.RemovedDockerHosts[i+1:]...)
|
|
s.LastUpdate = time.Now()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetRemovedDockerHosts returns a copy of removed docker host entries.
|
|
func (s *State) GetRemovedDockerHosts() []RemovedDockerHost {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
entries := make([]RemovedDockerHost, len(s.RemovedDockerHosts))
|
|
copy(entries, s.RemovedDockerHosts)
|
|
return entries
|
|
}
|
|
|
|
// AddRemovedHostAgent records a removed host agent entry.
|
|
func (s *State) AddRemovedHostAgent(entry RemovedHostAgent) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
replaced := false
|
|
for i, existing := range s.RemovedHostAgents {
|
|
if existing.ID == entry.ID {
|
|
s.RemovedHostAgents[i] = entry
|
|
replaced = true
|
|
break
|
|
}
|
|
}
|
|
if !replaced {
|
|
s.RemovedHostAgents = append(s.RemovedHostAgents, entry)
|
|
}
|
|
sort.Slice(s.RemovedHostAgents, func(i, j int) bool {
|
|
return s.RemovedHostAgents[i].RemovedAt.After(s.RemovedHostAgents[j].RemovedAt)
|
|
})
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// RemoveRemovedHostAgent deletes a removed host agent entry by ID.
|
|
func (s *State) RemoveRemovedHostAgent(hostID string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, entry := range s.RemovedHostAgents {
|
|
if entry.ID == hostID {
|
|
s.RemovedHostAgents = append(s.RemovedHostAgents[:i], s.RemovedHostAgents[i+1:]...)
|
|
s.LastUpdate = time.Now()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetRemovedHostAgents returns a copy of removed host agent entries.
|
|
func (s *State) GetRemovedHostAgents() []RemovedHostAgent {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
entries := make([]RemovedHostAgent, len(s.RemovedHostAgents))
|
|
copy(entries, s.RemovedHostAgents)
|
|
return entries
|
|
}
|
|
|
|
// UpsertKubernetesCluster inserts or updates a Kubernetes cluster in state.
|
|
func (s *State) UpsertKubernetesCluster(cluster KubernetesCluster) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
cluster = cloneKubernetesCluster(cluster)
|
|
|
|
updated := false
|
|
for i, existing := range s.KubernetesClusters {
|
|
if existing.ID == cluster.ID {
|
|
if existing.CustomDisplayName != "" {
|
|
cluster.CustomDisplayName = existing.CustomDisplayName
|
|
}
|
|
cluster.Hidden = existing.Hidden
|
|
cluster.PendingUninstall = existing.PendingUninstall
|
|
s.KubernetesClusters[i] = cluster
|
|
updated = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !updated {
|
|
s.KubernetesClusters = append(s.KubernetesClusters, cluster)
|
|
}
|
|
|
|
sort.Slice(s.KubernetesClusters, func(i, j int) bool {
|
|
left := s.KubernetesClusters[i].Name
|
|
right := s.KubernetesClusters[j].Name
|
|
if left == "" {
|
|
left = s.KubernetesClusters[i].ID
|
|
}
|
|
if right == "" {
|
|
right = s.KubernetesClusters[j].ID
|
|
}
|
|
return left < right
|
|
})
|
|
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// RemoveKubernetesCluster removes a Kubernetes cluster by ID and returns the removed cluster.
|
|
func (s *State) RemoveKubernetesCluster(clusterID string) (KubernetesCluster, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, cluster := range s.KubernetesClusters {
|
|
if cluster.ID == clusterID {
|
|
s.KubernetesClusters = append(s.KubernetesClusters[:i], s.KubernetesClusters[i+1:]...)
|
|
s.LastUpdate = time.Now()
|
|
return cloneKubernetesCluster(cluster), true
|
|
}
|
|
}
|
|
|
|
return KubernetesCluster{}, false
|
|
}
|
|
|
|
// SetKubernetesClusterStatus updates the status of a kubernetes cluster if present.
|
|
func (s *State) SetKubernetesClusterStatus(clusterID, status string) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
changed := false
|
|
for i, cluster := range s.KubernetesClusters {
|
|
if cluster.ID == clusterID {
|
|
if cluster.Status != status {
|
|
cluster.Status = status
|
|
s.KubernetesClusters[i] = cluster
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
changed = true
|
|
break
|
|
}
|
|
}
|
|
return changed
|
|
}
|
|
|
|
// SetKubernetesClusterHidden updates the hidden status of a kubernetes cluster and returns the updated cluster.
|
|
func (s *State) SetKubernetesClusterHidden(clusterID string, hidden bool) (KubernetesCluster, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, cluster := range s.KubernetesClusters {
|
|
if cluster.ID == clusterID {
|
|
cluster.Hidden = hidden
|
|
s.KubernetesClusters[i] = cluster
|
|
s.LastUpdate = time.Now()
|
|
return cloneKubernetesCluster(cluster), true
|
|
}
|
|
}
|
|
return KubernetesCluster{}, false
|
|
}
|
|
|
|
// SetKubernetesClusterPendingUninstall updates the pending uninstall flag and returns the updated cluster.
|
|
func (s *State) SetKubernetesClusterPendingUninstall(clusterID string, pending bool) (KubernetesCluster, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, cluster := range s.KubernetesClusters {
|
|
if cluster.ID == clusterID {
|
|
cluster.PendingUninstall = pending
|
|
s.KubernetesClusters[i] = cluster
|
|
s.LastUpdate = time.Now()
|
|
return cloneKubernetesCluster(cluster), true
|
|
}
|
|
}
|
|
return KubernetesCluster{}, false
|
|
}
|
|
|
|
// SetKubernetesClusterCustomDisplayName updates the custom display name for a kubernetes cluster.
|
|
func (s *State) SetKubernetesClusterCustomDisplayName(clusterID string, customName string) (KubernetesCluster, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, cluster := range s.KubernetesClusters {
|
|
if cluster.ID == clusterID {
|
|
cluster.CustomDisplayName = customName
|
|
s.KubernetesClusters[i] = cluster
|
|
s.LastUpdate = time.Now()
|
|
return cloneKubernetesCluster(cluster), true
|
|
}
|
|
}
|
|
|
|
return KubernetesCluster{}, false
|
|
}
|
|
|
|
// GetKubernetesClusters returns a copy of kubernetes clusters.
|
|
func (s *State) GetKubernetesClusters() []KubernetesCluster {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
return cloneKubernetesClusters(s.KubernetesClusters)
|
|
}
|
|
|
|
// AddRemovedKubernetesCluster records a removed kubernetes cluster entry.
|
|
func (s *State) AddRemovedKubernetesCluster(entry RemovedKubernetesCluster) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
replaced := false
|
|
for i, existing := range s.RemovedKubernetesClusters {
|
|
if existing.ID == entry.ID {
|
|
s.RemovedKubernetesClusters[i] = entry
|
|
replaced = true
|
|
break
|
|
}
|
|
}
|
|
if !replaced {
|
|
s.RemovedKubernetesClusters = append(s.RemovedKubernetesClusters, entry)
|
|
}
|
|
sort.Slice(s.RemovedKubernetesClusters, func(i, j int) bool {
|
|
return s.RemovedKubernetesClusters[i].RemovedAt.After(s.RemovedKubernetesClusters[j].RemovedAt)
|
|
})
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// RemoveRemovedKubernetesCluster deletes a removed kubernetes cluster entry by ID.
|
|
func (s *State) RemoveRemovedKubernetesCluster(clusterID string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, entry := range s.RemovedKubernetesClusters {
|
|
if entry.ID == clusterID {
|
|
s.RemovedKubernetesClusters = append(s.RemovedKubernetesClusters[:i], s.RemovedKubernetesClusters[i+1:]...)
|
|
s.LastUpdate = time.Now()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetRemovedKubernetesClusters returns a copy of removed kubernetes cluster entries.
|
|
func (s *State) GetRemovedKubernetesClusters() []RemovedKubernetesCluster {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
entries := make([]RemovedKubernetesCluster, len(s.RemovedKubernetesClusters))
|
|
copy(entries, s.RemovedKubernetesClusters)
|
|
return entries
|
|
}
|
|
|
|
// UpsertHost inserts or updates a generic host in state.
|
|
func (s *State) UpsertHost(host Host) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
host = cloneHost(host)
|
|
|
|
updated := false
|
|
for i, existing := range s.Hosts {
|
|
if existing.ID == host.ID {
|
|
s.Hosts[i] = host
|
|
updated = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !updated {
|
|
s.Hosts = append(s.Hosts, host)
|
|
}
|
|
|
|
sort.Slice(s.Hosts, func(i, j int) bool {
|
|
return s.Hosts[i].Hostname < s.Hosts[j].Hostname
|
|
})
|
|
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// GetHosts returns a copy of all generic hosts.
|
|
func (s *State) GetHosts() []Host {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
return cloneHosts(s.Hosts)
|
|
}
|
|
|
|
// RemoveHost removes a host by ID and returns the removed entry.
|
|
func (s *State) RemoveHost(hostID string) (Host, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, host := range s.Hosts {
|
|
if host.ID == hostID {
|
|
s.Hosts = append(s.Hosts[:i], s.Hosts[i+1:]...)
|
|
s.LastUpdate = time.Now()
|
|
return cloneHost(host), true
|
|
}
|
|
}
|
|
|
|
return Host{}, false
|
|
}
|
|
|
|
// ClearAllHosts removes all host agents from the state.
|
|
// This is used during initial security setup to clear any hosts that may have
|
|
// connected during the brief unauthenticated window before credentials were configured.
|
|
func (s *State) ClearAllHosts() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
count := len(s.Hosts)
|
|
s.Hosts = []Host{}
|
|
s.LastUpdate = time.Now()
|
|
return count
|
|
}
|
|
|
|
// LinkNodeToHostAgent updates a PVE node to link to its host agent.
|
|
// This is called when a host agent registers and matches a known PVE node by hostname.
|
|
func (s *State) LinkNodeToHostAgent(nodeID, hostAgentID string) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, node := range s.Nodes {
|
|
if node.ID == nodeID {
|
|
s.Nodes[i].LinkedAgentID = hostAgentID
|
|
s.LastUpdate = time.Now()
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// UnlinkNodesFromHostAgent clears LinkedAgentID from all nodes linked to the given host agent.
|
|
// This is called when a host agent is removed to clean up stale references.
|
|
func (s *State) UnlinkNodesFromHostAgent(hostAgentID string) int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
count := 0
|
|
for i, node := range s.Nodes {
|
|
if node.LinkedAgentID == hostAgentID {
|
|
s.Nodes[i].LinkedAgentID = ""
|
|
count++
|
|
}
|
|
}
|
|
if count > 0 {
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
return count
|
|
}
|
|
|
|
// LinkHostAgentToNode creates a bidirectional link between a host agent and a PVE node.
|
|
// This is used for manual linking when auto-linking can't disambiguate (e.g., multiple nodes
|
|
// with the same hostname). Sets LinkedNodeID on the host and LinkedAgentID on the node.
|
|
// Returns an error if either the host or node is not found.
|
|
func (s *State) LinkHostAgentToNode(hostID, nodeID string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// Find the host
|
|
var hostIdx int = -1
|
|
for i, host := range s.Hosts {
|
|
if host.ID == hostID {
|
|
hostIdx = i
|
|
break
|
|
}
|
|
}
|
|
if hostIdx < 0 {
|
|
return fmt.Errorf("%w: %s", ErrHostAgentNotFound, hostID)
|
|
}
|
|
|
|
// Find the node
|
|
var nodeIdx int = -1
|
|
for i, node := range s.Nodes {
|
|
if node.ID == nodeID {
|
|
nodeIdx = i
|
|
break
|
|
}
|
|
}
|
|
if nodeIdx < 0 {
|
|
return fmt.Errorf("%w: %s", ErrNodeNotFound, nodeID)
|
|
}
|
|
|
|
// Clear any existing links from this host
|
|
oldNodeID := s.Hosts[hostIdx].LinkedNodeID
|
|
if oldNodeID != "" {
|
|
for i, node := range s.Nodes {
|
|
if node.ID == oldNodeID {
|
|
s.Nodes[i].LinkedAgentID = ""
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear any existing links to the target node (from other hosts)
|
|
for i, host := range s.Hosts {
|
|
if host.LinkedNodeID == nodeID && i != hostIdx {
|
|
s.Hosts[i].LinkedNodeID = ""
|
|
}
|
|
}
|
|
|
|
// Create the bidirectional link
|
|
s.Hosts[hostIdx].LinkedNodeID = nodeID
|
|
s.Hosts[hostIdx].LinkedVMID = "" // Clear VM/container links if setting node link
|
|
s.Hosts[hostIdx].LinkedContainerID = ""
|
|
s.Nodes[nodeIdx].LinkedAgentID = hostID
|
|
|
|
s.LastUpdate = time.Now()
|
|
return nil
|
|
}
|
|
|
|
// UnlinkHostAgent removes the bidirectional link between a host agent and its PVE node.
|
|
// Clears LinkedNodeID on the host and LinkedAgentID on the node.
|
|
// Returns true if the host was found and unlinked.
|
|
func (s *State) UnlinkHostAgent(hostID string) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// Find the host
|
|
var hostIdx int = -1
|
|
var linkedNodeID string
|
|
for i, host := range s.Hosts {
|
|
if host.ID == hostID {
|
|
hostIdx = i
|
|
linkedNodeID = host.LinkedNodeID
|
|
break
|
|
}
|
|
}
|
|
|
|
if hostIdx < 0 || linkedNodeID == "" {
|
|
return false
|
|
}
|
|
|
|
// Clear the link on the host
|
|
s.Hosts[hostIdx].LinkedNodeID = ""
|
|
s.Hosts[hostIdx].LinkedVMID = ""
|
|
s.Hosts[hostIdx].LinkedContainerID = ""
|
|
|
|
// Clear the link on the node
|
|
for i, node := range s.Nodes {
|
|
if node.ID == linkedNodeID || node.LinkedAgentID == hostID {
|
|
s.Nodes[i].LinkedAgentID = ""
|
|
}
|
|
}
|
|
|
|
s.LastUpdate = time.Now()
|
|
return true
|
|
}
|
|
|
|
// UpsertCephCluster inserts or updates a Ceph cluster in the state.
|
|
// FSID identifies the physical Ceph cluster across API and host-agent reports.
|
|
func (s *State) UpsertCephCluster(cluster CephCluster) CephCluster {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
cluster = normalizeCephClusterForState(cloneCephCluster(cluster), "")
|
|
var stored CephCluster
|
|
s.CephClusters, stored = upsertCephClusterInSlice(s.CephClusters, cluster)
|
|
sortCephClustersForState(s.CephClusters)
|
|
|
|
s.LastUpdate = time.Now()
|
|
return cloneCephCluster(stored)
|
|
}
|
|
|
|
// SetHostStatus updates the status of a host if present.
|
|
func (s *State) SetHostStatus(hostID, status string) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, host := range s.Hosts {
|
|
if host.ID == hostID {
|
|
if host.Status != status {
|
|
host.Status = status
|
|
s.Hosts[i] = host
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ExpireHostTelemetry marks a host offline and clears transient operation
|
|
// claims that cannot remain authoritative after the reporting lease expires.
|
|
// Static topology and health counters remain available as last-known context.
|
|
func (s *State) ExpireHostTelemetry(hostID string) (Host, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, host := range s.Hosts {
|
|
if host.ID != hostID {
|
|
continue
|
|
}
|
|
changed := host.Status != "offline"
|
|
host.Status = "offline"
|
|
for idx := range host.RAID {
|
|
array := &host.RAID[idx]
|
|
if array.Operation != "" || array.RebuildPercent != 0 || array.RebuildSpeed != "" {
|
|
array.Operation = ""
|
|
array.RebuildPercent = 0
|
|
array.RebuildSpeed = ""
|
|
changed = true
|
|
}
|
|
}
|
|
if host.Unraid != nil && (host.Unraid.SyncAction != "" || host.Unraid.SyncProgress != 0) {
|
|
host.Unraid.SyncAction = ""
|
|
host.Unraid.SyncProgress = 0
|
|
changed = true
|
|
}
|
|
s.Hosts[i] = host
|
|
if changed {
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
return cloneHost(host), changed
|
|
}
|
|
return Host{}, false
|
|
}
|
|
|
|
// TouchHost updates the last seen timestamp for a host.
|
|
func (s *State) TouchHost(hostID string, ts time.Time) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, host := range s.Hosts {
|
|
if host.ID == hostID {
|
|
host.LastSeen = ts
|
|
s.Hosts[i] = host
|
|
s.LastUpdate = time.Now()
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// SetHostCommandsEnabled updates the CommandsEnabled flag for a host.
|
|
// This allows the UI to immediately reflect config changes without waiting for agent confirmation.
|
|
func (s *State) SetHostCommandsEnabled(hostID string, enabled bool) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, host := range s.Hosts {
|
|
if host.ID == hostID {
|
|
if host.CommandsEnabled != enabled {
|
|
host.CommandsEnabled = enabled
|
|
s.Hosts[i] = host
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// UpdateStorage updates the storage in the state
|
|
func (s *State) UpdateStorage(storage []Storage) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
cloned := cloneStorages(storage)
|
|
if cloned == nil {
|
|
cloned = []Storage{}
|
|
}
|
|
s.Storage = cloned
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
func updateSliceByInstance[T any](
|
|
existing []T,
|
|
newItems []T,
|
|
instanceName string,
|
|
getID func(T) string,
|
|
getInstance func(T) string,
|
|
clone func(T) T,
|
|
less func([]T, int, int) bool,
|
|
) []T {
|
|
itemMap := make(map[string]T)
|
|
for _, item := range existing {
|
|
if getInstance(item) != instanceName {
|
|
itemMap[getID(item)] = item
|
|
}
|
|
}
|
|
for _, item := range newItems {
|
|
itemMap[getID(item)] = clone(item)
|
|
}
|
|
result := make([]T, 0, len(itemMap))
|
|
for _, item := range itemMap {
|
|
result = append(result, item)
|
|
}
|
|
sort.Slice(result, func(i, j int) bool { return less(result, i, j) })
|
|
return result
|
|
}
|
|
|
|
// UpdatePhysicalDisks updates physical disks for a specific instance
|
|
func (s *State) UpdatePhysicalDisks(instanceName string, disks []PhysicalDisk) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.PhysicalDisks = updateSliceByInstance(
|
|
s.PhysicalDisks, disks, instanceName,
|
|
func(d PhysicalDisk) string { return d.ID },
|
|
func(d PhysicalDisk) string { return d.Instance },
|
|
clonePhysicalDisk,
|
|
func(items []PhysicalDisk, i, j int) bool {
|
|
if items[i].Node != items[j].Node {
|
|
return items[i].Node < items[j].Node
|
|
}
|
|
return items[i].DevPath < items[j].DevPath
|
|
},
|
|
)
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// UpdateStorageForInstance updates storage for a specific instance, merging with existing storage
|
|
func (s *State) UpdateStorageForInstance(instanceName string, storage []Storage) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.Storage = updateSliceByInstance(
|
|
s.Storage, storage, instanceName,
|
|
func(st Storage) string { return st.ID },
|
|
func(st Storage) string { return st.Instance },
|
|
cloneStorage,
|
|
func(items []Storage, i, j int) bool {
|
|
if items[i].Instance == items[j].Instance {
|
|
return items[i].Name < items[j].Name
|
|
}
|
|
return items[i].Instance < items[j].Instance
|
|
},
|
|
)
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// UpdateCephClustersForInstance updates Proxmox-API Ceph cluster information for a specific instance.
|
|
func (s *State) UpdateCephClustersForInstance(instanceName string, clusters []CephCluster) []CephCluster {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
filtered := make([]CephCluster, 0, len(s.CephClusters))
|
|
for _, cluster := range s.CephClusters {
|
|
cluster = normalizeCephClusterForState(cluster, "")
|
|
if cluster.Instance == instanceName && normalizeCephClusterSource(cluster.Source, cluster.Instance, "") == CephClusterSourceProxmoxAPI {
|
|
continue
|
|
}
|
|
filtered = append(filtered, cluster)
|
|
}
|
|
|
|
stored := make([]CephCluster, 0, len(clusters))
|
|
for _, cluster := range clusters {
|
|
cluster = cloneCephCluster(cluster)
|
|
if strings.TrimSpace(cluster.Instance) == "" {
|
|
cluster.Instance = instanceName
|
|
}
|
|
cluster = normalizeCephClusterForState(cluster, CephClusterSourceProxmoxAPI)
|
|
var reconciled CephCluster
|
|
filtered, reconciled = upsertCephClusterInSlice(filtered, cluster)
|
|
stored = append(stored, reconciled)
|
|
}
|
|
|
|
sortCephClustersForState(filtered)
|
|
s.CephClusters = filtered
|
|
s.LastUpdate = time.Now()
|
|
return cloneCephClusters(stored)
|
|
}
|
|
|
|
// UpdatePBSInstances updates the PBS instances in the state
|
|
func (s *State) UpdatePBSInstances(instances []PBSInstance) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
cloned := clonePBSInstances(instances)
|
|
if cloned == nil {
|
|
cloned = []PBSInstance{}
|
|
}
|
|
s.PBSInstances = cloned
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// UpdatePBSInstance updates a single PBS instance in the state, merging with existing instances
|
|
func (s *State) UpdatePBSInstance(instance PBSInstance) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// Find and update existing instance or append new one
|
|
found := false
|
|
instance = clonePBSInstance(instance)
|
|
|
|
for i, existing := range s.PBSInstances {
|
|
if existing.ID == instance.ID {
|
|
s.PBSInstances[i] = instance
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
s.PBSInstances = append(s.PBSInstances, instance)
|
|
}
|
|
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// UpdatePMGInstances replaces the entire PMG instance list
|
|
func (s *State) UpdatePMGInstances(instances []PMGInstance) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
cloned := clonePMGInstances(instances)
|
|
if cloned == nil {
|
|
cloned = []PMGInstance{}
|
|
}
|
|
s.PMGInstances = cloned
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// UpdatePMGInstance updates or inserts a PMG instance record
|
|
func (s *State) UpdatePMGInstance(instance PMGInstance) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
instance = clonePMGInstance(instance)
|
|
|
|
updated := false
|
|
for i := range s.PMGInstances {
|
|
if s.PMGInstances[i].ID == instance.ID || strings.EqualFold(s.PMGInstances[i].Name, instance.Name) {
|
|
s.PMGInstances[i] = instance
|
|
updated = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !updated {
|
|
s.PMGInstances = append(s.PMGInstances, instance)
|
|
}
|
|
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// UpdateBackupTasksForInstance updates backup tasks for a specific instance, merging with existing tasks
|
|
func (s *State) UpdateBackupTasksForInstance(instanceName string, tasks []BackupTask) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// Create a map of existing tasks, excluding those from this instance
|
|
taskMap := make(map[string]BackupTask)
|
|
for _, task := range s.PVEBackups.BackupTasks {
|
|
if task.Instance != instanceName {
|
|
taskMap[task.ID] = task
|
|
}
|
|
}
|
|
|
|
// Add or update tasks from this instance
|
|
for _, task := range tasks {
|
|
taskMap[task.ID] = task
|
|
}
|
|
|
|
// Convert map back to slice
|
|
newTasks := make([]BackupTask, 0, len(taskMap))
|
|
for _, task := range taskMap {
|
|
newTasks = append(newTasks, task)
|
|
}
|
|
|
|
// Sort by start time descending
|
|
sort.Slice(newTasks, func(i, j int) bool {
|
|
return newTasks[i].StartTime.After(newTasks[j].StartTime)
|
|
})
|
|
|
|
s.PVEBackups.BackupTasks = newTasks
|
|
s.syncBackupsLocked()
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// UpdateStorageBackupsForInstance updates storage backups for a specific instance, merging with existing backups
|
|
func (s *State) UpdateStorageBackupsForInstance(instanceName string, backups []StorageBackup) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// When storage is shared across nodes, backups can appear under whichever node reported the content.
|
|
// Align each backup with the guest's current node so the frontend column matches the VM/CT placement.
|
|
guestNodeByVMID := make(map[int]string)
|
|
for _, vm := range s.VMs {
|
|
if vm.Instance == instanceName && vm.Node != "" {
|
|
guestNodeByVMID[vm.VMID] = vm.Node
|
|
}
|
|
}
|
|
for _, ct := range s.Containers {
|
|
if ct.Instance == instanceName && ct.Node != "" {
|
|
guestNodeByVMID[ct.VMID] = ct.Node
|
|
}
|
|
}
|
|
|
|
normalizedBackups := make([]StorageBackup, 0, len(backups))
|
|
for _, backup := range backups {
|
|
if backup.VMID > 0 {
|
|
if node, ok := guestNodeByVMID[backup.VMID]; ok {
|
|
backup.Node = node
|
|
}
|
|
}
|
|
normalizedBackups = append(normalizedBackups, backup)
|
|
}
|
|
|
|
// Create a map of existing backups, excluding those from this instance
|
|
backupMap := make(map[string]StorageBackup)
|
|
for _, backup := range s.PVEBackups.StorageBackups {
|
|
if backup.Instance != instanceName {
|
|
backupMap[backup.ID] = backup
|
|
}
|
|
}
|
|
|
|
// Add or update backups from this instance
|
|
for _, backup := range normalizedBackups {
|
|
backupMap[backup.ID] = backup
|
|
}
|
|
|
|
// Convert map back to slice
|
|
newBackups := make([]StorageBackup, 0, len(backupMap))
|
|
for _, backup := range backupMap {
|
|
newBackups = append(newBackups, backup)
|
|
}
|
|
|
|
// Sort by time descending
|
|
sort.Slice(newBackups, func(i, j int) bool {
|
|
return newBackups[i].Time.After(newBackups[j].Time)
|
|
})
|
|
|
|
s.PVEBackups.StorageBackups = newBackups
|
|
s.syncBackupsLocked()
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// UpdateReplicationJobsForInstance updates replication jobs for a specific instance.
|
|
func (s *State) UpdateReplicationJobsForInstance(instanceName string, jobs []ReplicationJob) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
filtered := make([]ReplicationJob, 0, len(s.ReplicationJobs))
|
|
for _, job := range s.ReplicationJobs {
|
|
if job.Instance != instanceName {
|
|
filtered = append(filtered, job)
|
|
}
|
|
}
|
|
|
|
now := time.Now()
|
|
for _, job := range jobs {
|
|
job = cloneReplicationJob(job)
|
|
if job.Instance == "" {
|
|
job.Instance = instanceName
|
|
}
|
|
if job.JobID == "" {
|
|
job.JobID = job.ID
|
|
}
|
|
if job.LastPolled.IsZero() {
|
|
job.LastPolled = now
|
|
}
|
|
filtered = append(filtered, job)
|
|
}
|
|
|
|
sort.Slice(filtered, func(i, j int) bool {
|
|
if filtered[i].Instance == filtered[j].Instance {
|
|
if filtered[i].GuestID == filtered[j].GuestID {
|
|
if filtered[i].JobNumber == filtered[j].JobNumber {
|
|
if filtered[i].JobID == filtered[j].JobID {
|
|
return filtered[i].ID < filtered[j].ID
|
|
}
|
|
return filtered[i].JobID < filtered[j].JobID
|
|
}
|
|
return filtered[i].JobNumber < filtered[j].JobNumber
|
|
}
|
|
if filtered[i].GuestID == 0 || filtered[j].GuestID == 0 {
|
|
return filtered[i].Guest < filtered[j].Guest
|
|
}
|
|
return filtered[i].GuestID < filtered[j].GuestID
|
|
}
|
|
return filtered[i].Instance < filtered[j].Instance
|
|
})
|
|
|
|
s.ReplicationJobs = filtered
|
|
s.LastUpdate = now
|
|
}
|
|
|
|
// UpdateGuestSnapshotsForInstance updates guest snapshots for a specific instance, merging with existing snapshots
|
|
func (s *State) UpdateGuestSnapshotsForInstance(instanceName string, snapshots []GuestSnapshot) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// Create a map of existing snapshots, excluding those from this instance
|
|
snapshotMap := make(map[string]GuestSnapshot)
|
|
for _, snapshot := range s.PVEBackups.GuestSnapshots {
|
|
if snapshot.Instance != instanceName {
|
|
snapshotMap[snapshot.ID] = snapshot
|
|
}
|
|
}
|
|
|
|
// Add or update snapshots from this instance
|
|
for _, snapshot := range snapshots {
|
|
snapshotMap[snapshot.ID] = snapshot
|
|
}
|
|
|
|
// Convert map back to slice
|
|
newSnapshots := make([]GuestSnapshot, 0, len(snapshotMap))
|
|
for _, snapshot := range snapshotMap {
|
|
newSnapshots = append(newSnapshots, snapshot)
|
|
}
|
|
|
|
// Sort by time descending
|
|
sort.Slice(newSnapshots, func(i, j int) bool {
|
|
return newSnapshots[i].Time.After(newSnapshots[j].Time)
|
|
})
|
|
|
|
s.PVEBackups.GuestSnapshots = newSnapshots
|
|
s.syncBackupsLocked()
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// SetConnectionHealth updates the connection health for an instance
|
|
func (s *State) SetConnectionHealth(instanceID string, healthy bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.ConnectionHealth[instanceID] = healthy
|
|
}
|
|
|
|
// RemoveConnectionHealth removes a connection health entry if it exists.
|
|
func (s *State) RemoveConnectionHealth(instanceID string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.ConnectionHealth, instanceID)
|
|
}
|
|
|
|
// UpdatePBSBackups updates PBS backups for a specific instance
|
|
func (s *State) UpdatePBSBackups(instanceName string, backups []PBSBackup) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// Create a map of existing backups excluding ones from this instance
|
|
backupMap := make(map[string]PBSBackup)
|
|
for _, backup := range s.PBSBackups {
|
|
if backup.Instance != instanceName {
|
|
backupMap[backup.ID] = backup
|
|
}
|
|
}
|
|
|
|
// Add new backups from this instance
|
|
for _, backup := range backups {
|
|
backupMap[backup.ID] = clonePBSBackup(backup)
|
|
}
|
|
|
|
// Convert map back to slice
|
|
newBackups := make([]PBSBackup, 0, len(backupMap))
|
|
for _, backup := range backupMap {
|
|
newBackups = append(newBackups, backup)
|
|
}
|
|
|
|
// Sort by backup time (newest first)
|
|
sort.Slice(newBackups, func(i, j int) bool {
|
|
return newBackups[i].BackupTime.After(newBackups[j].BackupTime)
|
|
})
|
|
|
|
s.PBSBackups = newBackups
|
|
s.syncBackupsLocked()
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// UpdatePMGBackups updates PMG backups for a specific instance.
|
|
func (s *State) UpdatePMGBackups(instanceName string, backups []PMGBackup) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
combined := make([]PMGBackup, 0, len(s.PMGBackups)+len(backups))
|
|
for _, backup := range s.PMGBackups {
|
|
if backup.Instance != instanceName {
|
|
combined = append(combined, backup)
|
|
}
|
|
}
|
|
if len(backups) > 0 {
|
|
combined = append(combined, clonePMGBackups(backups)...)
|
|
}
|
|
|
|
if len(combined) > 1 {
|
|
sort.Slice(combined, func(i, j int) bool {
|
|
return combined[i].BackupTime.After(combined[j].BackupTime)
|
|
})
|
|
}
|
|
|
|
s.PMGBackups = combined
|
|
s.syncBackupsLocked()
|
|
s.LastUpdate = time.Now()
|
|
}
|
|
|
|
// GetContainers returns a copy of all LXC containers.
|
|
func (s *State) GetContainers() []Container {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
return cloneContainers(s.Containers)
|
|
}
|
|
|
|
// UpdateContainerDockerStatus updates the Docker detection status for a specific container.
|
|
func (s *State) UpdateContainerDockerStatus(containerID string, hasDocker bool, checkedAt time.Time) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i := range s.Containers {
|
|
if s.Containers[i].ID == containerID {
|
|
s.Containers[i].HasDocker = hasDocker
|
|
s.Containers[i].DockerCheckedAt = checkedAt
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// UpdatePollStats atomically updates performance and stats fields that are
|
|
// written at the end of each polling cycle. This prevents data races when
|
|
// multiple poll() goroutines run concurrently (e.g. during mock mode transitions).
|
|
func (s *State) UpdatePollStats(pollDuration float64, uptime int64, wsClients int) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.Performance.LastPollDuration = pollDuration
|
|
s.Stats.PollingCycles++
|
|
s.Stats.Uptime = uptime
|
|
s.Stats.WebSocketClients = wsClients
|
|
}
|