diff --git a/modules/power/cmd/power/check.go b/modules/power/cmd/power/check.go new file mode 100644 index 0000000..30cde61 --- /dev/null +++ b/modules/power/cmd/power/check.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "fmt" + "sort" + "strings" +) + +// Check is one thing the module expects of the machine. +type Check struct { + Name string `json:"name"` + OK bool `json:"ok"` + Detail string `json:"detail"` +} + +// Check says what this module expects and whether the machine meets it: its units in place and +// enabled, the runner and the moment files present, logind's file the mesh's, the inhibitor held, +// and the predecessor's leftovers. +func (m *Machine) Check(ctx context.Context, w *Watcher) map[string]any { + var checks []Check + add := func(name string, ok bool, format string, args ...any) { + checks = append(checks, Check{Name: name, OK: ok, Detail: fmt.Sprintf(format, args...)}) + } + units := make([]string, 0, len(Units)) + for u := range Units { + units = append(units, u) + } + sort.Strings(units) + for _, u := range units { + present := m.exists("/etc/systemd/system/" + u) + enabled, _ := m.Run(ctx, "systemctl", "is-enabled", u) + enabled = strings.TrimSpace(enabled) + want := "enabled" + if u == "mesh-power-supply.service" { + want = "static" // started by the udev rule, never by a target + } + add(u, present && enabled == want, "%s: file present %v, %s (want %s)", Units[u], present, orWord(enabled, "unknown"), want) + } + add("runner", m.exists(RunnerPath), "%s present: %v", RunnerPath, m.exists(RunnerPath)) + for _, moment := range Moments { + p := MomentsDir + "/" + moment + add("moment "+moment, m.exists(p), "%s placed: %v, %d piece(s)", p, m.exists(p), len(Pieces(m.read(p)+"\n"))) + } + logind := m.read(LogindFile) + add("logind's power file", strings.Contains(logind, "module power"), "%s is the mesh's (module power): %v", + LogindFile, strings.Contains(logind, "module power")) + var others []string + for _, f := range m.glob("/etc/systemd/logind.conf.d/*.conf") { + if f != LogindFile { + others = append(others, f) + } + } + add("one writer of logind's power handling", len(others) == 0, + "other logind drop-ins: %s (novox/hq ADR 0210: logind's power settings are this module's alone)", orWord(strings.Join(others, ", "), "none")) + hooks := m.glob("/etc/systemd/system-sleep/*") + add("no hand-placed sleep hooks", len(hooks) == 0, + "%s (code around sleep is a contribution to node-power now, novox/hq ADR 0211)", orWord(strings.Join(hooks, ", "), "none")) + add("udev rule", m.exists(UdevRule) || Source(m.Supplies()) == "none", "%s present: %v (only acts where there is a Mains supply)", UdevRule, m.exists(UdevRule)) + if w != nil { + s := w.Snapshot() + add("sleep inhibitor", s.Inhibiting, "delay lock on sleep and shutdown held by the watcher: %v%s", s.Inhibiting, orNote(s.Problem)) + add("events reach the bus", s.Pending == 0, "%d event(s) waiting for the bus", s.Pending) + } + failing := 0 + for _, c := range checks { + if !c.OK { + failing++ + } + } + return map[string]any{"checks": checks, "failing": failing, + "not_checked": []string{"that a contribution's own code does what it means (power_run runs one moment now)"}} +} + +func orWord(s, word string) string { + if strings.TrimSpace(s) == "" { + return word + } + return s +} + +func orNote(s string) string { + if s == "" { + return "" + } + return " (" + s + ")" +} diff --git a/modules/power/cmd/power/history.go b/modules/power/cmd/power/history.go new file mode 100644 index 0000000..747f953 --- /dev/null +++ b/modules/power/cmd/power/history.go @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "regexp" + "strings" +) + +// Event is one thing that happened to the machine's power, as the journal recorded it. +type Event struct { + At string `json:"at"` + What string `json:"what"` + Detail string `json:"detail,omitempty"` +} + +var ( + sleptLine = regexp.MustCompile(`(?i)(performing sleep operation|entering sleep state|suspending system)`) + wokeLine = regexp.MustCompile(`(?i)(returned from sleep|system resumed|resumed from)`) + bootsLine = regexp.MustCompile(`^\s*(-?\d+)\s+([0-9a-f]{32})\s+(\S+\s+\S+\s+\S+(?:\s+\S+)?)\s+(?:—|-)\s+(\S+\s+\S+\s+\S+(?:\s+\S+)?)\s*$`) +) + +// journal reads the system journal: as the account first, as root when the account may not. +func (m *Machine) journal(ctx context.Context, args ...string) (string, error) { + out, err := m.Run(ctx, "journalctl", args...) + if err == nil && strings.TrimSpace(out) != "" { + return out, nil + } + return m.privileged(ctx, "journalctl", args...) +} + +// History is the machine's recent power events, newest last: each boot and its last entry, and every +// sleep, wake and moment run the journal recorded in the boots asked for. +func (m *Machine) History(ctx context.Context, boots int) (map[string]any, error) { + if boots < 1 { + boots = 3 + } + var events []Event + list, err := m.journal(ctx, "--list-boots", "--no-pager", "-q") + if err != nil { + return nil, err + } + lines := nonEmpty(strings.Split(list, "\n")) + if len(lines) > boots { + lines = lines[len(lines)-boots:] + } + for i, l := range lines { + f := bootsLine.FindStringSubmatch(l) + if f == nil { + continue + } + events = append(events, Event{At: f[3], What: "booted", Detail: "boot " + f[1]}) + if i < len(lines)-1 { + events = append(events, Event{At: f[4], What: "last entry before the next boot", Detail: "boot " + f[1]}) + } + got, err := m.journal(ctx, "-b", f[1], "-q", "--no-pager", "-o", "short-iso", + "-t", "systemd-sleep", "-t", "mesh-power", "-t", "systemd-logind") + if err != nil { + continue + } + for _, e := range strings.Split(got, "\n") { + at, rest, ok := splitJournalLine(e) + if !ok { + continue + } + switch { + case sleptLine.MatchString(rest): + events = append(events, Event{At: at, What: "sleeping", Detail: rest}) + case wokeLine.MatchString(rest): + events = append(events, Event{At: at, What: "woke", Detail: rest}) + case strings.Contains(rest, "mesh-power"): + events = append(events, Event{At: at, What: "moment", Detail: rest}) + case strings.Contains(rest, "Power key pressed") || strings.Contains(rest, "Lid closed") || + strings.Contains(rest, "Lid opened") || strings.Contains(rest, "System is powering down") || + strings.Contains(rest, "System is rebooting"): + events = append(events, Event{At: at, What: "logind", Detail: rest}) + } + } + } + if len(events) > 200 { + events = events[len(events)-200:] + } + return map[string]any{"boots": boots, "events": orEmpty(events)}, nil +} + +// splitJournalLine is a short-iso line's time and the rest (unit and message). +func splitJournalLine(line string) (string, string, bool) { + f := strings.SplitN(strings.TrimSpace(line), " ", 3) + if len(f) < 3 || !strings.Contains(f[0], "T") { + return "", "", false + } + return f[0], f[2], true +} + +func nonEmpty(lines []string) []string { + var out []string + for _, l := range lines { + if strings.TrimSpace(l) != "" { + out = append(out, l) + } + } + return out +} diff --git a/modules/power/cmd/power/machine.go b/modules/power/cmd/power/machine.go new file mode 100644 index 0000000..af26ec6 --- /dev/null +++ b/modules/power/cmd/power/machine.go @@ -0,0 +1,123 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +// CommandTimeout bounds every command a tool runs: a manager that hangs on its bus must cost a tool +// call twenty seconds, never the runtime's thirty. +const CommandTimeout = 20 * time.Second + +// OutputCap is the most a tool answers of one command's output. +const OutputCap = 16 * 1024 + +// The module's places on the machine (novox/hq ADR 0211): the runner it ships, and the files the +// controller fills with every module's code for each moment. +const ( + RunnerPath = "/usr/local/lib/mesh-power/bin/power-moment" + MomentsDir = "/etc/mesh-power/moments" + LogindFile = "/etc/systemd/logind.conf.d/power.conf" + UdevRule = "/etc/udev/rules.d/90-mesh-power.rules" +) + +// Moments are the power moments a module may contribute code for, in the order a machine meets them. +var Moments = []string{"after-boot", "on-mains", "on-battery", "before-sleep", "after-wake", "before-shutdown"} + +// Units are the module's own units and the moment each runs. +var Units = map[string]string{ + "mesh-power-after-boot.service": "after-boot", + "mesh-power-before-shutdown.service": "before-shutdown", + "mesh-power-before-sleep.service": "before-sleep", + "mesh-power-after-wake.service": "after-wake", + "mesh-power-supply.service": "on-mains / on-battery", +} + +// Runner runs one command and answers its standard output. Injected, so every tool is tested against +// recorded answers rather than this machine's managers. +type Runner func(ctx context.Context, name string, args ...string) (string, error) + +// ExecRunner runs a command, bounded by CommandTimeout. A failure carries what it said on stderr. +func ExecRunner(ctx context.Context, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, CommandTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err := cmd.Run() + out := capped(stdout.String()) + if ctx.Err() == context.DeadlineExceeded { + return out, fmt.Errorf("%s did not answer within %s", name, CommandTimeout) + } + if err != nil { + said := strings.TrimSpace(stderr.String()) + if said == "" { + said = strings.TrimSpace(stdout.String()) + } + return out, fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, capped(said)) + } + return out, nil +} + +func capped(s string) string { + if len(s) <= OutputCap { + return s + } + return s[:OutputCap] + "\n… (cut at 16 KiB)" +} + +// Machine is what the module reads and acts on: a filesystem root (the real one, or a test's tree) +// and a way to run commands. +type Machine struct { + Root string + Run Runner +} + +// Here is the machine this process runs on. +func Here() *Machine { return &Machine{Root: "/", Run: ExecRunner} } + +func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) } + +func (m *Machine) read(p string) string { + b, err := os.ReadFile(m.path(p)) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +func (m *Machine) readInt(p string) (int64, bool) { + n, err := strconv.ParseInt(m.read(p), 10, 64) + return n, err == nil +} + +func (m *Machine) exists(p string) bool { + _, err := os.Stat(m.path(p)) + return err == nil +} + +func (m *Machine) glob(pattern string) []string { + got, _ := filepath.Glob(m.path(pattern)) + out := make([]string, 0, len(got)) + for _, g := range got { + rel, err := filepath.Rel(m.Root, g) + if err != nil { + continue + } + out = append(out, "/"+rel) + } + return out +} + +// privileged runs a command as root without asking for a password (sudo -n), as the module's other +// action tools do: the runtime runs as the operator's account, and these files are root's. +func (m *Machine) privileged(ctx context.Context, name string, args ...string) (string, error) { + return m.Run(ctx, "sudo", append([]string{"-n", name}, args...)...) +} diff --git a/modules/power/cmd/power/moments.go b/modules/power/cmd/power/moments.go new file mode 100644 index 0000000..d082e29 --- /dev/null +++ b/modules/power/cmd/power/moments.go @@ -0,0 +1,70 @@ +package main + +import ( + "regexp" + "strings" +) + +// A piece is one module's code for one moment, as the controller placed it (novox/hq ADR 0204 §3, +// ADR 0211 §3): under a line naming the module, in module order. +type Piece struct { + Module string `json:"module"` + Lines int `json:"lines"` + Code string `json:"code"` +} + +// marker is the line the controller writes before each module's code: "# ", nothing else. +var marker = regexp.MustCompile(`^# ([a-z0-9][a-z0-9-]*)$`) + +// Pieces splits a moment file into its modules' pieces, exactly as the runner does: a marker line +// opens a piece; what comes before the first marker is the holder's own header and runs nowhere. +func Pieces(content string) []Piece { + var out []Piece + var cur *Piece + var b strings.Builder + flush := func() { + if cur != nil { + cur.Code = b.String() + cur.Lines = strings.Count(cur.Code, "\n") + out = append(out, *cur) + } + b.Reset() + } + for _, line := range strings.SplitAfter(content, "\n") { + if m := marker.FindStringSubmatch(strings.TrimRight(line, "\n")); m != nil { + flush() + cur = &Piece{Module: m[1]} + continue + } + if cur != nil { + b.WriteString(line) + } + } + flush() + return out +} + +// Hooks is every moment's pieces on this machine, read from the files the controller placed. +func (m *Machine) Hooks() map[string]any { + moments := map[string]any{} + total := 0 + for _, moment := range Moments { + p := MomentsDir + "/" + moment + if !m.exists(p) { + moments[moment] = map[string]any{"placed": false} + continue + } + pieces := Pieces(m.read(p) + "\n") + total += len(pieces) + moments[moment] = map[string]any{"placed": true, "pieces": orEmpty(pieces)} + } + return map[string]any{"moments": moments, "pieces": total, + "note": "each piece runs on its own with sh as root, bounded (MESH_POWER_BOUND, default 30 s); outcomes are in the journal under mesh-power"} +} + +func orEmpty[T any](s []T) []T { + if s == nil { + return []T{} + } + return s +} diff --git a/modules/power/cmd/power/state.go b/modules/power/cmd/power/state.go new file mode 100644 index 0000000..f358895 --- /dev/null +++ b/modules/power/cmd/power/state.go @@ -0,0 +1,152 @@ +package main + +import ( + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +// Supply is one power supply the kernel reports. +type Supply struct { + Name string `json:"name"` + Type string `json:"type"` + Online *bool `json:"online,omitempty"` + Percent *int `json:"percent,omitempty"` + Status string `json:"status,omitempty"` + Device bool `json:"device,omitempty"` // a peripheral's battery (a mouse), not the machine's + Capacity string `json:"capacity_level,omitempty"` +} + +// Supplies is every power supply under /sys/class/power_supply, by name. +func (m *Machine) Supplies() []Supply { + var out []Supply + for _, dir := range m.glob("/sys/class/power_supply/*") { + s := Supply{Name: filepath.Base(dir), Type: m.read(dir + "/type")} + if v, ok := m.readInt(dir + "/online"); ok { + on := v == 1 + s.Online = &on + } + if v, ok := m.readInt(dir + "/capacity"); ok { + p := int(v) + s.Percent = &p + } + s.Status = m.read(dir + "/status") + s.Device = m.read(dir+"/scope") == "Device" + s.Capacity = m.read(dir + "/capacity_level") + out = append(out, s) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// Source is where the machine draws its power from: on-mains, on-battery, or none when it has no +// Mains supply to say (a desktop, a server, a USB-C source only) — the same reading as the runner's. +func Source(supplies []Supply) string { + found := false + for _, s := range supplies { + if s.Type != "Mains" { + continue + } + found = true + if s.Online != nil && *s.Online { + return "on-mains" + } + } + if found { + return "on-battery" + } + return "none" +} + +// Battery is the machine's own battery charge, or nil when it has none. +func Battery(supplies []Supply) (percent *int, status string) { + for _, s := range supplies { + if s.Type == "Battery" && !s.Device && s.Percent != nil { + return s.Percent, s.Status + } + } + return nil, "" +} + +// BootTime is when the kernel started, from /proc/stat's btime. +func (m *Machine) BootTime() (time.Time, bool) { + for _, line := range strings.Split(m.read("/proc/stat"), "\n") { + if f := strings.Fields(line); len(f) == 2 && f[0] == "btime" { + if v, err := strconv.ParseInt(f[1], 10, 64); err == nil { + return time.Unix(v, 0).UTC(), true + } + } + } + return time.Time{}, false +} + +// Lid is the lid's state ("open", "closed"), or "" on a machine without one. +func (m *Machine) Lid() string { + for _, p := range m.glob("/proc/acpi/button/lid/*/state") { + if f := strings.Fields(m.read(p)); len(f) > 0 { + return f[len(f)-1] + } + } + return "" +} + +// LogindSettings is what logind's power keys are set to, the last file to say each one winning, in +// logind's own order: its main file, then the drop-ins by name. +func (m *Machine) LogindSettings() map[string]map[string]string { + keys := []string{"HandlePowerKey", "HandleSuspendKey", "HandleHibernateKey", "HandleLidSwitch", + "HandleLidSwitchExternalPower", "HandleLidSwitchDocked", "IdleAction", "InhibitDelayMaxSec"} + want := map[string]bool{} + for _, k := range keys { + want[k] = true + } + out := map[string]map[string]string{} + files := append([]string{"/etc/systemd/logind.conf"}, sortedGlob(m, "/usr/lib/systemd/logind.conf.d/*.conf")...) + files = append(files, sortedGlob(m, "/etc/systemd/logind.conf.d/*.conf")...) + for _, f := range files { + for _, line := range strings.Split(m.read(f), "\n") { + line = strings.TrimSpace(line) + k, v, ok := strings.Cut(line, "=") + if !ok || strings.HasPrefix(line, "#") || !want[strings.TrimSpace(k)] { + continue + } + out[strings.TrimSpace(k)] = map[string]string{"value": strings.TrimSpace(v), "from": f} + } + } + for _, k := range keys { + if _, set := out[k]; !set { + out[k] = map[string]string{"value": "(logind's default)", "from": ""} + } + } + return out +} + +func sortedGlob(m *Machine, pattern string) []string { + got := m.glob(pattern) + sort.Strings(got) + return got +} + +// State is the machine's power now: since when it runs, where its power comes from, the battery and +// the lid, logind's settings, and what the module's watcher last saw and holds. +func (m *Machine) State(w *Watcher) map[string]any { + supplies := m.Supplies() + out := map[string]any{"source": Source(supplies), "supplies": orEmpty(supplies)} + if bt, ok := m.BootTime(); ok { + out["booted_at"] = bt.Format(time.RFC3339) + out["up_for"] = time.Since(bt).Round(time.Second).String() + } + if p, status := Battery(supplies); p != nil { + out["battery_percent"] = *p + out["battery_status"] = status + } + if lid := m.Lid(); lid != "" { + out["lid"] = lid + } + out["logind"] = m.LogindSettings() + if w != nil { + out["watcher"] = w.Snapshot() + } + return out +} diff --git a/modules/power/files/bin/power-moment b/modules/power/files/bin/power-moment new file mode 100755 index 0000000..e7095e4 --- /dev/null +++ b/modules/power/files/bin/power-moment @@ -0,0 +1,90 @@ +#!/bin/sh +# power-moment MOMENT [--force] — run every module's code for one power moment (novox/hq ADR 0211). +# Shipped by the mesh's power module; edit the catalogue. +# +# MOMENT is after-boot, before-sleep, after-wake, before-shutdown, on-mains or on-battery, or +# `supply`, which reads the machine's power source and runs on-mains or on-battery once per change. +# +# The moment's file holds what the controller placed: each module's code under a line `# `. +# Each module's piece runs on its own, with sh, as root, bounded by MESH_POWER_BOUND seconds +# (default 30) and killed 5 s after that, so one module that hangs cannot hold the machine awake or +# stop the next module. Every outcome goes to the journal under the tag mesh-power. +set -u + +moment=${1:-} +force=${2:-} +moments=/etc/mesh-power/moments +bound=${MESH_POWER_BOUND:-30} +runtime=/run/mesh-power + +say() { logger -t mesh-power -- "$*" 2>/dev/null || true; printf '%s\n' "$*"; } + +# The power source as the kernel says it: on mains when any Mains supply is online. A machine with +# no Mains supply (a desktop, a server, a USB-C source only) has no source to change. +source_now() { + found=no + for s in /sys/class/power_supply/*; do + [ "$(cat "$s/type" 2>/dev/null)" = Mains ] || continue + found=yes + [ "$(cat "$s/online" 2>/dev/null)" = 1 ] && { echo on-mains; return; } + done + [ "$found" = yes ] && echo on-battery || echo none +} + +case "$moment" in +supply) + now=$(source_now) + [ "$now" = none ] && exit 0 + mkdir -p "$runtime" + last=$(cat "$runtime/source" 2>/dev/null || true) + [ "$now" = "$last" ] && [ "$force" != --force ] && exit 0 + echo "$now" >"$runtime/source" + moment=$now + ;; +after-boot | before-sleep | after-wake | on-mains | on-battery) ;; +before-shutdown) + # Run only while the machine is really going down. The unit that runs it is stopped at shutdown, + # and also when the module is taken off the machine; the second must not run anyone's shutdown code. + state=$(systemctl is-system-running 2>/dev/null || true) + if [ "$state" != stopping ] && [ "$force" != --force ]; then + say "moment=before-shutdown skipped: the system is $state, not stopping" + exit 0 + fi + ;; +*) + echo "power-moment: no moment $moment (after-boot, before-sleep, after-wake, before-shutdown, on-mains, on-battery, supply)" >&2 + exit 2 + ;; +esac + +file="$moments/$moment" +[ -r "$file" ] || { say "moment=$moment: nothing placed"; exit 0; } + +work=$(mktemp -d) || exit 1 +trap 'rm -rf "$work"' EXIT + +# One file per module piece, in the order the controller placed them. A line that is exactly +# "# " opens a piece; lines before the first are the holder's own header. +awk -v dir="$work" ' + /^# [a-z0-9][a-z0-9-]*$/ { n++; name = substr($0, 3); out = sprintf("%s/%03d-%s", dir, n, name); next } + n > 0 { print > out } +' "$file" + +ran=0 failed=0 +for piece in "$work"/*; do + [ -e "$piece" ] || continue + module=${piece##*/} + module=${module#*-} + start=$(date +%s) + timeout --kill-after=5 "$bound" sh "$piece" "$piece.out" 2>&1 + rc=$? + took=$(($(date +%s) - start)) + ran=$((ran + 1)) + case $rc in + 0) say "moment=$moment module=$module ok in ${took}s" ;; + 124 | 137) failed=$((failed + 1)); say "moment=$moment module=$module ended after ${bound}s: it did not finish" ;; + *) failed=$((failed + 1)); say "moment=$moment module=$module exited $rc: $(tail -n 3 "$piece.out" | tr '\n' ' ')" ;; + esac +done +say "moment=$moment: $ran piece(s), $failed failed" +exit 0 diff --git a/modules/power/go.mod b/modules/power/go.mod new file mode 100644 index 0000000..04c5a4b --- /dev/null +++ b/modules/power/go.mod @@ -0,0 +1,8 @@ +module power + +go 1.22 + +require ( + git.novox.be/novox/mesh-sdk/go v0.1.7 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect +) diff --git a/modules/power/go.sum b/modules/power/go.sum new file mode 100644 index 0000000..8c37f7d --- /dev/null +++ b/modules/power/go.sum @@ -0,0 +1,4 @@ +git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= +git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=