mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
6045 lines
216 KiB
Go
6045 lines
216 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"`
|
|
LastSeen *time.Time `json:"lastSeen,omitempty"`
|
|
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"`
|
|
NetworkInterfaces []HostNetworkInterface `json:"networkInterfaces,omitempty"`
|
|
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 successfully checked
|
|
PendingUpdatesStatus string `json:"pendingUpdatesStatus,omitempty"` // checked, stale, unavailable, or not_checked
|
|
PendingUpdatesReason string `json:"pendingUpdatesReason,omitempty"` // Fixed diagnostic category; never a raw provider error
|
|
|
|
// 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.NetworkInterfaces == nil {
|
|
n.NetworkInterfaces = []HostNetworkInterface{}
|
|
}
|
|
for i := range n.NetworkInterfaces {
|
|
n.NetworkInterfaces[i] = n.NetworkInterfaces[i].NormalizeCollections()
|
|
}
|
|
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"`
|
|
// BackupInProgress reports that a backup of this guest is running right
|
|
// now (a live vzdump task, or an in-flight PBS snapshot). LastBackup
|
|
// still points at the most recent COMPLETED backup while this is set.
|
|
BackupInProgress bool `json:"backupInProgress,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"`
|
|
// BackupInProgress reports that a backup of this guest is running right
|
|
// now (a live vzdump task, or an in-flight PBS snapshot). LastBackup
|
|
// still points at the most recent COMPLETED backup while this is set.
|
|
BackupInProgress bool `json:"backupInProgress,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"`
|
|
AgentPrivilege *AgentPrivilegeStatus `json:"agentPrivilege,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"`
|
|
}
|
|
|
|
// AgentPrivilegeStatus is the agent-reported privilege profile: whether the
|
|
// service runs as root, the service account, and whether the scoped
|
|
// smartctl/pct privilege helpers of a least-privilege install are in effect.
|
|
// Informational only — a non-root agent is an intentional profile, not a
|
|
// health defect.
|
|
type AgentPrivilegeStatus struct {
|
|
RunningAsRoot bool `json:"runningAsRoot"`
|
|
ServiceUser string `json:"serviceUser,omitempty"`
|
|
CommandAuthority string `json:"commandAuthority,omitempty"`
|
|
TypedHelper bool `json:"typedHelper,omitempty"`
|
|
SmartctlHelper bool `json:"smartctlHelper,omitempty"`
|
|
PctHelper bool `json:"pctHelper,omitempty"`
|
|
}
|
|
|
|
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"`
|
|
HealthcheckTargets []string `json:"healthcheckTargets,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.HealthcheckTargets == nil {
|
|
c.HealthcheckTargets = []string{}
|
|
}
|
|
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"`
|
|
// InProgress marks a snapshot the PBS API listed while the backup is
|
|
// still being written: the manifest (index.json.blob) does not exist yet,
|
|
// so the listing has no size and only the guest config blob in files. It
|
|
// is not a completed backup and must not advance any last-backup age.
|
|
InProgress bool `json:"inProgress,omitempty"`
|
|
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
|
|
// InProgress marks a backup file that a still-running vzdump task for the
|
|
// same guest is currently writing (or a PBS-storage listing entry whose
|
|
// snapshot has no manifest yet). It is a partial artifact, not a
|
|
// completed backup, and must not advance any last-backup age.
|
|
InProgress bool `json:"inProgress,omitempty"`
|
|
}
|
|
|
|
// 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 == "" {
|
|
fpA := normalizeNodeTLSFingerprint(a.TLSFingerprint)
|
|
fpB := normalizeNodeTLSFingerprint(b.TLSFingerprint)
|
|
if fpA != "" && fpA == fpB {
|
|
return true
|
|
}
|
|
// Standalone connection instances are provider scopes too. They may
|
|
// reuse the same native node name, so only the exact endpoint (or the
|
|
// fingerprint above) proves that two unclassified views are the same
|
|
// machine. A shared short hostname or guessed agent link is not proof.
|
|
endpointA := extractHostEndpoint(a.Host)
|
|
endpointB := extractHostEndpoint(b.Host)
|
|
return endpointA != "" && endpointA == endpointB
|
|
}
|
|
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
|
|
hostHostnameByID := make(map[string]string) // hostAgentID -> normalized full hostname
|
|
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
|
|
hostHostnameByID[host.ID] = strings.TrimSpace(strings.ToLower(host.Hostname))
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// sharedAgentProvesNodePair applies the stronger boundary required when a
|
|
// host agent is already linked to a node from another provider instance.
|
|
// A unique short hostname is only unique in the current snapshot, not in
|
|
// the operator's estate: during sequential setup, the first pve01 agent
|
|
// must not be lent to a later pve01 from another standalone connection.
|
|
// Preserve legitimate split views when the same agent strongly bridges
|
|
// both endpoints through an exact IP or a full (dotted) hostname.
|
|
hostStronglyCorroboratesNode := func(hostID string, node Node) bool {
|
|
endpoint := extractHostEndpoint(node.Host)
|
|
if endpoint == "" {
|
|
return false
|
|
}
|
|
if ip := normalizeIPAddress(endpoint); ip != "" {
|
|
_, ok := hostIPsByID[hostID][ip]
|
|
return ok
|
|
}
|
|
hostname := hostHostnameByID[hostID]
|
|
return strings.Contains(endpoint, ".") && endpoint == hostname
|
|
}
|
|
sharedAgentProvesNodePair := func(hostID string, existing, candidate Node) bool {
|
|
if nodeCrossViewMergeProven(existing, candidate) {
|
|
return true
|
|
}
|
|
if normalizeNodeIdentityPart(existing.ClusterName) != "" ||
|
|
normalizeNodeIdentityPart(candidate.ClusterName) != "" {
|
|
return false
|
|
}
|
|
return hostStronglyCorroboratesNode(hostID, existing) &&
|
|
hostStronglyCorroboratesNode(hostID, candidate)
|
|
}
|
|
hostAlreadyClaimsDifferentNode := func(hostID string, node Node) bool {
|
|
linkedKey := linkedHostToKey[strings.TrimSpace(hostID)]
|
|
if linkedKey == "" {
|
|
return false
|
|
}
|
|
existing, ok := nodeMap[linkedKey]
|
|
return ok && !sharedAgentProvesNodePair(hostID, existing, node)
|
|
}
|
|
|
|
// 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 hostAlreadyClaimsDifferentNode(hostID, node) {
|
|
return true
|
|
}
|
|
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] && !hostContradictsNode(existingLink, node) {
|
|
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] && !hostContradictsNode(existingLink, node) {
|
|
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 || sharedAgentProvesNodePair(node.LinkedAgentID, 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,
|
|
carryBackupState func(item T, prev T) 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)
|
|
// Guest polls know nothing about backups (LastBackup arrives zero);
|
|
// carry the previously computed backup state forward so it survives
|
|
// until the next SyncGuestBackupTimes pass recomputes it.
|
|
if prev, ok := existingByVMID[getVMID(item)]; ok && getLastBackup(item).IsZero() {
|
|
item = carryBackupState(item, prev)
|
|
}
|
|
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,
|
|
carryBackupState func(item T, prev T) 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, carryBackupState, 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, prev VM) VM {
|
|
vm.LastBackup = prev.LastBackup
|
|
vm.BackupInProgress = prev.BackupInProgress
|
|
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()
|
|
}
|
|
|
|
// runningBackupTaskMaxAge bounds how long an unfinished vzdump task keeps
|
|
// reporting a guest as backing up. Task lists stop refreshing for an
|
|
// unreachable instance, and a wedged multi-day vzdump is not a state worth
|
|
// advertising as "running" on the guest badge.
|
|
const runningBackupTaskMaxAge = 24 * time.Hour
|
|
|
|
// 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 and BackupInProgress on VMs and
|
|
// Containers from storage backups, PBS backups, and running backup tasks.
|
|
// LastBackup only ever reflects completed backups; in-flight PBS snapshots
|
|
// and partial vzdump archives surface through BackupInProgress instead.
|
|
// 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)
|
|
|
|
// Guests with a backup running right now: a live vzdump task is the
|
|
// authoritative PVE-side signal. Partial artifacts (an in-flight PBS
|
|
// snapshot, a half-written vzdump archive) are collected separately
|
|
// below so they can surface as "backup running" without ever advancing
|
|
// the last-completed-backup age.
|
|
runningBackupTask := make(map[string]struct{})
|
|
runningTaskCutoff := time.Now().Add(-runningBackupTaskMaxAge)
|
|
for _, task := range s.PVEBackups.BackupTasks {
|
|
if task.VMID <= 0 || task.Instance == "" || !task.EndTime.IsZero() {
|
|
continue
|
|
}
|
|
status := strings.ToLower(strings.TrimSpace(task.Status))
|
|
if status != "" && status != "running" {
|
|
continue
|
|
}
|
|
// Task lists stop refreshing when an instance goes unreachable, so a
|
|
// stale "running" entry must eventually age out rather than pin the
|
|
// guest in a backup-running state forever. ObservedAt refreshes on
|
|
// every poll; tasks synthesized from job logs carry only StartTime.
|
|
observed := task.ObservedAt
|
|
if observed.IsZero() {
|
|
observed = task.StartTime
|
|
}
|
|
if observed.Before(runningTaskCutoff) {
|
|
continue
|
|
}
|
|
runningBackupTask[backupKey(task.Instance, task.VMID)] = struct{}{}
|
|
}
|
|
|
|
// Process PVE storage backups
|
|
for _, backup := range s.PVEBackups.StorageBackups {
|
|
if backup.VMID <= 0 || backup.Instance == "" {
|
|
continue
|
|
}
|
|
if backup.InProgress {
|
|
// A partial file is not a completed backup; the running-task set
|
|
// above already reports the guest as backing up.
|
|
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.
|
|
// In-flight snapshots are kept apart: they only feed the backup-running
|
|
// flag, never the completed-backup ages or the attribution learner.
|
|
pbsBackupsBySubject := make(map[pbsSubjectKey][]PBSBackup)
|
|
pbsInProgressBySubject := 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}
|
|
if backup.InProgress {
|
|
pbsInProgressBySubject[key] = append(pbsInProgressBySubject[key], backup)
|
|
continue
|
|
}
|
|
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. The subject map is a
|
|
// parameter so the same attribution rules apply to completed snapshots
|
|
// (feeding LastBackup) and to in-flight ones (feeding BackupInProgress).
|
|
findBestPBSBackup := func(bySubject map[pbsSubjectKey][]PBSBackup, vmid int, backupType string, instance string, node string, name string) time.Time {
|
|
subjectKey := pbsSubjectKey{backupType: backupType, vmid: vmid}
|
|
backups, ok := bySubject[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(pbsBackupsBySubject, 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
|
|
_, taskRunning := runningBackupTask[key]
|
|
s.VMs[i].BackupInProgress = taskRunning ||
|
|
!findBestPBSBackup(pbsInProgressBySubject, s.VMs[i].VMID, "vm", s.VMs[i].Instance, s.VMs[i].Node, s.VMs[i].Name).IsZero()
|
|
}
|
|
|
|
// 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(pbsBackupsBySubject, 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
|
|
_, taskRunning := runningBackupTask[key]
|
|
s.Containers[i].BackupInProgress = taskRunning ||
|
|
!findBestPBSBackup(pbsInProgressBySubject, s.Containers[i].VMID, "ct", s.Containers[i].Instance, s.Containers[i].Node, s.Containers[i].Name).IsZero()
|
|
}
|
|
|
|
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, prev Container) Container {
|
|
ct.LastBackup = prev.LastBackup
|
|
ct.BackupInProgress = prev.BackupInProgress
|
|
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
|
|
}
|