Files
mesh-catalog/modules/nats/cmd/nats-tools/snapshots_test.go
T
jschoubben 44cb7ad449 bus5 B5 (A-107): the keeper's process answers mesh-broker's snapshot verbs itself through AnswerSeat
snapshot-get, snapshot-restore, snapshot-restore-cancel and snapshot-remove are answered by
the keeper's process with the proved caller; it publishes snapshot-get's chunks and hears
their acks on its own subscription, and acts at once with no hand-over through requests
(the record of a running restore is written before it runs). The tools bundle keeps its
tools, nats_snapshots among them. The protected refusal and the whole test's case (send,
restore in place, removal) stay.

Builds: design 47 G A-107 (G-20-lead-answers), §681

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 23:56:06 +01:00

252 lines
9.7 KiB
Go

package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"runtime"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-contracts/go/seats/meshbroker"
stdio "git.novox.be/novox/mesh-sdk/go"
"nats-tools/internal/keeper"
)
// mapStore is the keeper's state in memory.
type mapStore struct {
mu sync.Mutex
m map[string][]byte
}
func (s *mapStore) Get(state, key string) ([]byte, uint64, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.m[state+"/"+key]
return v, 1, ok, nil
}
func (s *mapStore) Put(state, key string, v []byte) (uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[state+"/"+key] = v
return 1, nil
}
func (s *mapStore) Delete(state, key string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.m, state+"/"+key)
return nil
}
func (s *mapStore) Keys(state string) ([]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
var out []string
for k := range s.m {
if strings.HasPrefix(k, state+"/") {
out = append(out, strings.TrimPrefix(k, state+"/"))
}
}
return out, nil
}
func (s *mapStore) Create(state, key string, v []byte) (uint64, error) { return s.Put(state, key, v) }
// The seat mesh-broker's snapshot verbs and nats_snapshots (design 47 G §681, A-44, A-107): the keeper's process answers snapshot-get
// with the fields and the chunks of at most 512 KiB, each acknowledged before the next, to a caller granted the chunk subjects, and
// never a protected snapshot; the other three verbs it acts on itself, with the proved caller, and answers in the one acting form;
// nats_snapshots lists each snapshot with its expired and recorded.
func TestTheKeepersProcessAnswersTheSnapshotVerbs(t *testing.T) {
at := time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC)
file := bytes.Repeat([]byte("snap"), (ChunkSize*5/2)/4) // two whole chunks and a half
sum := sha256.Sum256(file)
digest := "sha256:" + hex.EncodeToString(sum[:])
files := map[string][]byte{"/k/ok.tar": file, "/k/damaged.tar": []byte("other bytes"), "/k/protected.tar": file}
held := map[string]keeper.Request{
"r1": {ID: "r1", Verb: "drop", Module: "m", Local: "drafts", At: at, Path: "/k/ok.tar", Size: int64(len(file)), Digest: digest},
"r2": {ID: "r2", Verb: "drop", Module: "m", Local: "secret", At: at, Path: "/k/protected.tar", Size: int64(len(file)), Digest: digest, Protected: true},
"r3": {ID: "r3", Verb: "drop", Module: "m", Local: "bad", At: at, Path: "/k/damaged.tar", Size: int64(len(file)), Digest: digest},
"r4": {ID: "r4", Verb: "drop", Module: "m", Local: "gone", At: at, Path: "/k/gone.tar", Size: 5, Digest: digest},
"r5": {ID: "r5", Verb: "recreate", Module: "m", Local: "old", At: at.AddDate(0, 0, -400), Snapshot: "/before/g.tar"},
"r6": {ID: "r6", Verb: "drop", Module: "m", Local: "aged", At: at.AddDate(0, 0, -91), Path: "/k/ok.tar", Size: 1, Digest: digest},
}
rs := requestStore{
Get: func(key string) (*keeper.Request, error) {
r, ok := held[key]
if !ok {
return nil, nil
}
return &r, nil
},
Keys: func() ([]string, error) {
var out []string
for k := range held {
out = append(out, k)
}
return out, nil
},
Now: func() time.Time { return at },
}
chunks := make(chan string, 8)
var published []Chunk
var ackHandler func(subject string)
var journal []string
var mu sync.Mutex
locked := func(f func()) { mu.Lock(); defer mu.Unlock(); f() }
granted := true
w := &snapshotWorld{
Open: func(path string) (io.ReadCloser, error) {
b, ok := files[path]
if !ok {
return nil, os.ErrNotExist
}
return io.NopCloser(bytes.NewReader(b)), nil
},
Publish: func(subject string, body any, id string) error {
c := body.(Chunk)
locked(func() { published = append(published, c) })
chunks <- subject + " " + id
return nil
},
Hear: func(ack func(subject string)) error {
ackHandler = ack
return nil
},
Granted: func(string, string) bool { return granted },
Logf: func(f string, a ...any) { locked(func() { journal = append(journal, fmt.Sprintf(f, a...)) }) },
Within: time.Millisecond,
}
store := &mapStore{m: map[string][]byte{}}
snapFile := t.TempDir() + "/rm.tar"
if err := os.WriteFile(snapFile, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
put := func(r keeper.Request) {
raw, _ := json.Marshal(r)
_, _ = store.Put(keeper.StateRequests, r.ID, raw)
}
for _, r := range held {
put(r)
}
put(keeper.Request{ID: "r7", Verb: "drop", Module: "m", Local: "rm", At: at, Path: snapFile, Size: 1, Digest: "sha256:x"})
put(keeper.Request{ID: "r5", Verb: "recreate", Module: "m", Local: "old", At: at, Snapshot: "/before/g.tar"})
var acts []string
k := &keeper.Keeper{Store: store, Act: func(what, why, cause string) error { acts = append(acts, what+" | "+cause); return nil }}
answers := snapshotAnswers(k, heldRequest(store), w)
if len(answers) != 4 {
t.Fatalf("the process answers %d verbs, not the four snapshot verbs: %v", len(answers), answers)
}
answer := func(verb, caller string, args map[string]any) (any, error) {
raw, _ := json.Marshal(args)
return answers[verb](caller, raw)
}
get := func(request, call string) meshbroker.SnapshotGetAnswer {
out, err := answers[meshbroker.VerbSnapshotGet]("person.jo", json.RawMessage(fmt.Sprintf(`{"request":%q,"call":%q}`, request, call)))
if err != nil {
t.Fatal(err)
}
return out.(meshbroker.SnapshotGetAnswer)
}
call := "0123456789abcdef0123456789abcdef"
for _, row := range []struct{ request, want string }{{"r2", "snapshot protected"}, {"r3", "snapshot damaged"}, {"r4", "snapshot missing"},
{"r5", "snapshot taken before milestone G"}} {
if got := get(row.request, call); got.Error != row.want && !strings.HasPrefix(got.Error, row.want) || got.Chunks != 0 || len(published) != 0 {
t.Errorf("%s: answered %+v, want %q and no chunk", row.request, got, row.want)
}
}
// A caller granted no chunk subjects is answered the fields alone.
granted = false
if got := get("r1", call); got.Error != "" || got.Chunks != 0 || got.Digest != digest || got.Stream != "m_drafts" || got.Size != int64(len(file)) || len(published) != 0 {
t.Errorf("an ungranted caller: %+v, published %d", got, len(published))
}
// A granted one hears each chunk after the answer, the next only once the last is acknowledged.
granted = true
got := get("r1", call)
if got.Chunks != 3 {
t.Fatalf("answered %+v: 2.5 chunks of 512 KiB are three", got)
}
for n := 1; n <= 3; n++ {
want := fmt.Sprintf("mesh.seat.mesh-broker.chunk.snapshot-get.%s.%d %s.%d", call, n, call, n)
if s := <-chunks; s != want {
t.Fatalf("chunk %d sent as %q, want %q", n, s, want)
}
ackHandler(fmt.Sprintf("mesh.seat.mesh-broker.ack.snapshot-get.%s.%d", call, n))
}
var whole []byte
locked(func() {})
for _, c := range published {
if len(c.Data) > ChunkSize {
t.Errorf("chunk %d holds %d bytes, past 512 KiB", c.N, len(c.Data))
}
whole = append(whole, c.Data...)
}
if !bytes.Equal(whole, file) {
t.Errorf("the chunks hold %d bytes, not the file's %d", len(whole), len(file))
}
// A missed acknowledgement ends the send, said in the journal; no second chunk goes.
locked(func() { published = nil })
_ = get("r1", call)
<-chunks
var said []string
var sent int
for deadline := time.Now().Add(5 * time.Second); len(said) == 0 && time.Now().Before(deadline); {
locked(func() { said, sent = append([]string(nil), journal...), len(published) })
runtime.Gosched()
}
if len(said) != 1 || !strings.Contains(said[0], "ends at chunk 1 of 3") || sent != 1 {
t.Errorf("a missed acknowledgement: journal %v, published %d", said, sent)
}
// The three other verbs are acted on by the keeper itself, with the proved caller, and answered in the acting form.
out, err := answer(meshbroker.VerbSnapshotRestore, "person.jo", map[string]any{"request": "r5", "why": "w", "caller": "person.mallory"})
if err != nil {
t.Fatal(err)
}
if a := out.(meshbroker.SnapshotRestoreAnswer); a.Accepted != "" || a.Error != "snapshot taken before milestone G" {
t.Errorf("a restore of a snapshot of before G answered %+v", a)
}
if _, err := answer(meshbroker.VerbSnapshotRestore, "person.jo", map[string]any{"request": "r2"}); err == nil {
t.Error("a restore with no why was answered")
}
out, _ = answer(meshbroker.VerbSnapshotRestoreCancel, "", map[string]any{"act": "a1", "why": "w"})
if a := out.(meshbroker.SnapshotRestoreCancelAnswer); a.Accepted != "" || !strings.Contains(a.Error, "a1 has ended") {
t.Errorf("cancel of an act that is none answered %+v", a)
}
out, err = answer(meshbroker.VerbSnapshotRemove, "", map[string]any{"request": "r7", "why": "w"})
if a, _ := out.(meshbroker.SnapshotRemoveAnswer); err != nil || a.Accepted == "" || a.Error != "" {
t.Fatalf("remove answered %+v, %v", out, err)
}
if _, err := os.Stat(snapFile); !os.IsNotExist(err) {
t.Errorf("the snapshot file is still there: %v", err)
}
if _, _, found, _ := store.Get(keeper.StateRequests, "r7"); found {
t.Error("the removed snapshot's record is still held")
}
if len(acts) != 1 || !strings.Contains(acts[0], "asked by "+stdio.Unproven) {
t.Errorf("the hand acts %v: the caller of a call that named none is %s", acts, stdio.Unproven)
}
// nats_snapshots lists every snapshot, the expired and the unrecorded said.
out, err = snapshotListTool(rs, 90)[0].Run(nil)
if err != nil {
t.Fatal(err)
}
list := out.(map[string]any)["snapshots"].([]map[string]any)
byRequest := map[string]map[string]any{}
for _, s := range list {
byRequest[s["request"].(string)] = s
}
if r := byRequest["r2"]; r["protected"] != true || r["stream"] != "m_secret" {
t.Errorf("r2 listed as %v", r)
}
if r := byRequest["r6"]; r["expired"] != true || r["recorded"] != true {
t.Errorf("r6 listed as %v: 91 days old, recorded", r)
}
if r := byRequest["r5"]; r["recorded"] != false || r["expired"] != false {
t.Errorf("r5 (before G) listed as %v: unrecorded, never expired by age", r)
}
}