mesh-watcher: read who holds the controller and the bus from the placement seat's state, not the controller's seats (issue 1955, ADR 0571 clause 4)

This commit is contained in:
2026-10-29 23:46:55 +01:00
parent 2802fdf783
commit 3d3524dd69
6 changed files with 52 additions and 62 deletions
+30 -43
View File
@@ -15,6 +15,7 @@ import (
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/health"
)
@@ -150,60 +151,46 @@ func roundTrip(node string) error {
}
}
// controlNodes reads which machines hold the controller and the bus.
// controlNodes reads which machines hold the controller and the bus, from the placement seat's state of who holds
// which seat (novox/hq ADR 0571 clause 4).
func controlNodes() ([]string, error) {
raw, err := stdio.Ask("seat:mesh-controller.seats", map[string]any{})
st := stdio.State(delivery.SeatHoldersRead)
names, err := st.Keys()
if err != nil {
return nil, err
}
return holdersOf(raw, "mesh-controller", "mesh-broker")
keys := map[string][]byte{}
for _, k := range names {
e, err := st.Get(k)
if err != nil {
return nil, err
}
if e != nil {
keys[k] = e.Value
}
}
return holderNodes(keys, "mesh-controller", "mesh-broker")
}
// holdersOf reads the seats verb's answer, bare or inside a tool reply, for the named seats' machines.
func holdersOf(raw json.RawMessage, seats ...string) ([]string, error) {
var answer struct {
Seats []struct {
Seat string `json:"seat"`
Holders []struct {
Node string `json:"node"`
} `json:"holders"`
} `json:"seats"`
Output string `json:"output"`
Content []struct {
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(raw, &answer); err != nil {
var s string
if json.Unmarshal(raw, &s) == nil {
return holdersOf(json.RawMessage(s), seats...)
}
return nil, fmt.Errorf("the seats answer is not JSON")
}
if len(answer.Seats) == 0 {
switch {
case answer.Output != "":
return holdersOf(json.RawMessage(answer.Output), seats...)
case len(answer.Content) > 0:
return holdersOf(json.RawMessage(answer.Content[0].Text), seats...)
}
return nil, fmt.Errorf("the seats answer lists no seat")
}
// holderNodes is the machines holding any of the seats, in the order the seats are named, each once.
func holderNodes(keys map[string][]byte, seats ...string) ([]string, error) {
var out []string
seen := map[string]bool{}
for _, s := range answer.Seats {
for _, want := range seats {
if s.Seat != want {
continue
}
for _, h := range s.Holders {
if !seen[h.Node] {
seen[h.Node] = true
out = append(out, h.Node)
}
for _, seat := range seats {
holders, err := delivery.HoldersOf(keys, seat, "")
if err != nil {
return nil, err
}
for _, h := range holders {
if !seen[h.Node] {
seen[h.Node] = true
out = append(out, h.Node)
}
}
}
if len(out) == 0 {
return nil, fmt.Errorf("the placement seat's state names no holder of %s", strings.Join(seats, " or "))
}
return out, nil
}
@@ -37,7 +37,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
if !reflect.DeepEqual(m.Consumes, []string{ControllerSeat + "." + HeartbeatEvent, "mesh-self-check.checked"}) {
t.Fatalf("consumes %v", m.Consumes)
}
if !reflect.DeepEqual(m.Invokes, []string{m.Module + ".watcher_ping", "seat:mesh-controller.seats"}) {
if !reflect.DeepEqual(m.Invokes, []string{m.Module + ".watcher_ping"}) {
t.Fatalf("invokes %v", m.Invokes)
}
if len(m.Claims) != 0 {
@@ -1,7 +1,6 @@
package main
import (
"encoding/json"
"errors"
"strings"
"testing"
@@ -214,16 +213,18 @@ func TestOnTheControlNodeItSaysItIsMisplaced(t *testing.T) {
}
}
func TestTheSeatsAnswerIsReadInEitherShape(t *testing.T) {
bare := `{"seats":[{"seat":"mesh-controller","holders":[{"node":"n1","module":"mesh-controller"}]},
{"seat":"mesh-broker","holders":[{"node":"n1"},{"node":"n2"}]},{"seat":"git","holders":[{"node":"n3"}]}]}`
wrapped, _ := json.Marshal(map[string]any{"content": []map[string]string{{"type": "text", "text": bare}}})
output, _ := json.Marshal(map[string]any{"ok": true, "output": bare})
for _, raw := range []string{bare, string(wrapped), string(output)} {
got, err := holdersOf(json.RawMessage(raw), "mesh-controller", "mesh-broker")
if err != nil || strings.Join(got, ",") != "n1,n2" {
t.Errorf("%s: %v %v", raw, got, err)
}
func TestTheHoldersAreReadFromThePlacementSeatsState(t *testing.T) {
keys := map[string][]byte{
"mesh-controller": []byte(`{"v":1,"seat":"mesh-controller","scope":"mesh","holders":[{"module":"mesh-controller","node":"n1"}]}`),
"mesh-broker": []byte(`{"v":1,"seat":"mesh-broker","scope":"mesh","holders":[{"module":"nats","node":"n1"},{"module":"nats","node":"n2"}]}`),
"git": []byte(`{"v":1,"seat":"git","scope":"mesh","holders":[{"module":"gitea","node":"n3"}]}`),
}
got, err := holderNodes(keys, "mesh-controller", "mesh-broker")
if err != nil || strings.Join(got, ",") != "n1,n2" {
t.Errorf("%v %v", got, err)
}
if _, err := holderNodes(map[string][]byte{}, "mesh-controller", "mesh-broker"); err == nil {
t.Error("no holder named, and no error")
}
}
+1 -1
View File
@@ -2,4 +2,4 @@ module mesh-watcher
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029220947-f9a496d3e121
+2 -2
View File
@@ -1,2 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a h1:elC2IjB+qbeXG+pWItLz71DenegbrdsqbcH5kyGo56Q=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029214732-6b8c6943811a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029220947-f9a496d3e121 h1:OsA1v6n736a7RqEV8Nkip8pf5ILMbzj0PWuKqcBOhK0=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029220947-f9a496d3e121/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
+6 -4
View File
@@ -7,11 +7,11 @@
"mesh-self-check.checked"
],
"reads": [
"mesh-conditions.takeover"
"mesh-conditions.takeover",
"mesh-delivery-assigner.seat-holders"
],
"invokes": [
"mesh-watcher.watcher_ping",
"seat:mesh-controller.seats"
"mesh-watcher.watcher_ping"
],
"own-secrets": {
"telegram-token": "${dir:state}/telegram-token"
@@ -43,7 +43,9 @@
"type": "file",
"path": "${dir:state}/settings.json",
"mode": "0600",
"takes-config": ["telegram-chat-id"],
"takes-config": [
"telegram-chat-id"
],
"merge": "json",
"template": "files/settings.json"
}