Files
pulse/pkg/proxmox/ceph.go
T
courtmanr@gmail.com c08da19ae7 fix(ceph): parse Quincy+/Squid status schema for MON and MGR counts
Ceph Quincy and later (including Squid on PVE 9) dropped the monmap
mons array and the mgrmap active_name/standbys arrays from ceph status
output, replacing them with monmap.num_mons and mgrmap.num_standbys,
with quorum membership reported at the top level of the payload. Both
the host agent parser and the Proxmox API path only understood the
legacy arrays, so modern clusters showed 0 monitors and undercounted
managers.

- hostagent: read num_mons/num_standbys and top-level quorum data,
  taking the largest available signal, and base the mon/mgr service
  rows on the same counts
- pkg/proxmox: decode mgrmap num_standbys and top-level
  quorum_names/quorum on CephStatus
- monitoring: fall back to the new fields when counting MON/MGR
  daemons, and log Ceph 401/403 failures at warn level with a hint to
  grant Sys.Audit on / instead of hiding them at debug
- models: prefer the larger non-zero MON/MGR counts when merging Ceph
  cluster records from multiple sources

Fixes #1626, Refs discussion #1290

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 19:31:15 +01:00

315 lines
8.6 KiB
Go

package proxmox
import (
"context"
"encoding/json"
"strings"
)
// CephStatus represents the Ceph cluster status information returned by /cluster/ceph/status
// Only the fields required for monitoring are included here.
type CephStatus struct {
FSID string `json:"fsid"`
Health CephHealth `json:"health"`
ServiceMap CephServiceMap `json:"servicemap"`
MonMap CephMonMap `json:"monmap"`
MgrMap CephMgrMap `json:"mgrmap"`
OSDMap CephOSDMap `json:"osdmap"`
PGMap CephPGMap `json:"pgmap"`
// Ceph reports quorum membership at the top level of the status payload,
// not inside monmap. Quincy+ omits the monmap mons array, so these are the
// only per-monitor signals available there.
QuorumNames []string `json:"quorum_names,omitempty"`
Quorum []json.RawMessage `json:"quorum,omitempty"`
}
// CephHealth captures cluster health status and summaries.
type CephHealth struct {
Status string `json:"status"`
Summary []CephHealthSummary `json:"summary"`
Checks map[string]CephHealthCheckRaw `json:"checks"`
}
// CephHealthSummary holds high-level messages describing the health state.
type CephHealthSummary struct {
Severity string `json:"severity"`
Summary string `json:"summary"`
Message string `json:"message,omitempty"`
}
// CephHealthCheckRaw captures the raw JSON blocks describing specific health checks.
type CephHealthCheckRaw struct {
Severity string `json:"severity"`
Summary json.RawMessage `json:"summary"`
Detail []CephCheckDetail `json:"detail"`
}
// CephCheckDetail represents individual health detail messages.
type CephCheckDetail struct {
Message string `json:"message"`
}
// CephServiceMap contains information about Ceph services and daemon states.
type CephServiceMap struct {
Services map[string]CephServiceDefinition `json:"services"`
}
// CephServiceDefinition represents a Ceph service (e.g., mon, mgr) and its daemons.
type CephServiceDefinition struct {
Daemons map[string]CephServiceDaemon `json:"daemons"`
}
// CephServiceDaemon represents the status of an individual Ceph daemon.
type CephServiceDaemon struct {
Host string `json:"hostname"`
Status string `json:"status"`
}
// CephMonMap captures monitor summary information.
type CephMonMap struct {
NumMons int `json:"num_mons"`
Mons []CephMonitor `json:"mons,omitempty"`
Monitors []CephMonitor `json:"monitors,omitempty"`
QuorumNames []string `json:"quorum_names,omitempty"`
Quorum []json.RawMessage `json:"quorum,omitempty"`
}
// CephMonitor captures a monitor entry from Ceph monmap payloads.
type CephMonitor struct {
Name string `json:"name"`
Rank int `json:"rank"`
Addr string `json:"addr"`
}
func (m *CephMonMap) UnmarshalJSON(data []byte) error {
type rawCephMonMap struct {
NumMons int `json:"num_mons"`
NumMonsLegacy int `json:"numMons"`
Mons []json.RawMessage `json:"mons"`
Monitors []json.RawMessage `json:"monitors"`
QuorumNames []string `json:"quorum_names"`
Quorum []json.RawMessage `json:"quorum"`
}
var raw rawCephMonMap
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
m.Mons = parseCephMonitors(raw.Mons)
m.Monitors = parseCephMonitors(raw.Monitors)
m.QuorumNames = trimNonEmptyStrings(raw.QuorumNames)
m.Quorum = append(m.Quorum[:0], raw.Quorum...)
m.NumMons = maxCephMonitorCount(
raw.NumMons,
raw.NumMonsLegacy,
len(m.Mons),
len(m.Monitors),
len(m.QuorumNames),
len(m.Quorum),
)
return nil
}
func parseCephMonitors(rawMonitors []json.RawMessage) []CephMonitor {
if len(rawMonitors) == 0 {
return nil
}
monitors := make([]CephMonitor, 0, len(rawMonitors))
for _, raw := range rawMonitors {
var monitor CephMonitor
if err := json.Unmarshal(raw, &monitor); err == nil {
monitors = append(monitors, monitor)
continue
}
var name string
if err := json.Unmarshal(raw, &name); err == nil {
monitors = append(monitors, CephMonitor{Name: strings.TrimSpace(name)})
continue
}
monitors = append(monitors, CephMonitor{})
}
return monitors
}
func trimNonEmptyStrings(values []string) []string {
if len(values) == 0 {
return nil
}
result := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
func maxCephMonitorCount(values ...int) int {
maxValue := 0
for _, value := range values {
if value > maxValue {
maxValue = value
}
}
return maxValue
}
// CephMgrMap captures manager summary information.
type CephMgrMap struct {
Available bool `json:"available"`
NumMgrs int `json:"num_mgrs"`
ActiveName string `json:"active_name"`
// Quincy+ reports num_standbys without a standbys array.
NumStandbys int `json:"num_standbys"`
Standbys []string `json:"standbys"`
}
func (m *CephMgrMap) UnmarshalJSON(data []byte) error {
type rawCephMgrMap struct {
Available bool `json:"available"`
NumMgrs int `json:"num_mgrs"`
ActiveName string `json:"active_name"`
NumStandbys int `json:"num_standbys"`
Standbys []json.RawMessage `json:"standbys"`
}
var raw rawCephMgrMap
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
m.Available = raw.Available
m.NumMgrs = raw.NumMgrs
m.ActiveName = raw.ActiveName
m.Standbys = m.Standbys[:0]
for _, standby := range raw.Standbys {
var name string
if err := json.Unmarshal(standby, &name); err == nil {
name = strings.TrimSpace(name)
if name != "" {
m.Standbys = append(m.Standbys, name)
}
continue
}
var named struct {
Name string `json:"name"`
}
if err := json.Unmarshal(standby, &named); err == nil {
name = strings.TrimSpace(named.Name)
if name != "" {
m.Standbys = append(m.Standbys, name)
}
}
}
m.NumStandbys = max(raw.NumStandbys, len(m.Standbys))
return nil
}
// CephOSDMap captures summary statistics about OSDs.
type CephOSDMap struct {
NumOSDs int `json:"num_osds"`
NumUpOSDs int `json:"num_up_osds"`
NumInOSDs int `json:"num_in_osds"`
}
// CephPGMap captures placement group statistics.
type CephPGMap struct {
NumPGs int `json:"num_pgs"`
BytesTotal uint64 `json:"bytes_total"`
BytesUsed uint64 `json:"bytes_used"`
BytesAvail uint64 `json:"bytes_avail"`
DataBytes uint64 `json:"data_bytes"`
Dirty uint64 `json:"dirty"`
Unfound uint64 `json:"unfound"`
DegradedRatio float64 `json:"degraded_ratio"`
}
// CephDF represents the data returned by /cluster/ceph/df.
type CephDF struct {
Data CephDFData `json:"data"`
}
// CephDFData wraps high-level capacity stats and per-pool information.
type CephDFData struct {
Stats CephDFStats `json:"stats"`
Pools []CephDFPool `json:"pools"`
}
// CephDFStats captures total cluster capacity usage metrics.
type CephDFStats struct {
TotalBytes uint64 `json:"total_bytes"`
TotalUsedBytes uint64 `json:"total_used_bytes"`
TotalAvailBytes uint64 `json:"total_avail_bytes"`
RawUsedBytes uint64 `json:"total_used_raw_bytes"`
PercentUsed float64 `json:"percent_used"`
}
// CephDFPool represents usage metrics for an individual Ceph pool.
type CephDFPool struct {
ID int `json:"id"`
Name string `json:"name"`
Stats CephDFPoolStat `json:"stats"`
}
// CephDFPoolStat represents statistics for a Ceph pool returned by /cluster/ceph/df.
type CephDFPoolStat struct {
BytesUsed uint64 `json:"bytes_used"`
KBUsed uint64 `json:"kb_used"`
MaxAvail uint64 `json:"max_avail"`
Objects uint64 `json:"objects"`
PercentUsed float64 `json:"percent_used"`
Dirty uint64 `json:"dirty"`
RawBytesUsed uint64 `json:"stored_raw"`
}
// GetCephStatus fetches Ceph status information for the cluster.
func (c *Client) GetCephStatus(ctx context.Context) (*CephStatus, error) {
resp, err := c.get(ctx, "/cluster/ceph/status")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data CephStatus `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result.Data, nil
}
// GetCephDF fetches Ceph capacity information (/cluster/ceph/df).
func (c *Client) GetCephDF(ctx context.Context) (*CephDF, error) {
resp, err := c.get(ctx, "/cluster/ceph/df")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result struct {
Data CephDF `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result.Data, nil
}