Files
mesh-catalog/modules/time-sync/cmd/time-sync-tools/timesync.go
T
jochen 21d8a7f6b4 time-sync: one time daemon, timesyncd, with its servers declared
Three machines ran timesyncd and one ran ntpd. The module declares
timesyncd running with a 50-mesh.conf drop-in (European pool) and ntp absent
(hq ADR 0180). A run-once step of its Go binary stops and disables ntpd first
and takes out only dangling wants-links, so removing the package leaves no
enabled unit pointing at nothing. A provider's drop-in sorting after the
mesh's still wins and is reported, not removed. Tools: time_sync_status,
_servers, _sync_now (to-be 42 Phase 1).
2026-10-04 12:50:20 +02:00

331 lines
10 KiB
Go

package main
// systemd-timesyncd as the machine's one time daemon (novox/hq to-be 42 Phase 1, research 027/01:
// "two daemons across four machines"). Three machines ran timesyncd; one ran ntpd with timesyncd
// disabled. The module declares timesyncd running with its servers in a drop-in, and ntp absent
// (ADR 0180). Removing a package leaves the links that enabled its units behind, so before it goes
// the module's step stops and disables ntpd (`retire`, below) — and on a machine where it is gone
// already, takes out a link left pointing at nothing.
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
// The unit and the drop-in the manifest declares.
const (
Daemon = "systemd-timesyncd.service"
MeshDropIn = "/etc/systemd/timesyncd.conf.d/50-mesh.conf"
)
// OtherDaemons are the time daemons that are not timesyncd, reported wherever they are found.
var OtherDaemons = []string{"ntpd.service", "chronyd.service", "openntpd.service"}
// Unit is a unit's state as the service manager reports it.
type Unit struct {
Unit string `json:"unit"`
Load string `json:"load"`
Active string `json:"active"`
Boot string `json:"boot"`
}
func (m *Machine) unit(name string) (Unit, error) {
p, err := m.unitProps(name, "LoadState", "ActiveState", "UnitFileState")
if err != nil {
return Unit{}, err
}
return Unit{Unit: name, Load: p["LoadState"], Active: p["ActiveState"], Boot: p["UnitFileState"]}, nil
}
// Status is whether the clock is synchronised, and from where.
type Status struct {
Synchronized bool `json:"synchronized"`
NTPEnabled bool `json:"ntp_enabled"`
Timesyncd Unit `json:"timesyncd"`
Server string `json:"server,omitempty"`
OffsetMS *float64 `json:"offset_ms,omitempty"`
DelayMS *float64 `json:"delay_ms,omitempty"`
JitterMS *float64 `json:"jitter_ms,omitempty"`
Stratum int `json:"stratum,omitempty"`
PacketCount int `json:"packet_count,omitempty"`
Raw map[string]string `json:"timesync_status,omitempty"`
Others []Unit `json:"other_daemons"`
Error string `json:"timesync_error,omitempty"`
}
// ParseTimesyncStatus reads `timedatectl timesync-status`: aligned `Label: value` lines.
func ParseTimesyncStatus(out string) map[string]string {
kv := map[string]string{}
for _, l := range strings.Split(out, "\n") {
k, v, ok := strings.Cut(l, ": ")
if ok {
kv[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
}
return kv
}
var duration = regexp.MustCompile(`^([+-]?[0-9.]+)(ns|us|µs|ms|s|min)$`)
// Millis is one of timedatectl's durations ("-1.949ms", "+27us", "1.2s") in milliseconds.
func Millis(s string) *float64 {
m := duration.FindStringSubmatch(strings.TrimSpace(s))
if m == nil {
return nil
}
v, err := strconv.ParseFloat(m[1], 64)
if err != nil {
return nil
}
switch m[2] {
case "ns":
v /= 1e6
case "us", "µs":
v /= 1e3
case "s":
v *= 1e3
case "min":
v *= 60e3
}
return &v
}
// Status reads timedatectl and the time daemons' units. timesyncd not running is an answer — the
// machine is not synchronised by it — and is said beside the rest rather than failing the call.
func (m *Machine) Status() (Status, error) {
s := Status{Others: []Unit{}}
td, err := m.Out("timedatectl", "show")
if err != nil {
return s, err
}
kv := keyValues(td, "=")
s.Synchronized, s.NTPEnabled = kv["NTPSynchronized"] == "yes", kv["NTP"] == "yes"
if s.Timesyncd, err = m.unit(Daemon); err != nil {
return s, err
}
for _, name := range OtherDaemons {
u, err := m.unit(name)
if err != nil {
return s, err
}
if u.Load != "not-found" {
s.Others = append(s.Others, u)
}
}
r := m.Run(bg(), "timedatectl", "timesync-status")
if r.Status != 0 || r.Err != "" {
s.Error = failure("timedatectl", "timedatectl", r).Error()
return s, nil
}
s.Raw = ParseTimesyncStatus(r.Stdout)
s.Server = s.Raw["Server"]
s.OffsetMS, s.DelayMS, s.JitterMS = Millis(s.Raw["Offset"]), Millis(s.Raw["Delay"]), Millis(s.Raw["Jitter"])
s.Stratum, _ = strconv.Atoi(s.Raw["Stratum"])
s.PacketCount, _ = strconv.Atoi(s.Raw["Packet count"])
return s, nil
}
// ConfigFile is one file timesyncd reads, with the servers it sets.
type ConfigFile struct {
Path string `json:"path"`
NTP []string `json:"ntp,omitempty"`
SetsNTP bool `json:"sets_ntp"`
FallbackNTP []string `json:"fallback_ntp,omitempty"`
SetsFallback bool `json:"sets_fallback_ntp"`
Mesh bool `json:"mesh_owned"`
}
// Servers is which servers timesyncd uses, and which file decided them.
type Servers struct {
ServerName string `json:"server_name,omitempty"`
ServerAddress string `json:"server_address,omitempty"`
System []string `json:"system_servers"`
Fallback []string `json:"fallback_servers"`
Link []string `json:"link_servers"`
Runtime []string `json:"runtime_servers"`
Files []ConfigFile `json:"files"`
NTPDecidedBy string `json:"ntp_decided_by,omitempty"`
FallbackDecidedBy string `json:"fallback_decided_by,omitempty"`
Note string `json:"note,omitempty"`
}
var fileHeader = regexp.MustCompile(`^# (/\S+)$`)
// ParseCatConfig reads `systemd-analyze cat-config systemd/timesyncd.conf`: each file under a
// `# /path` header, in the order timesyncd reads them, with what it sets of NTP= and FallbackNTP=.
func ParseCatConfig(out string) []ConfigFile {
var files []ConfigFile
prevBlank := true
for _, raw := range strings.Split(out, "\n") {
line := strings.TrimSpace(raw)
if h := fileHeader.FindStringSubmatch(line); h != nil && prevBlank {
files = append(files, ConfigFile{Path: h[1], Mesh: h[1] == MeshDropIn})
prevBlank = false
continue
}
prevBlank = line == ""
if len(files) == 0 || line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
f := &files[len(files)-1]
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
switch strings.TrimSpace(k) {
case "NTP":
// An empty assignment resets the list; a later one adds to it.
if strings.TrimSpace(v) == "" {
f.NTP = nil
}
f.NTP, f.SetsNTP = append(f.NTP, strings.Fields(v)...), true
case "FallbackNTP":
if strings.TrimSpace(v) == "" {
f.FallbackNTP = nil
}
f.FallbackNTP, f.SetsFallback = append(f.FallbackNTP, strings.Fields(v)...), true
}
}
return files
}
// Servers reads timesyncd's servers in force and the files that set them.
func (m *Machine) Servers() (Servers, error) {
s := Servers{}
show, err := m.Out("timedatectl", "show-timesync", "--all")
if err != nil {
return s, err
}
kv := keyValues(show, "=")
s.ServerName, s.ServerAddress = kv["ServerName"], kv["ServerAddress"]
s.System, s.Fallback = fields(kv["SystemNTPServers"]), fields(kv["FallbackNTPServers"])
s.Link, s.Runtime = fields(kv["LinkNTPServers"]), fields(kv["RuntimeNTPServers"])
cat, err := m.Out("systemd-analyze", "cat-config", "systemd/timesyncd.conf")
if err != nil {
return s, err
}
s.Files = ParseCatConfig(cat)
if s.Files == nil {
s.Files = []ConfigFile{}
}
for _, f := range s.Files {
if f.SetsNTP {
s.NTPDecidedBy = f.Path
}
if f.SetsFallback {
s.FallbackDecidedBy = f.Path
}
}
if s.NTPDecidedBy != "" && s.NTPDecidedBy != MeshDropIn {
for _, f := range s.Files {
if f.Mesh {
s.Note = fmt.Sprintf("%s sorts after the mesh's drop-in and its servers are the ones used; the mesh keeps it as found", s.NTPDecidedBy)
}
}
}
return s, nil
}
func fields(s string) []string {
f := strings.Fields(s)
if f == nil {
return []string{}
}
return f
}
// SyncNow restarts timesyncd, which asks its server at once, and waits a little for an answer.
func (m *Machine) SyncNow() (Status, error) {
if _, err := m.Root("systemctl", "restart", Daemon); err != nil {
return Status{}, err
}
var s Status
var err error
for i := 0; i < 10; i++ {
m.Sleep(time.Second)
if s, err = m.Status(); err != nil {
return s, err
}
if s.Error == "" && s.PacketCount > 0 {
break
}
}
return s, nil
}
// Retired is what the retire step did.
type Retired struct {
Disabled []string
Removed []string
}
// Retire is the module's step, run once by the host as root before ntp is removed: each named unit
// that is installed is stopped and disabled; a link left in the service manager's wants directories
// pointing at a unit that is no longer installed is taken out. It never touches a link it can still
// follow.
func (m *Machine) Retire(units []string, wants func(unit string) ([]string, error), dangling func(path string) bool, remove func(path string) error) (Retired, error) {
var r Retired
for _, name := range units {
if !strings.HasSuffix(name, ".service") || strings.ContainsAny(name, "/ ") || strings.HasPrefix(name, "-") {
return r, fmt.Errorf("%q is not a service's unit name", name)
}
u, err := m.unit(name)
if err != nil {
return r, err
}
if u.Load == "loaded" && (u.Boot == "enabled" || u.Active == "active" || u.Active == "activating") {
if _, err := m.Root("systemctl", "disable", "--now", name); err != nil {
return r, err
}
r.Disabled = append(r.Disabled, name)
}
links, err := wants(name)
if err != nil {
return r, err
}
for _, link := range links {
if !dangling(link) {
continue
}
if err := remove(link); err != nil {
return r, fmt.Errorf("taking out %s, a link to a unit no longer installed: %w", link, err)
}
r.Removed = append(r.Removed, link)
}
}
if len(r.Removed) > 0 {
if _, err := m.Root("systemctl", "daemon-reload"); err != nil {
return r, err
}
}
return r, nil
}
// Wants is every link to a unit in the system manager's wants and requires directories under /etc.
func Wants(unit string) ([]string, error) {
var out []string
for _, kind := range []string{"wants", "requires"} {
found, err := filepath.Glob(filepath.Join("/etc/systemd/system", "*."+kind, unit))
if err != nil {
return nil, err
}
out = append(out, found...)
}
return out, nil
}
// Dangling is whether a path is a symbolic link whose target is gone.
func Dangling(path string) bool {
fi, err := os.Lstat(path)
if err != nil || fi.Mode()&os.ModeSymlink == 0 {
return false
}
_, err = os.Stat(path)
return os.IsNotExist(err)
}