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:
Audiobuzz
2026-07-10 06:47:27 +00:00
parent df7dad81b7
commit 1c68c37824
11 changed files with 222 additions and 45 deletions
+33 -4
View File
@@ -278,8 +278,13 @@ type HealthSnapshot struct {
ChromiumCrashes int32 `protobuf:"varint,13,opt,name=chromium_crashes,json=chromiumCrashes,proto3" json:"chromium_crashes,omitempty"`
Display string `protobuf:"bytes,14,opt,name=display,proto3" json:"display,omitempty"`
UptimeSeconds int64 `protobuf:"varint,15,opt,name=uptime_seconds,json=uptimeSeconds,proto3" json:"uptime_seconds,omitempty"` // HOST uptime (seconds since boot) — boot_time =
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// manager receive-time - this; drives reboot-pending
// chrony tracking relative to the NTP source (from `chronyc tracking`):
ChronyOffsetSeconds float64 `protobuf:"fixed64,16,opt,name=chrony_offset_seconds,json=chronyOffsetSeconds,proto3" json:"chrony_offset_seconds,omitempty"` // system time offset from NTP (signed, fast=+)
ChronySource string `protobuf:"bytes,17,opt,name=chrony_source,json=chronySource,proto3" json:"chrony_source,omitempty"` // reference source name/IP (which server)
ChronyStratum int32 `protobuf:"varint,18,opt,name=chrony_stratum,json=chronyStratum,proto3" json:"chrony_stratum,omitempty"` // hops from a reference clock (0 = unsynced)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *HealthSnapshot) Reset() {
@@ -417,6 +422,27 @@ func (x *HealthSnapshot) GetUptimeSeconds() int64 {
return 0
}
func (x *HealthSnapshot) GetChronyOffsetSeconds() float64 {
if x != nil {
return x.ChronyOffsetSeconds
}
return 0
}
func (x *HealthSnapshot) GetChronySource() string {
if x != nil {
return x.ChronySource
}
return ""
}
func (x *HealthSnapshot) GetChronyStratum() int32 {
if x != nil {
return x.ChronyStratum
}
return 0
}
type CommandResult struct {
state protoimpl.MessageState `protogen:"open.v1"`
CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
@@ -696,7 +722,7 @@ const file_proto_fleet_agent_proto_rawDesc = "" +
"\rrootfs_digest\x18\x02 \x01(\tR\frootfsDigest\x12\x16\n" +
"\x06kernel\x18\x03 \x01(\tR\x06kernel\x12\x1b\n" +
"\tkiosk_url\x18\x04 \x01(\tR\bkioskUrl\x12#\n" +
"\rstarship_name\x18\x05 \x01(\tR\fstarshipName\"\xd5\x03\n" +
"\rstarship_name\x18\x05 \x01(\tR\fstarshipName\"\xd5\x04\n" +
"\x0eHealthSnapshot\x12\x17\n" +
"\aat_unix\x18\x01 \x01(\x03R\x06atUnix\x12\x19\n" +
"\bload_avg\x18\x02 \x01(\x01R\aloadAvg\x12\x17\n" +
@@ -714,7 +740,10 @@ const file_proto_fleet_agent_proto_rawDesc = "" +
"\x0esession_uptime\x18\f \x01(\tR\rsessionUptime\x12)\n" +
"\x10chromium_crashes\x18\r \x01(\x05R\x0fchromiumCrashes\x12\x18\n" +
"\adisplay\x18\x0e \x01(\tR\adisplay\x12%\n" +
"\x0euptime_seconds\x18\x0f \x01(\x03R\ruptimeSeconds\"V\n" +
"\x0euptime_seconds\x18\x0f \x01(\x03R\ruptimeSeconds\x122\n" +
"\x15chrony_offset_seconds\x18\x10 \x01(\x01R\x13chronyOffsetSeconds\x12#\n" +
"\rchrony_source\x18\x11 \x01(\tR\fchronySource\x12%\n" +
"\x0echrony_stratum\x18\x12 \x01(\x05R\rchronyStratum\"V\n" +
"\rCommandResult\x12\x1d\n" +
"\n" +
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x0e\n" +
+4
View File
@@ -59,6 +59,10 @@ message HealthSnapshot {
string display = 14;
int64 uptime_seconds = 15; // HOST uptime (seconds since boot) — boot_time =
// manager receive-time - this; drives reboot-pending
// chrony tracking relative to the NTP source (from `chronyc tracking`):
double chrony_offset_seconds = 16; // system time offset from NTP (signed, fast=+)
string chrony_source = 17; // reference source name/IP (which server)
int32 chrony_stratum = 18; // hops from a reference clock (0 = unsynced)
}
message CommandResult {
+69 -21
View File
@@ -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).
+33
View File
@@ -68,6 +68,39 @@ func TestParseUptime(t *testing.T) {
}
}
func TestParseChrony(t *testing.T) {
out := `Reference ID : C0A8320A (192.168.50.10)
Stratum : 3
Ref time (UTC) : Fri Jul 10 06:00:00 2026
System time : 0.000012345 seconds slow of NTP time
Last offset : +0.000001234 seconds
Frequency : 12.345 ppm slow
Leap status : Normal
`
c := parseChrony(out)
if c.state != "synced" {
t.Errorf("state = %q, want synced", c.state)
}
if c.source != "192.168.50.10" {
t.Errorf("source = %q, want 192.168.50.10", c.source)
}
if c.stratum != 3 {
t.Errorf("stratum = %d, want 3", c.stratum)
}
// "slow of NTP time" -> negative offset (sign-sensitive check).
if c.offset >= 0 || math.Abs(c.offset+0.000012345) > 1e-9 {
t.Errorf("offset = %v, want -0.000012345", c.offset)
}
// unsynced when Leap status isn't Normal.
if c := parseChrony("Leap status : Not synchronised\n"); c.state != "unsynced" {
t.Errorf("state = %q, want unsynced", c.state)
}
// empty / unavailable.
if c := parseChrony(""); c.state != "unsynced" || c.source != "" {
t.Errorf("empty parse = %+v", c)
}
}
func TestParseHandle(t *testing.T) {
for _, tc := range []struct {
in string