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,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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user