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).
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user