nfs-server: read the backup seat's holder 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 7b5728b1f3
commit 34a6932288
8 changed files with 72 additions and 44 deletions
+17 -29
View File
@@ -20,6 +20,7 @@ package main
import (
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-sdk/go/delivery"
"os"
"path"
"sort"
@@ -46,33 +47,19 @@ type BackupFromBus struct {
Error string `json:"error,omitempty"`
}
// BackupFrom reads the holder of node-backup on one machine from mesh-controller.seats' answer, and the
// paths that module keeps data in on that machine from mesh-controller.data's. A machine whose backup seat
// nobody holds, and an answer with no data list, are errors: never an empty list read as "nothing to fear".
func BackupFrom(machine string, seats, data json.RawMessage) (string, []string, error) {
var s struct {
Answer *struct {
Seats []struct {
Seat string `json:"seat"`
Holders []struct {
Module string `json:"module"`
Node string `json:"node"`
} `json:"holders"`
} `json:"seats"`
} `json:"answer"`
}
if err := json.Unmarshal(seats, &s); err != nil || s.Answer == nil {
return "", nil, fmt.Errorf("the controller's seats answer could not be read: %v", err)
// BackupFrom reads the holder of node-backup on one machine from the placement seat's state of who holds which seat
// (novox/hq ADR 0571 clause 4), and the paths that module keeps data in on that machine from mesh-controller.data's
// answer. A machine whose backup seat nobody holds, and an answer with no data list, are errors: never an empty list
// read as "nothing to fear".
func BackupFrom(machine string, keys map[string][]byte, data json.RawMessage) (string, []string, error) {
holders, err := delivery.HoldersOf(keys, BackupSeat, machine)
if err != nil {
return "", nil, err
}
holder := ""
for _, seat := range s.Answer.Seats {
if seat.Seat != BackupSeat {
continue
}
for _, h := range seat.Holders {
if h.Node == machine {
holder = h.Module
}
for _, h := range holders {
if h.Node == machine {
holder = h.Module
}
}
if holder == "" {
@@ -102,12 +89,13 @@ func BackupFrom(machine string, seats, data json.RawMessage) (string, []string,
// TakeBackup asks the controller over the bus, as the module, through the runtime.
func (m *Machine) TakeBackup(machine string) {
m.TakeBackupWith(machine, askBus)
m.TakeBackupWith(machine, askBus, seatHolderKeys)
}
// TakeBackupWith is TakeBackup with the ask given, for tests. A failed ask keeps the last holder and
// TakeBackupWith is TakeBackup with the ask and the reading of the seat-holders state given, for tests. A failed ask keeps the last holder and
// paths and adds why.
func (m *Machine) TakeBackupWith(machine string, ask func(key string, body any) (json.RawMessage, error)) {
func (m *Machine) TakeBackupWith(machine string, ask func(key string, body any) (json.RawMessage, error),
holderKeys func() (map[string][]byte, error)) {
var last BackupFromBus
if text, err := m.Read(BackupFile); err == nil {
_ = json.Unmarshal([]byte(text), &last)
@@ -115,7 +103,7 @@ func (m *Machine) TakeBackupWith(machine string, ask func(key string, body any)
next := last
next.Looked = m.Now().UTC().Format(time.RFC3339)
next.Error = ""
seats, err := ask("seat:mesh-controller.seats", map[string]any{})
seats, err := holderKeys()
var data json.RawMessage
if err == nil {
data, err = ask("seat:mesh-controller.data", map[string]any{"machine": machine})
@@ -81,7 +81,7 @@ func TestWithoutTheBackupListTheExportsStayAndItIsSaid(t *testing.T) {
}
func TestBackupFromTheSeatsAndTheData(t *testing.T) {
seats := json.RawMessage(`{"answer":{"seats":[{"seat":"node-backup","scope":"node","holders":[{"module":"restic","node":"ace"},{"module":"borg","node":"shanks"}]}]},"ok":true}`)
seats := backupHolders("ace", "restic")
data := json.RawMessage(`{"answer":{"data":[{"machine":"ace","module":"restic","item":"repository","path":"/storage/restic"},{"machine":"ace","module":"restic","item":"state","path":"/var/lib/restic"},{"machine":"ace","module":"plex","item":"data","path":"/mnt/plex/data"}]},"ok":true}`)
holder, paths, err := BackupFrom("ace", seats, data)
if err != nil || holder != "restic" || strings.Join(paths, " ") != "/storage/restic /var/lib/restic" {
@@ -101,11 +101,11 @@ func TestAFailedAskKeepsTheLastBackupPaths(t *testing.T) {
m := f.machine()
m.TakeBackupWith("ace", func(string, any) (json.RawMessage, error) {
return nil, errors.New("no responders")
})
}, noHolders)
f.backup("restic", "/storage/restic")
m.TakeBackupWith("ace", func(string, any) (json.RawMessage, error) {
return nil, errors.New("timeout")
})
}, func() (map[string][]byte, error) { return backupHolders("ace", "restic"), nil })
var b BackupFromBus
if err := json.Unmarshal([]byte(f.read(BackupFile)), &b); err != nil {
t.Fatal(err)
@@ -153,14 +153,12 @@ func TestAStaleOrUnknownBackupListIsSaidWithItsAge(t *testing.T) {
}
ok := func(key string, body any) (json.RawMessage, error) {
if strings.Contains(key, "seats") {
return json.RawMessage(`{"answer":{"seats":[{"seat":"node-backup","holders":[{"module":"restic","node":"hs"}]}]}}`), nil
}
return json.RawMessage(`{"answer":{"data":[{"machine":"hs","module":"restic","path":"/srv/restic"}]}}`), nil
}
m.TakeBackupWith("hs", ok)
m.TakeBackupWith("hs", ok, func() (map[string][]byte, error) { return backupHolders("hs", "restic"), nil })
now = now.Add(2 * time.Hour)
m.TakeBackupWith("hs", func(string, any) (json.RawMessage, error) { return nil, errors.New("the bus did not answer") })
m.TakeBackupWith("hs", func(string, any) (json.RawMessage, error) { return nil, errors.New("the bus did not answer") },
func() (map[string][]byte, error) { return backupHolders("hs", "restic"), nil })
s := m.Pass(context.Background(), nil)
said := strings.Join(append(append([]string{}, s.Said...), s.Warnings...), "\n")
if !strings.Contains(said, "2h0m0s") || !strings.Contains(said, "the bus did not answer") {
@@ -171,3 +169,11 @@ func TestAStaleOrUnknownBackupListIsSaidWithItsAge(t *testing.T) {
t.Errorf("the health does not say the backup list's age: %+v", h)
}
}
// backupHolders is the placement seat's state with one holder of node-backup on one node.
func backupHolders(node, module string) map[string][]byte {
return map[string][]byte{"node-backup." + node: []byte(`{"v":1,"seat":"node-backup","scope":"node","node":"` + node +
`","holders":[{"module":"` + module + `","node":"` + node + `"}]}`)}
}
func noHolders() (map[string][]byte, error) { return nil, errors.New("the state is not read yet") }
@@ -64,7 +64,7 @@ func TestAFailedReloadIsTriedAgainWhenNothingElseAsks(t *testing.T) {
}
func TestBackupFromRefusesAnAnswerWithoutADataList(t *testing.T) {
seats := json.RawMessage(`{"answer":{"seats":[{"seat":"node-backup","holders":[{"module":"restic","node":"ace"}]}]}}`)
seats := backupHolders("ace", "restic")
for _, data := range []string{`{"answer":{}}`, `{"answer":{"data":null}}`} {
if _, paths, err := BackupFrom("ace", seats, json.RawMessage(data)); err == nil {
t.Errorf("%s was read as the empty list %v: never \"nothing to fear\"", data, paths)
+21
View File
@@ -20,6 +20,7 @@ import (
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// ExportsState is the state the exports are kept in on the bus; MountsState the `mounts` modules' state,
@@ -93,6 +94,26 @@ func askBus(key string, body any) (json.RawMessage, error) {
return stdio.Ask(key, body)
}
// seatHolderKeys reads the placement seat's state of who holds which seat, every key.
func seatHolderKeys() (map[string][]byte, error) {
state := stdio.State(delivery.SeatHoldersRead)
names, err := state.Keys()
if err != nil {
return nil, err
}
keys := map[string][]byte{}
for _, k := range names {
e, err := state.Get(k)
if err != nil {
return nil, err
}
if e != nil {
keys[k] = e.Value
}
}
return keys, nil
}
// TakeWants reads every node's `mounts` state from the bus and writes what is asked of this machine to
// the wants file, for the root process. When the bus does not answer, the last wants stay.
func (m *Machine) TakeWants(machine string) {
@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"git.novox.be/novox/mesh-sdk/go/delivery"
"os"
"regexp"
"sort"
@@ -137,7 +138,7 @@ func TestTheManifestCarriesGrantsTheRosterTheWantsAndTheRemovalOrder(t *testing.
if err := json.Unmarshal([]byte(raw), &full); err != nil {
t.Fatal(err)
}
if len(full.Reads) != 1 || full.Reads[0] != MountsState {
if len(full.Reads) != 2 || full.Reads[0] != delivery.SeatHoldersRead || full.Reads[1] != MountsState {
t.Errorf("reads: %v", full.Reads)
}
roster := full.Facts["machines"]
+2 -2
View File
@@ -1,5 +1,5 @@
module nfsserver
go 1.22
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.7
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029220947-f9a496d3e121
+14 -2
View File
@@ -1,2 +1,14 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
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=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+1 -1
View File
@@ -48,10 +48,10 @@
}
],
"reads": [
"mesh-delivery-assigner.seat-holders",
"mounts.shares"
],
"invokes": [
"seat:mesh-controller.seats",
"seat:mesh-controller.data"
],
"config": {