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:
+24
-14
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user