fleet-manager: chrony tracking metrics relative to the NTP source
The agent only reported chrony as synced|unsynced — nothing about how well the
clock tracks. Parse `chronyc tracking` for the source-relative metrics.
- proto: HealthSnapshot.chrony_offset_seconds (16, signed system-time offset,
fast=+), chrony_source (17, reference name/IP), chrony_stratum (18); regenerated.
- agent: replace chronyState() with chronyTracking()/parseChrony() — one exec,
parses Leap status + Reference ID + Stratum + System time offset (+ test).
- manager: Health.{ChronyOffset,ChronySource,ChronyStratum}; snapshotToHealth maps
them; influx writes chrony_offset_seconds/_stratum/_source and whitelists the
offset + stratum as graphable fields.
- UI: chronySummary() + offsetMs() — the chrony row now shows
"synced · 192.168.50.10 · s3 · +0.01ms" instead of just "synced".
This commit is contained in:
+69
-21
@@ -86,19 +86,23 @@ type prober struct {
|
||||
// receive-time (matches agent.go's snapshotToHealth path).
|
||||
func (p *prober) snapshot(ctx context.Context) *fleetpb.HealthSnapshot {
|
||||
f := fetchFacts(ctx, p.hostapiURL)
|
||||
ch := chronyTracking()
|
||||
s := &fleetpb.HealthSnapshot{
|
||||
LoadAvg: loadAvg(),
|
||||
CpuPct: p.cpuPct(),
|
||||
TempC: tempC(),
|
||||
MemUsedPct: memUsedPct(),
|
||||
TmpfsUsedPct: fsUsedPct("/"),
|
||||
NvmeRoot: nvmeRoot(),
|
||||
Auth: authState(),
|
||||
Selinux: selinuxState(),
|
||||
Chrony: chronyState(),
|
||||
KioskActive: unitActive("kiosk.service"),
|
||||
SessionUptime: f.Kiosk.Uptime,
|
||||
UptimeSeconds: uptimeSeconds(),
|
||||
LoadAvg: loadAvg(),
|
||||
CpuPct: p.cpuPct(),
|
||||
TempC: tempC(),
|
||||
MemUsedPct: memUsedPct(),
|
||||
TmpfsUsedPct: fsUsedPct("/"),
|
||||
NvmeRoot: nvmeRoot(),
|
||||
Auth: authState(),
|
||||
Selinux: selinuxState(),
|
||||
Chrony: ch.state,
|
||||
ChronyOffsetSeconds: ch.offset,
|
||||
ChronySource: ch.source,
|
||||
ChronyStratum: ch.stratum,
|
||||
KioskActive: unitActive("kiosk.service"),
|
||||
SessionUptime: f.Kiosk.Uptime,
|
||||
UptimeSeconds: uptimeSeconds(),
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -291,22 +295,66 @@ func selinuxState() string {
|
||||
return "permissive"
|
||||
}
|
||||
|
||||
// chronyState best-effort: "synced" when chronyc tracking reports a Normal leap
|
||||
// status, "unsynced" otherwise, "" if chronyc is unavailable.
|
||||
func chronyState() string {
|
||||
// chrony holds the parsed `chronyc tracking` fields relative to the NTP source.
|
||||
type chrony struct {
|
||||
state string // synced|unsynced|"" (unavailable)
|
||||
offset float64 // system time offset from NTP, seconds (signed: fast=+, slow=-)
|
||||
source string // reference source name/IP (which server)
|
||||
stratum int32 // hops from a reference clock
|
||||
}
|
||||
|
||||
// chronyTracking runs `chronyc tracking` once and parses it. Best-effort: a zero
|
||||
// chrony{} (state "") if chronyc is unavailable — never fails the snapshot.
|
||||
func chronyTracking() chrony {
|
||||
out, err := runShort("chronyc", "-n", "tracking")
|
||||
if err != nil {
|
||||
return ""
|
||||
return chrony{}
|
||||
}
|
||||
return parseChrony(out)
|
||||
}
|
||||
|
||||
// parseChrony extracts the source/offset/stratum/leap fields from `chronyc
|
||||
// tracking` key:value output. Pure (unit-testable).
|
||||
func parseChrony(out string) chrony {
|
||||
c := chrony{state: "unsynced"}
|
||||
for line := range strings.SplitSeq(out, "\n") {
|
||||
if strings.HasPrefix(line, "Leap status") {
|
||||
if strings.Contains(line, "Normal") {
|
||||
return "synced"
|
||||
key, val, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
val = strings.TrimSpace(val)
|
||||
switch key {
|
||||
case "Leap status":
|
||||
if strings.Contains(val, "Normal") {
|
||||
c.state = "synced"
|
||||
}
|
||||
case "Stratum":
|
||||
if n, err := strconv.Atoi(strings.Fields(val)[0]); err == nil {
|
||||
c.stratum = int32(n)
|
||||
}
|
||||
case "Reference ID":
|
||||
// "C0A8320A (192.168.50.10)" — prefer the parenthesised name/IP.
|
||||
c.source = val
|
||||
if i := strings.IndexByte(val, '('); i >= 0 {
|
||||
if j := strings.IndexByte(val[i:], ')'); j > 1 {
|
||||
c.source = val[i+1 : i+j]
|
||||
}
|
||||
}
|
||||
case "System time":
|
||||
// "0.000012345 seconds slow of NTP time" — magnitude + direction.
|
||||
fields := strings.Fields(val)
|
||||
if len(fields) >= 3 {
|
||||
if v, err := strconv.ParseFloat(fields[0], 64); err == nil {
|
||||
if fields[2] == "slow" { // system behind NTP -> negative
|
||||
v = -v
|
||||
}
|
||||
c.offset = v
|
||||
}
|
||||
}
|
||||
return "unsynced"
|
||||
}
|
||||
}
|
||||
return "unsynced"
|
||||
return c
|
||||
}
|
||||
|
||||
// unitActive reports whether a systemd unit is active (best-effort).
|
||||
|
||||
Reference in New Issue
Block a user