Carry the power draw to the bar over the bus, not through a file (hq ADR 0260)

Modules talk over the bus. The power module's bundle puts each fresh reading of its own
sampler in its state draw, keyed by the machine; the bar's block shows that state, which
the bar's bundle watches under the read the mesh grants it. A new catalogue check refuses
a module naming another module's path under /run or /var/lib.
This commit is contained in:
jochen
2026-10-08 16:12:37 +02:00
parent e1b7fcd8c9
commit 76b63744e6
21 changed files with 697 additions and 73 deletions
+24 -14
View File
@@ -13,7 +13,7 @@ states on the bus.
| `/etc/mesh-power/moments/<moment>` | for each moment, every module's code as the controller placed it (`${shell:<moment>:<slot>}`) |
| `/usr/local/lib/mesh-power/bin/power-moment` | the runner: each module's piece on its own, as root, with `sh`, bounded (`MESH_POWER_BOUND`, default 30 s); every outcome in the journal under `mesh-power` |
| five units | `mesh-power-after-boot`, `-before-shutdown` (its stop is the shutdown, while the network is still up), `-before-sleep`, `-after-wake`, `-supply` |
| `mesh-power-draw` (process) | samples the power drawn and publishes it to `/run/mesh-power/draw` for the bar (below) |
| `mesh-power-draw` (process) | samples the power drawn for the module's bundle, which puts it on the bus as `power.draw` (below) |
| `/etc/udev/rules.d/90-mesh-power.rules` | a power supply's change starts `mesh-power-supply`, which runs `on-mains` or `on-battery` once per change of source |
## The moments, and how a module adds code
@@ -32,7 +32,7 @@ Code that must run *inside* the sleep transaction, such as the NVIDIA driver's o
resume units, is not a contribution. Its module keeps its own drop-ins on the sleep services (ADR
0211, "What got harder").
## The bar's battery and power draw (novox/hq ADR 0255)
## The bar's battery and power draw (novox/hq ADR 0255, ADR 0260)
The module holds the machine's power everywhere, so it gives the bar what is generic about power, on
the machines that have it. Both are contributions to `node-bar` in the bar's own neutral terms, so any
@@ -40,8 +40,8 @@ bar renders them, and each names the capability the node-engine reports for it:
| block | where | what it shows |
|---|---|---|
| battery | `if-capability: battery` (a Battery supply not scoped to a device) | the charge, and the time left or to full |
| power draw | `if-capability: power-meter` (a system battery, a powercap package counter, or a graphics device that reports its power) | one line from `bin/power-draw` |
| battery | `if-capability: battery` | the bar's own battery block: the bar reads the kernel's power-supply class itself, not anything of this module's |
| power draw | `if-capability: power-meter` | this module's state `power.draw` for the machine, over the bus |
The power draw says what it measured:
@@ -51,16 +51,26 @@ The power draw says what it measured:
`14.0 W CPU`. That is part of the machine, and is called that part.
- **The machine at the wall** needs a metered plug or a UPS and is not measured.
**The energy counter stays root's.** `energy_uj` is readable by root alone on purpose: read often by
anyone, it leaks what the processor is doing (the PLATYPUS attack). So no permission is loosened. The
module's process `mesh-power-draw` (`power draw`, as root) samples every 5 s and writes only the
averaged rate, one line, to `/run/mesh-power/draw` (0644). The bar's script prints that line, and says
`power not measured` when it is missing or older than 30 s. Graphics are counted honestly: an AMD
device only when it has its own memory (an APU's integrated graphics report the whole package, which
the counter already holds), and only while it is awake; an NVIDIA device through `nvidia-smi` only
while it is awake, so the bar never wakes a sleeping GPU. Each package is counted once by its name:
where Intel shows one twice (`intel-rapl:N` and `intel-rapl-mmio:N`), the first is read. A counter that
gives more than 1000 W was reset, after a resume say, and that sample gives no reading.
**Over the bus, not through a file.** Modules talk over the bus. The energy counter is root's to read,
on purpose: read often by anyone, it leaks what the processor is doing (the PLATYPUS attack), so no
permission is loosened. Two parts of this module carry the reading:
1. the process `mesh-power-draw` (`power draw`, as root) samples every 5 s and leaves the averaged rate
in `/run/mesh-power/draw`, this module's own directory, read by nothing but this module;
2. the module's bundle, which the runtime launches, puts each fresh reading in its state `draw`, keyed by
the machine's name: `{"text", "watts", "measures", "at"}`. A value lives 30 s (`ttl-seconds`).
**State, not events, by bus load.** A reading is the current value and is replaced every five seconds;
nobody needs the ones before. A key per machine keeps one value, so the bus holds at most one reading
per machine, and a bar starting later reads it at once. An event every five seconds would keep each one
in a stream. On four machines the puts are under one message a second.
The bar's holder is granted the read by the mesh, because this module offers the state in its block; it
names no module. Graphics are counted honestly: an AMD device only when it has its own memory (an APU's
integrated graphics report the whole package, which the counter already holds), and only while it is
awake; an NVIDIA device through `nvidia-smi` only while it is awake. Each package is counted once by its
name: where Intel shows one twice (`intel-rapl:N` and `intel-rapl-mmio:N`), the first is read. A counter
that gives more than 1000 W was reset, after a resume say, and that sample gives no reading.
**The sampler runs wherever this module is assigned, servers included.** A server has the energy
counter too, so conditioning it on `power-meter` would not keep it off one, and the mesh has no way yet
+39 -7
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
@@ -21,10 +22,12 @@ import (
//
// The energy counter is root's to read, on purpose: read often, it leaks what the processor is doing
// (the PLATYPUS attack). So the counter is never opened to the operator's account. `power draw` runs as
// root, as the module's process, samples every SampleEvery, and writes only the averaged rate, one line,
// to DrawFile — readable by anyone, and too coarse to leak anything. The bar's block prints that line.
// root, as the module's process, samples every SampleEvery, and writes only the averaged rate to
// DrawFile, too coarse to leak anything. That file is this module's alone: its bundle reads it and puts
// the reading on the bus as the state `draw`, keyed by the machine (publish.go), and nothing of another
// module reads it (novox/hq ADR 0260: modules talk over the bus).
// DrawFile is where the rate is published, and SampleEvery how often.
// DrawFile is where the sampler leaves the reading for the module's bundle, and SampleEvery how often.
const (
DrawFile = "/run/mesh-power/draw"
SampleEvery = 5 * time.Second
@@ -40,6 +43,30 @@ type Draw struct {
GPU *float64
}
// Reading is a draw as the state `draw` holds it: the line shown, the watts, what they cover and when.
type Reading struct {
Text string `json:"text"`
Watts float64 `json:"watts,omitempty"`
Measures string `json:"measures,omitempty"`
At string `json:"at"`
}
// Reading is the draw as it is published, at a moment.
func (d Draw) Reading(at time.Time) Reading {
r := Reading{Text: d.Line(), At: at.UTC().Format(time.RFC3339)}
switch {
case d.Machine != nil:
r.Watts, r.Measures = *d.Machine, "machine"
case d.CPU != nil && d.GPU != nil:
r.Watts, r.Measures = *d.CPU+*d.GPU, "cpu+gpu"
case d.CPU != nil:
r.Watts, r.Measures = *d.CPU, "cpu"
case d.GPU != nil:
r.Watts, r.Measures = *d.GPU, "gpu"
}
return r
}
// Line is the reading as the bar shows it.
func (d Draw) Line() string {
switch {
@@ -221,8 +248,13 @@ func (m *Machine) Sample(ctx context.Context, every time.Duration, wait func(tim
return Draw{CPU: cpuWatts(before, m.packageCounters(), time.Since(start)), GPU: m.gpuWatts(ctx)}
}
// Publish writes the line where the bar reads it: whole, readable by anyone, replacing the last.
func (m *Machine) Publish(line string) error {
// Publish writes the reading where the module's bundle reads it, whole, replacing the last.
func (m *Machine) Publish(r Reading) error {
raw, err := json.Marshal(r)
if err != nil {
return err
}
line := string(raw)
path := m.path(DrawFile)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
@@ -244,7 +276,7 @@ func RunDraw(ctx context.Context, m *Machine) error {
// runDraw publishes one reading every `every`. A battery reading is instant, so it waits the interval
// itself; a counter reading waits inside its sample. It returns as soon as the context ends.
func runDraw(ctx context.Context, m *Machine, every time.Duration, publish func(string) error) error {
func runDraw(ctx context.Context, m *Machine, every time.Duration, publish func(Reading) error) error {
wait := func(d time.Duration) bool {
select {
case <-ctx.Done():
@@ -258,7 +290,7 @@ func runDraw(ctx context.Context, m *Machine, every time.Duration, publish func(
if ctx.Err() != nil {
break
}
if err := publish(d.Line()); err != nil {
if err := publish(d.Reading(time.Now())); err != nil {
return fmt.Errorf("publishing the draw: %w", err)
}
if d.Machine != nil && !wait(every) {
+5 -34
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
@@ -124,9 +123,10 @@ func TestNothingToMeasureIsSaid(t *testing.T) {
}
}
func TestOnlyTheRateIsPublishedReadableByAnyone(t *testing.T) {
func TestOnlyTheRateIsLeftForTheBundle(t *testing.T) {
m := tree(t, nil)
if err := m.Publish("8.0 W CPU"); err != nil {
w := 8.0
if err := m.Publish(Draw{CPU: &w}.Reading(time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC))); err != nil {
t.Fatal(err)
}
info, err := os.Stat(filepath.Join(m.Root, DrawFile))
@@ -134,7 +134,7 @@ func TestOnlyTheRateIsPublishedReadableByAnyone(t *testing.T) {
t.Fatalf("%v %v", info, err)
}
b, _ := os.ReadFile(filepath.Join(m.Root, DrawFile))
if string(b) != "8.0 W CPU\n" {
if string(b) != `{"text":"8.0 W CPU","watts":8,"measures":"cpu","at":"2026-10-08T12:00:00Z"}`+"\n" {
t.Fatalf("%q", b)
}
}
@@ -191,7 +191,7 @@ func TestTheLoopPublishesEveryIntervalAndStopsWhenCancelled(t *testing.T) {
var lines []string
done := make(chan error)
go func() {
done <- runDraw(ctx, m, 20*time.Millisecond, func(l string) error { lines = append(lines, l); return nil })
done <- runDraw(ctx, m, 20*time.Millisecond, func(r Reading) error { lines = append(lines, r.Text); return nil })
}()
time.Sleep(210 * time.Millisecond)
cancel()
@@ -208,32 +208,3 @@ func TestTheLoopPublishesEveryIntervalAndStopsWhenCancelled(t *testing.T) {
t.Fatalf("%d lines: %v", len(lines), lines)
}
}
func TestTheBarsScriptShowsOnlyAFreshLine(t *testing.T) {
file := filepath.Join(t.TempDir(), "draw")
run := func() string {
cmd := exec.Command("sh", "../../files/bin/power-draw")
cmd.Env = append(os.Environ(), "MESH_POWER_DRAW="+file)
out, err := cmd.Output()
if err != nil {
t.Fatal(err)
}
return strings.TrimSpace(string(out))
}
if got := run(); got != "power not measured" {
t.Fatalf("no file: %q", got)
}
if err := os.WriteFile(file, []byte("8.0 W CPU\n"), 0o644); err != nil {
t.Fatal(err)
}
if got := run(); got != "8.0 W CPU" {
t.Fatalf("fresh: %q", got)
}
old := time.Now().Add(-31 * time.Second)
if err := os.Chtimes(file, old, old); err != nil {
t.Fatal(err)
}
if got := run(); got != "power not measured" {
t.Fatalf("31 s old: %q", got)
}
}
+2
View File
@@ -35,6 +35,8 @@ func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go w.Run(ctx)
// And the power drawn, from the module's own sampler onto the bus (novox/hq ADR 0260).
go ShareDraw(m)
if err := stdio.Serve("", Tools(m, w)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// The draw on the bus (novox/hq ADR 0260). The module's bundle runs as the operator's account and so
// cannot read the energy counter; its root process can and cannot reach the bus. So the process leaves
// each reading in the module's own file and the bundle puts it in the state `draw`, keyed by this
// machine: the current value, which a machine reading later sees at once. Not an event: a reading is
// replaced every five seconds and nobody needs the ones before, and an event would keep each in a stream.
// Nothing measured is not put; a value older than its lifetime (`ttl-seconds` in the manifest) is gone.
// DrawState is the state the draw is kept in, and DrawStale how old a reading may be and still be put.
const (
DrawState = "draw"
DrawStale = 30 * time.Second
)
// Putter puts one key of a state; the SDK's in the bundle, a recorder in a test.
type Putter func(key string, value any) error
// shareDraw reads the sampler's reading and puts it for this machine, if it is fresh and measured
// something. It answers what it did, for the log.
func (m *Machine) shareDraw(machine string, now time.Time, put Putter) (string, error) {
raw, err := os.ReadFile(m.path(DrawFile))
if err != nil {
return "no reading yet", nil
}
var r Reading
if err := json.Unmarshal(raw, &r); err != nil {
return "", fmt.Errorf("the sampler's reading is not one: %w", err)
}
at, err := time.Parse(time.RFC3339, r.At)
if err != nil || now.Sub(at) > DrawStale {
return "the sampler's reading is stale", nil
}
if r.Measures == "" {
return "nothing measured", nil
}
if err := put(machine, r); err != nil {
return "", err
}
return "put " + r.Text, nil
}
// ShareDraw puts the draw every SampleEvery for as long as the bundle runs. A machine with no name
// from the runtime puts nothing, and says so once.
func ShareDraw(m *Machine) {
machine := os.Getenv("MESH_NODE")
if machine == "" {
fmt.Fprintln(os.Stderr, "power: the runtime gave no machine name (MESH_NODE); the draw is not shared")
return
}
put := func(key string, value any) error {
_, err := stdio.State(DrawState).Put(key, value)
return err
}
last := ""
for range time.Tick(SampleEvery) {
said, err := m.shareDraw(machine, time.Now(), put)
if err != nil {
said = err.Error()
}
if said != last && (err != nil || !strings.HasPrefix(said, "put ")) {
fmt.Fprintln(os.Stderr, "power: draw:", said)
}
last = said
}
}
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"errors"
"strings"
"testing"
"time"
)
// Defends novox/hq ADR 0260: the draw reaches the bar over the bus, as this module's state keyed by the
// machine, and only a fresh reading that measured something is put.
func TestAFreshReadingIsPutForThisMachine(t *testing.T) {
m := tree(t, nil)
now := time.Date(2026, 10, 8, 12, 0, 10, 0, time.UTC)
w := 18.2
if err := m.Publish(Draw{Machine: &w}.Reading(now.Add(-5 * time.Second))); err != nil {
t.Fatal(err)
}
var key string
var value Reading
said, err := m.shareDraw("laptop", now, func(k string, v any) error { key, value = k, v.(Reading); return nil })
if err != nil || key != "laptop" || value.Text != "18.2 W machine" || value.Measures != "machine" ||
!strings.HasPrefix(said, "put ") {
t.Fatalf("%q %v: put %q %+v", said, err, key, value)
}
}
func TestAStaleOrEmptyReadingIsNotPut(t *testing.T) {
m := tree(t, nil)
now := time.Date(2026, 10, 8, 12, 1, 0, 0, time.UTC)
never := func(string, any) error { t.Fatal("put"); return nil }
if said, _ := m.shareDraw("laptop", now, never); said != "no reading yet" {
t.Fatalf("%q", said)
}
w := 9.0
if err := m.Publish(Draw{CPU: &w}.Reading(now.Add(-time.Minute))); err != nil {
t.Fatal(err)
}
if said, _ := m.shareDraw("laptop", now, never); said != "the sampler's reading is stale" {
t.Fatalf("%q", said)
}
if err := m.Publish(Draw{}.Reading(now)); err != nil {
t.Fatal(err)
}
if said, _ := m.shareDraw("laptop", now, never); said != "nothing measured" {
t.Fatalf("%q", said)
}
}
func TestARefusedPutIsSaid(t *testing.T) {
m := tree(t, nil)
now := time.Now()
w := 9.0
if err := m.Publish(Draw{CPU: &w}.Reading(now)); err != nil {
t.Fatal(err)
}
if _, err := m.shareDraw("laptop", now, func(string, any) error { return errors.New("refused") }); err == nil {
t.Fatal("a refused put was taken for one done")
}
}
-12
View File
@@ -1,12 +0,0 @@
#!/bin/sh
# power-draw (module power, novox/hq ADR 0255): the power the machine draws, for the bar's block.
#
# Prints the line the module's sampler (`power draw`, as root) publishes every five seconds, which says
# what it measured: "machine" on battery, "CPU" or "CPU+GPU" otherwise. A line older than thirty
# seconds is a sampler that stopped, and is not shown as if it were now.
f="${MESH_POWER_DRAW:-/run/mesh-power/draw}"
if [ -r "$f" ] && [ $(( $(date +%s) - $(stat -c %Y "$f") )) -le 30 ]; then
head -n 1 "$f"
else
echo "power not measured"
fi
+14 -3
View File
@@ -20,6 +20,12 @@
"on-battery",
"battery-low"
],
"state": [
{
"name": "draw",
"ttl-seconds": 30
}
],
"tools": [
"power_state",
"power_hooks",
@@ -235,6 +241,12 @@
"state": "running",
"boot": "enabled"
},
{
"id": "draw-dir",
"type": "directory",
"path": "/run/mesh-power",
"mode": "0755"
},
{
"id": "draw",
"type": "process",
@@ -289,10 +301,9 @@
"bar": "bottom",
"place": "status",
"order": 45,
"shows": "command",
"shows": "state",
"options": {
"command": "/usr/local/lib/mesh-power/bin/power-draw",
"interval": 5
"state": "power.draw"
}
}
}