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