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:
jochen
2026-10-04 12:50:20 +02:00
parent a8d308d440
commit 21d8a7f6b4
11 changed files with 1263 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
# time-sync
The machine's clock, kept by one daemon: systemd-timesyncd (novox/hq to-be 42 Phase 1,
research 027).
## What it owns
- `/etc/systemd/timesyncd.conf.d/50-mesh.conf`, written whole: `NTP=` the four European pool
servers, `FallbackNTP=` the distribution's pool.
- `systemd-timesyncd.service`, running and enabled, and restarted when the drop-in changes.
- `ntp`, declared **absent** (ADR 0180).
- A **step**, `time-sync-retire-ntpd`. The host runs the module's own binary once per version of the
bundle, as root, *before* timesyncd is started and ntp removed:
`time-sync-tools retire ntpd.service ntpdate.service`. The step stops and disables each unit that
is installed and running or enabled. Removing a package does not disable its units, so without the
step ntp's removal would leave `multi-user.target.wants/ntpd.service` pointing at nothing. Where
the package is already gone, the step takes out only such a dangling link, never a link it can
still follow.
## What it improves
- One daemon on every machine. Three ran timesyncd and one ran ntpd, with timesyncd disabled.
- The servers are declared, not left to whatever a machine was installed with. One machine had
edited `timesyncd.conf` itself; the drop-in now overrides that.
## What it leaves found
- **A hosting provider's own drop-in.** On a machine whose provider installed a timesyncd drop-in
(found on the anchor), that file sorts after `50-mesh.conf`, so its servers win. They are in the
same network as the machine. It is kept, and `time_sync_servers` names it as the file that decides.
- `/etc/systemd/timesyncd.conf`, and an `/etc/ntp.conf` that the package manager keeps as `.pacsave`.
## Tools
| tool | | answers |
|---|---|---|
| `time_sync_status` | r | synchronised, NTP on, timesyncd's unit; server, offset, delay, jitter (ms), stratum, packets, and every line of `timesync-status`; any other time daemon installed. If timesyncd is not running, that is said, not failed |
| `time_sync_servers` | r | the server in use; system, fallback, link and runtime servers; every config file in reading order with what it sets; which file decides |
| `time_sync_sync_now` | a | restarts timesyncd (sudo -n) and answers the status after waiting up to 10 s for a packet |
@@ -0,0 +1,289 @@
package main
// The commands this bundle runs on its machine, and who runs them.
//
// Who asks. The node's tool runtime runs as the operator account, not root (novox/hq ADR 0175 §4),
// and launches this binary as a process of its own (ADR 0188, ADR 0193) with the runtime's words —
// HOME, a PATH, MESH_OPERATOR_ACCOUNT — and no session words. Reading needs nothing more; what only
// root may do goes through `sudo -n`, as the packet filter's, the service manager's and the
// intrusion prevention's tools do (to-be 38 WP4), and the `sudo` module is what declares that the
// account may (to-be 42, research 027). A refusal is named by how it failed, never read as an
// empty answer.
//
// The runner is injected, so every tool is tested over a fake one without the machine.
import (
"bytes"
"context"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"strings"
"time"
)
// Ran is what one command did: its output, its exit status, and why it never ran to an answer.
type Ran struct {
Stdout string
Stderr string
Status int
// Err is "ENOENT" when the program is not there, or that it was ended for taking too long.
Err string
}
// Runner runs one command, so the tools can be tested without the machine.
type Runner func(ctx context.Context, name string, args ...string) Ran
// CallTimeout is how long one command may take: below the runtime's thirty-second call limit, so a
// command that hangs is answered as such rather than as a call the runtime gave up on.
const CallTimeout = 20 * time.Second
// outputLimit bounds what one command may hand back, so a runaway listing cannot exhaust the
// process; well above anything a tool answers.
const outputLimit = 16 << 20
type bounded struct {
bytes.Buffer
cut bool
}
func (b *bounded) Write(p []byte) (int, error) {
if room := outputLimit - b.Len(); room < len(p) {
if room > 0 {
b.Buffer.Write(p[:room])
}
b.cut = true
return len(p), nil
}
return b.Buffer.Write(p)
}
// ExecRunner runs a command on this machine, in the C locale so what is parsed is one language.
func ExecRunner(ctx context.Context, name string, args ...string) Ran {
ctx, cancel := context.WithTimeout(ctx, CallTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C")
var out, errb bounded
cmd.Stdout, cmd.Stderr = &out, &errb
err := cmd.Run()
r := Ran{Stdout: out.String(), Stderr: errb.String()}
if ctx.Err() == context.DeadlineExceeded {
r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout.Seconds()))
return r
}
var exit *exec.ExitError
switch {
case err == nil:
case errors.As(err, &exit):
r.Status = exit.ExitCode()
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist):
r.Status, r.Err = 127, "ENOENT"
default:
r.Status, r.Err = 126, err.Error()
}
return r
}
// Escalated is the command as it is run: as given when this process is root, else through sudo
// without a prompt.
func Escalated(uid int, name string, args ...string) (string, []string) {
if uid == 0 {
return name, args
}
return "sudo", append([]string{"-n", name}, args...)
}
// Machine is this machine as the tools see it: a runner, who this process is, and its files.
type Machine struct {
Run Runner
UID int
User string
Account string
ReadFile func(path string) ([]byte, error)
Now func() time.Time
Sleep func(time.Duration)
}
// ThisMachine is the machine the runtime launched this bundle on.
func ThisMachine() *Machine {
user := os.Getenv("USER")
if user == "" {
user = os.Getenv("LOGNAME")
}
account := strings.TrimSpace(os.Getenv("MESH_OPERATOR_ACCOUNT"))
if account == "" {
account = user
}
return &Machine{Run: ExecRunner, UID: os.Getuid(), User: user, Account: account, ReadFile: os.ReadFile, Now: time.Now, Sleep: time.Sleep}
}
// Out runs a command that only reads, and fails with what went wrong named.
func (m *Machine) Out(name string, args ...string) (string, error) {
r := m.Run(context.Background(), name, args...)
if r.Status == 0 && r.Err == "" {
return r.Stdout, nil
}
return r.Stdout, failure(name, name, r)
}
// Root runs a command that needs root, escalated when this process is not.
func (m *Machine) Root(name string, args ...string) (string, error) {
program, argv := Escalated(m.UID, name, args...)
r := m.Run(context.Background(), program, argv...)
if r.Status == 0 && r.Err == "" {
return r.Stdout, nil
}
return r.Stdout, failure(name, program, r)
}
// RootRan is Root's raw answer, for a command whose non-zero status is itself an answer.
func (m *Machine) RootRan(name string, args ...string) (Ran, error) {
program, argv := Escalated(m.UID, name, args...)
r := m.Run(context.Background(), program, argv...)
if r.Err != "" || (program == "sudo" && sudoRefused(r)) {
return r, failure(name, program, r)
}
return r, nil
}
func sudoRefused(r Ran) bool {
return strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:")
}
// failure names what failed by how it failed: the program missing is a spawn error, sudo missing
// or refusing speaks for itself, and the rest is the command's own first line.
func failure(cmd, program string, r Ran) error {
said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout)
if r.Err == "ENOENT" {
if program == "sudo" {
return fmt.Errorf("%s needs root for this, and sudo is not installed here for the runtime's account to escalate with", cmd)
}
return fmt.Errorf("%s is not installed on this machine", cmd)
}
if r.Err != "" {
return fmt.Errorf("%s did not answer: %s", cmd, r.Err)
}
if program == "sudo" && sudoRefused(r) {
if strings.Contains(said, "command not found") {
return fmt.Errorf("%s is not installed on this machine", cmd)
}
return fmt.Errorf("%s needs root for this and the runtime's account may not run it without a prompt: %s", cmd, firstLine(said))
}
if line := firstLine(said); line != "" {
return fmt.Errorf("%s failed (%d): %s", cmd, r.Status, line)
}
return fmt.Errorf("%s failed with status %d", cmd, r.Status)
}
func firstLine(text string) string {
for _, l := range strings.Split(text, "\n") {
if l = strings.TrimSpace(l); l != "" {
return l
}
}
return ""
}
func lines(text string) []string {
var out []string
for _, l := range strings.Split(text, "\n") {
if l = strings.TrimRight(l, "\r"); strings.TrimSpace(l) != "" {
out = append(out, l)
}
}
return out
}
// text is a string argument; required says whether it may be absent. It is never something a
// command would read as an option, which under sudo would be root's option.
func text(args map[string]any, key string, required bool) (string, error) {
raw, present := args[key]
if !present || raw == nil {
if required {
return "", fmt.Errorf("%s is required", key)
}
return "", nil
}
s, ok := raw.(string)
if !ok {
return "", fmt.Errorf("%s must be a string", key)
}
s = strings.TrimSpace(s)
if required && s == "" {
return "", fmt.Errorf("%s is required", key)
}
if strings.HasPrefix(s, "-") || strings.ContainsRune(s, 0) || strings.ContainsAny(s, "\n\r") {
return "", fmt.Errorf("%s %q is not a value this tool passes on", key, s)
}
return s, nil
}
// whole is a whole-number argument with a default, kept within bounds.
func whole(args map[string]any, key string, def, least, most int) (int, error) {
raw, present := args[key]
if !present || raw == nil {
return def, nil
}
f, ok := raw.(float64)
if !ok || f != float64(int(f)) {
return 0, fmt.Errorf("%s must be a whole number", key)
}
n := int(f)
if n < least {
return 0, fmt.Errorf("%s must be at least %d", key, least)
}
if n > most {
n = most
}
return n, nil
}
// flag is a boolean argument, false when absent.
func flag(args map[string]any, key string) (bool, error) {
raw, present := args[key]
if !present || raw == nil {
return false, nil
}
b, ok := raw.(bool)
if !ok {
return false, fmt.Errorf("%s must be true or false", key)
}
return b, nil
}
// schema is a tool's input: its properties and the ones it requires.
func schema(properties map[string]any, required ...string) map[string]any {
s := map[string]any{"type": "object", "properties": properties}
if len(required) > 0 {
s["required"] = required
}
return s
}
// unitProps reads a unit's properties as systemctl shows them.
func (m *Machine) unitProps(unit string, props ...string) (map[string]string, error) {
args := []string{"show", unit, "--no-pager"}
for _, p := range props {
args = append(args, "--property="+p)
}
out, err := m.Out("systemctl", args...)
if err != nil {
return nil, err
}
return keyValues(out, "="), nil
}
// keyValues reads `key<sep>value` lines; a line without the separator is skipped.
func keyValues(out, sep string) map[string]string {
kv := map[string]string{}
for _, l := range strings.Split(out, "\n") {
k, v, ok := strings.Cut(l, sep)
if ok {
kv[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
}
return kv
}
@@ -0,0 +1,107 @@
package main
import (
"context"
"strings"
"testing"
"time"
)
// call is one command a fake runner was asked to run.
type call struct {
name string
args []string
}
func (c call) String() string {
if len(c.args) == 0 {
return c.name
}
return c.name + " " + strings.Join(c.args, " ")
}
// fake is a runner answering by the command line it is given, recording every call.
func fake(answer func(c call) Ran, calls *[]call) Runner {
return func(_ context.Context, name string, args ...string) Ran {
c := call{name, append([]string(nil), args...)}
if calls != nil {
*calls = append(*calls, c)
}
return answer(c)
}
}
// byLine answers from a table keyed by the whole command line, and refuses anything else as a
// command the test did not expect.
func byLine(table map[string]Ran, calls *[]call) Runner {
return fake(func(c call) Ran {
if r, ok := table[c.String()]; ok {
return r
}
return Ran{Status: 99, Stderr: "unexpected command: " + c.String()}
}, calls)
}
func machine(run Runner, uid int) *Machine {
return &Machine{Run: run, UID: uid, User: "operator", Account: "operator",
ReadFile: func(string) ([]byte, error) { return nil, errNoFile },
Now: func() time.Time { return time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) },
Sleep: func(time.Duration) {}}
}
type noFile struct{}
func (noFile) Error() string { return "no such file" }
var errNoFile = noFile{}
func TestAnActNeedingRootGoesThroughSudoWithoutAPromptUnlessThisIsRoot(t *testing.T) {
if p, a := Escalated(1000, "visudo", "-c"); p != "sudo" || strings.Join(a, " ") != "-n visudo -c" {
t.Fatalf("not root: %s %v", p, a)
}
if p, a := Escalated(0, "visudo", "-c"); p != "visudo" || strings.Join(a, " ") != "-c" {
t.Fatalf("root: %s %v", p, a)
}
}
func TestFailuresAreNamedNeverReadAsEmpty(t *testing.T) {
cases := []struct {
r Ran
want string
}{
{Ran{Status: 127, Err: "ENOENT"}, "sudo is not installed here"},
{Ran{Status: 1, Stderr: "sudo: a password is required\n"}, "may not run it without a prompt: sudo: a password is required"},
{Ran{Status: 124, Err: "no answer within 20 s"}, "did not answer: no answer within 20 s"},
{Ran{Status: 2, Stderr: "boom\nmore"}, "failed (2): boom"},
}
for _, c := range cases {
m := machine(fake(func(call) Ran { return c.r }, nil), 1000)
if _, err := m.Root("thing"); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%+v: %v, want %q", c.r, err, c.want)
}
}
m := machine(fake(func(call) Ran { return Ran{Status: 127, Err: "ENOENT"} }, nil), 1000)
if _, err := m.Out("thing"); err == nil || !strings.Contains(err.Error(), "thing is not installed") {
t.Errorf("a missing program: %v", err)
}
}
func TestAnArgumentIsNeverAnOption(t *testing.T) {
for _, bad := range []any{"-rf", "a\nb", 3.0} {
if _, err := text(map[string]any{"x": bad}, "x", true); err == nil {
t.Errorf("%v was accepted", bad)
}
}
if s, err := text(map[string]any{"x": " ok "}, "x", true); err != nil || s != "ok" {
t.Errorf("a plain value: %q %v", s, err)
}
if _, err := text(map[string]any{}, "x", true); err == nil {
t.Error("a missing required value was accepted")
}
if n, _ := whole(map[string]any{"n": 10000.0}, "n", 5, 1, 100); n != 100 {
t.Errorf("not bounded: %d", n)
}
if _, err := whole(map[string]any{"n": 0.0}, "n", 5, 1, 100); err == nil {
t.Error("below the least was accepted")
}
}
@@ -0,0 +1,77 @@
// time-sync's tools bundle (novox/hq to-be 42 Phase 1, research 026/05), and its step.
//
// Served by the node's runtime over MCP on stdio through the Go SDK (ADR 0188, ADR 0193) when it is
// started with no arguments. Started as `time-sync-tools retire <unit>…` it is the module's step,
// which the host runs once as root for every version of the bundle (timesync.go says why).
package main
import (
"context"
"fmt"
"os"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// binaryName is what the build names this bundle's executable: the manifest's `binary`.
const binaryName = "time-sync-tools"
func bg() context.Context { return context.Background() }
func main() {
m := ThisMachine()
if len(os.Args) > 1 {
if os.Args[1] != "retire" || len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "usage: %s [retire <unit>…]\n", binaryName)
os.Exit(2)
}
r, err := m.Retire(os.Args[2:], Wants, Dangling, os.Remove)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Printf("disabled: %s; dangling links taken out: %s\n", orNone(r.Disabled), orNone(r.Removed))
return
}
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): time-sync.
if err := stdio.Serve("", tools(m)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func orNone(list []string) string {
if len(list) == 0 {
return "none"
}
return strings.Join(list, ", ")
}
func tools(m *Machine) []stdio.Tool {
return []stdio.Tool{
{
Name: "time_sync_status",
Description: "Whether the clock is synchronised and how well: NTP on, synchronised, timesyncd's unit state, the server " +
"it uses, offset, delay and jitter in milliseconds, stratum and packet count (timedatectl timesync-status, with " +
"its every line), and any other time daemon installed beside it. timesyncd not answering is said, not failed.",
Input: schema(map[string]any{}),
Run: func(map[string]any) (any, error) { return m.Status() },
},
{
Name: "time_sync_servers",
Description: "The servers timesyncd uses (the one now, the configured, the fallback, the link's and the runtime's) " +
"and every configuration file in the order it reads them with the NTP= and FallbackNTP= each sets; which file " +
"decides, and a note when a drop-in sorting after the mesh's wins.",
Input: schema(map[string]any{}),
Run: func(map[string]any) (any, error) { return m.Servers() },
},
{
Name: "time_sync_sync_now",
Description: "Restart timesyncd (sudo -n), which asks its server at once, and answer the status after up to ten " +
"seconds of waiting for its first packet.",
Input: schema(map[string]any{}),
Run: func(map[string]any) (any, error) { return m.SyncNow() },
},
}
}
@@ -0,0 +1,59 @@
package main
// The module's shape (novox/hq to-be 42 Phase 1, research 027): its servers in a drop-in timesyncd
// reads, timesyncd running and enabled and restarted when they change, ntp absent (ADR 0180) — and
// the step that retires ntpd declared before the package goes and before timesyncd is started, so the
// host, which applies a module's resources in order, never removes a daemon that is still enabled.
import (
"strings"
"testing"
)
func TestTheOrderIsServersStepDaemonThenNtpAbsent(t *testing.T) {
m := manifest(t)
var ids []string
for _, r := range m.Resources {
ids = append(ids, r["id"].(string))
}
if strings.Join(ids, " ") != "servers retire-ntpd daemon ntp" {
t.Fatalf("order: %v", ids)
}
step := m.resource(t, "retire-ntpd")
if step["type"] != "process" || step["run-once"] != true || step["user"] != nil {
t.Fatalf("step: %v", step)
}
if run := step["run"].([]any); run[0] != "./"+binaryName || run[1] != "retire" || run[2] != "ntpd.service" {
t.Fatalf("run: %v", run)
}
daemon := m.resource(t, "daemon")
if daemon["unit"] != Daemon || daemon["state"] != "running" || daemon["boot"] != "enabled" {
t.Fatalf("daemon: %v", daemon)
}
if on := daemon["restart-on"].([]any); len(on) != 1 || on[0] != "servers" {
t.Fatalf("restart-on: %v", on)
}
if ntp := m.resource(t, "ntp"); ntp["package"] != "ntp" || ntp["absent"] != true {
t.Fatalf("ntp: %v", ntp)
}
}
func TestTheDropInSetsEuropeanServersAndTheDistributionsFallback(t *testing.T) {
f := manifest(t).resource(t, "servers")
if f["path"] != MeshDropIn {
t.Fatalf("path: %v", f["path"])
}
files := ParseCatConfig("# " + MeshDropIn + "\n" + f["content"].(string))
if len(files) != 1 || len(files[0].NTP) != 4 || len(files[0].FallbackNTP) != 4 {
t.Fatalf("%+v", files)
}
for _, s := range files[0].NTP {
if !strings.HasSuffix(s, ".europe.pool.ntp.org") {
t.Errorf("%s", s)
}
}
// A drop-in is read in name order; the mesh's must sort before a provider's own, which keeps it.
if !(strings.Compare("50-mesh.conf", "provider.conf") < 0) {
t.Fatal("the mesh's drop-in no longer sorts before a provider's")
}
}
@@ -0,0 +1,80 @@
package main
import (
"encoding/json"
"os"
"testing"
)
type resource map[string]any
type manifestShape struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Claims []map[string]any `json:"claims"`
Tools []string `json:"tools"`
Resources []resource `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func manifest(t *testing.T) manifestShape {
t.Helper()
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m manifestShape
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
func (m manifestShape) resource(t *testing.T, id string) resource {
t.Helper()
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
t.Fatalf("no resource %s", id)
return nil
}
// TestToolsAreTheManifests holds the served tools and the manifest's list to one another, and the
// bundle to the shape the builder compiles and the runtime loads.
func TestToolsAreTheManifests(t *testing.T) {
m := manifest(t)
names := map[string]bool{}
for _, tool := range tools(machine(nil, 1000)) {
if names[tool.Name] {
t.Errorf("%s is served twice", tool.Name)
}
names[tool.Name] = true
}
for _, want := range m.Tools {
if !names[want] {
t.Errorf("the manifest lists %s and the bundle does not serve it", want)
}
delete(names, want)
}
if len(names) != 0 {
t.Errorf("served and not listed: %v", names)
}
var tools map[string]any
for _, a := range m.Build.Artifacts {
if a["name"] == "tools" {
tools = a
}
}
if tools == nil || tools["kind"] != "bundle" || tools["language"] != "go" || tools["system"] != "arch" ||
tools["from"] != "cmd/"+binaryName || tools["binary"] != binaryName {
t.Fatalf("the tools artifact: %v", tools)
}
if loads, _ := tools["loads"].([]any); len(loads) != 1 || loads[0] != binaryName {
t.Fatalf("loads: %v", tools["loads"])
}
}
@@ -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)
}
@@ -0,0 +1,199 @@
package main
import (
"strings"
"testing"
)
const timesyncStatus = ` Server: 185.51.192.63 (0.arch.pool.ntp.org)
Poll interval: 8min 32s (min: 32s; max 34min 8s)
Leap: normal
Version: 4
Stratum: 2
Reference: C0AB0196
Precision: 1us (-21)
Root distance: 1.418ms (max: 5s)
Offset: -1.949ms
Delay: 27.986ms
Jitter: 1.648ms
Packet count: 4
Frequency: -8.704ppm
`
func show(load, active, boot string) Ran {
return Ran{Stdout: "LoadState=" + load + "\nActiveState=" + active + "\nUnitFileState=" + boot + "\n"}
}
func unitLine(u string) string {
return "systemctl show " + u + " --no-pager --property=LoadState --property=ActiveState --property=UnitFileState"
}
func TestDurationsAreMilliseconds(t *testing.T) {
for in, want := range map[string]float64{"-1.949ms": -1.949, "+27us": 0.027, "1.5s": 1500, "2min": 120000, "500ns": 0.0005} {
if got := Millis(in); got == nil || *got-want > 1e-9 || want-*got > 1e-9 {
t.Errorf("%s: %v", in, got)
}
}
if Millis("n/a") != nil {
t.Error("not a duration")
}
}
func TestStatusReadsTimesyncAndNamesAnotherDaemon(t *testing.T) {
m := machine(byLine(map[string]Ran{
"timedatectl show": {Stdout: "NTP=yes\nNTPSynchronized=yes\n"},
unitLine(Daemon): show("loaded", "active", "enabled"),
unitLine("ntpd.service"): show("loaded", "inactive", "disabled"),
unitLine("chronyd.service"): show("not-found", "inactive", ""),
unitLine("openntpd.service"): show("not-found", "inactive", ""),
"timedatectl timesync-status": {Stdout: timesyncStatus},
}, nil), 1000)
s, err := m.Status()
if err != nil {
t.Fatal(err)
}
if !s.Synchronized || !s.NTPEnabled || s.Timesyncd.Active != "active" || s.Server != "185.51.192.63 (0.arch.pool.ntp.org)" {
t.Fatalf("%+v", s)
}
if *s.OffsetMS != -1.949 || *s.DelayMS != 27.986 || s.Stratum != 2 || s.PacketCount != 4 || s.Raw["Leap"] != "normal" {
t.Fatalf("%+v", s)
}
if len(s.Others) != 1 || s.Others[0].Unit != "ntpd.service" {
t.Fatalf("others: %+v", s.Others)
}
}
func TestTimesyncNotRunningIsSaidBesideTheRest(t *testing.T) {
m := machine(byLine(map[string]Ran{
"timedatectl show": {Stdout: "NTP=no\nNTPSynchronized=yes\n"},
unitLine(Daemon): show("loaded", "inactive", "disabled"),
unitLine("ntpd.service"): show("loaded", "active", "enabled"),
unitLine("chronyd.service"): show("not-found", "inactive", ""),
unitLine("openntpd.service"): show("not-found", "inactive", ""),
"timedatectl timesync-status": {Status: 1, Stderr: "Command requires systemd-timesyncd.service, but it is not available: unknown unit\n"},
}, nil), 1000)
s, err := m.Status()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(s.Error, "requires systemd-timesyncd.service") || s.Others[0].Active != "active" || s.Server != "" {
t.Fatalf("%+v", s)
}
}
const catConfigAnchor = `# /etc/systemd/timesyncd.conf
# This file is part of systemd.
#
# See timesyncd.conf(5) for details.
[Time]
#NTP=
#FallbackNTP=0.arch.pool.ntp.org
# /etc/systemd/timesyncd.conf.d/50-mesh.conf
# The mesh's (module time-sync, novox/hq to-be 42)
[Time]
NTP=0.europe.pool.ntp.org 1.europe.pool.ntp.org
FallbackNTP=0.arch.pool.ntp.org
# /etc/systemd/timesyncd.conf.d/provider.conf
[Time]
NTP=ntp1.provider.example ntp2.provider.example
`
func TestServersNameTheFileThatDecidesAndAProvidersDropInWinning(t *testing.T) {
m := machine(byLine(map[string]Ran{
"timedatectl show-timesync --all": {Stdout: "LinkNTPServers=\nSystemNTPServers=ntp1.provider.example ntp2.provider.example\nRuntimeNTPServers=\nFallbackNTPServers=0.arch.pool.ntp.org\nServerName=ntp2.provider.example\nServerAddress=2001:db8::2\n"},
"systemd-analyze cat-config systemd/timesyncd.conf": {Stdout: catConfigAnchor},
}, nil), 1000)
s, err := m.Servers()
if err != nil {
t.Fatal(err)
}
if len(s.Files) != 3 || !s.Files[1].Mesh || s.Files[0].SetsNTP || len(s.Files[1].NTP) != 2 {
t.Fatalf("files: %+v", s.Files)
}
if s.NTPDecidedBy != "/etc/systemd/timesyncd.conf.d/provider.conf" || s.FallbackDecidedBy != MeshDropIn {
t.Fatalf("decided: %s / %s", s.NTPDecidedBy, s.FallbackDecidedBy)
}
if !strings.Contains(s.Note, "provider.conf sorts after the mesh's drop-in") || s.ServerName != "ntp2.provider.example" || len(s.System) != 2 || len(s.Link) != 0 {
t.Fatalf("%+v", s)
}
}
func TestAnEmptyAssignmentResetsTheList(t *testing.T) {
f := ParseCatConfig("# /etc/a.conf\n[Time]\nNTP=a b\nNTP=\nNTP=c\n")
if len(f) != 1 || strings.Join(f[0].NTP, " ") != "c" {
t.Fatalf("%+v", f)
}
}
func TestSyncNowRestartsThroughSudoAndWaitsForAPacket(t *testing.T) {
var calls []call
packets := "0"
m := machine(fake(func(c call) Ran {
switch {
case c.String() == "sudo -n systemctl restart "+Daemon:
return Ran{}
case c.String() == "timedatectl show":
return Ran{Stdout: "NTP=yes\nNTPSynchronized=yes\n"}
case c.name == "systemctl":
return show("not-found", "inactive", "")
case c.String() == "timedatectl timesync-status":
r := Ran{Stdout: strings.Replace(timesyncStatus, "Packet count: 4", "Packet count: "+packets, 1)}
packets = "1"
return r
}
return Ran{Status: 99}
}, &calls), 1000)
s, err := m.SyncNow()
if err != nil || s.PacketCount != 1 {
t.Fatalf("%+v %v", s, err)
}
if calls[0].String() != "sudo -n systemctl restart "+Daemon {
t.Fatalf("first: %s", calls[0])
}
}
func TestRetireDisablesAnInstalledDaemonAndTakesOutOnlyDanglingLinks(t *testing.T) {
var calls []call
m := machine(byLine(map[string]Ran{
unitLine("ntpd.service"): show("loaded", "active", "enabled"),
unitLine("ntpdate.service"): show("loaded", "inactive", "disabled"),
"systemctl disable --now ntpd.service": {},
"systemctl daemon-reload": {},
}, &calls), 0)
links := map[string][]string{
"ntpd.service": {"/etc/systemd/system/multi-user.target.wants/ntpd.service"},
"ntpdate.service": {"/etc/systemd/system/multi-user.target.wants/ntpdate.service"},
}
gone := map[string]bool{"/etc/systemd/system/multi-user.target.wants/ntpdate.service": true}
var removed []string
r, err := m.Retire([]string{"ntpd.service", "ntpdate.service"},
func(u string) ([]string, error) { return links[u], nil },
func(p string) bool { return gone[p] },
func(p string) error { removed = append(removed, p); return nil })
if err != nil {
t.Fatal(err)
}
if strings.Join(r.Disabled, ",") != "ntpd.service" || strings.Join(removed, ",") != "/etc/systemd/system/multi-user.target.wants/ntpdate.service" {
t.Fatalf("%+v %v", r, removed)
}
for _, c := range calls {
if c.name == "sudo" {
t.Fatal("the step runs as root")
}
}
}
func TestRetireOnAMachineWithoutTheDaemonDoesNothing(t *testing.T) {
var calls []call
m := machine(byLine(map[string]Ran{unitLine("ntpd.service"): show("not-found", "inactive", "")}, &calls), 0)
r, err := m.Retire([]string{"ntpd.service"}, func(string) ([]string, error) { return nil, nil }, func(string) bool { return false }, nil)
if err != nil || len(r.Disabled)+len(r.Removed) != 0 || len(calls) != 1 {
t.Fatalf("%+v %v %v", r, err, calls)
}
if _, err := m.Retire([]string{"../x"}, nil, nil, nil); err == nil {
t.Fatal("a path was taken for a unit")
}
}
+5
View File
@@ -0,0 +1,5 @@
module time-sync
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+76
View File
@@ -0,0 +1,76 @@
{
"module": "time-sync",
"version": "1",
"capabilities": [
"package-manager",
"service-manager"
],
"tools": [
"time_sync_status",
"time_sync_servers",
"time_sync_sync_now"
],
"resources": [
{
"id": "servers",
"type": "file",
"path": "/etc/systemd/timesyncd.conf.d/50-mesh.conf",
"mode": "0644",
"content": "# The mesh's (module time-sync, novox/hq to-be 42): the servers timesyncd asks. Written whole at\n# every push. A drop-in whose name sorts after this one wins over it: a hosting provider's own\n# servers are kept that way where the machine was found with them.\n[Time]\nNTP=0.europe.pool.ntp.org 1.europe.pool.ntp.org 2.europe.pool.ntp.org 3.europe.pool.ntp.org\nFallbackNTP=0.arch.pool.ntp.org 1.arch.pool.ntp.org 2.arch.pool.ntp.org 3.arch.pool.ntp.org\n",
"names-on-purpose": {
"0.europe.pool.ntp.org": "the public NTP pool's European zone: the world's time service, the same for every installation; nearest to every machine of this mesh",
"1.europe.pool.ntp.org": "the public NTP pool's European zone: the world's time service, the same for every installation; nearest to every machine of this mesh",
"2.europe.pool.ntp.org": "the public NTP pool's European zone: the world's time service, the same for every installation; nearest to every machine of this mesh",
"3.europe.pool.ntp.org": "the public NTP pool's European zone: the world's time service, the same for every installation; nearest to every machine of this mesh",
"0.arch.pool.ntp.org": "the distribution's own NTP pool, timesyncd's compiled-in fallback, stated so the drop-in says the whole of what timesyncd uses",
"1.arch.pool.ntp.org": "the distribution's own NTP pool, timesyncd's compiled-in fallback, stated so the drop-in says the whole of what timesyncd uses",
"2.arch.pool.ntp.org": "the distribution's own NTP pool, timesyncd's compiled-in fallback, stated so the drop-in says the whole of what timesyncd uses",
"3.arch.pool.ntp.org": "the distribution's own NTP pool, timesyncd's compiled-in fallback, stated so the drop-in says the whole of what timesyncd uses"
}
},
{
"id": "retire-ntpd",
"type": "process",
"name": "time-sync-retire-ntpd",
"artifact": "tools",
"run": [
"./time-sync-tools",
"retire",
"ntpd.service",
"ntpdate.service"
],
"run-once": true
},
{
"id": "daemon",
"type": "service",
"unit": "systemd-timesyncd.service",
"state": "running",
"boot": "enabled",
"restart-on": [
"servers"
]
},
{
"id": "ntp",
"type": "package",
"package": "ntp",
"absent": true
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/time-sync-tools",
"binary": "time-sync-tools",
"loads": [
"time-sync-tools"
]
}
]
}
}