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
+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")
}
}