Files
mesh-catalog/modules/nats/snapshot/live_test.go
T
jochen 419e82cded Back up the bus by the server's own snapshot of each stream, not its live files (hq ADR 0235)
The restic holder copied JetStream's store while the server wrote it; such a
copy may not restore. The nats image now carries mesh-nats-snapshot, run by
the declared dump under the module's own bus account (snapshot API only):
every stream one at a time, flow-controlled, into one tar with a manifest of
counts, sequences and checksums. Restore builds a new store beside the live
one with the bus's own server; a person swaps it in. Proven against
throwaway nats 2.11 servers being written to during the snapshot.
2026-10-06 18:20:51 +02:00

588 lines
21 KiB
Go

package main
// The snapshot and the way back, against real servers: the bus's own release in throwaway containers.
//
// MESH_TEST_DOCKER=1 go test -race ./...
//
// Every container and file it makes is removed when it ends. MESH_TEST_SNAPSHOT_MESSAGES sets how
// many events the source holds (default 6000), to time a snapshot at the live bus's size.
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"math/big"
mrand "math/rand"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/nats-io/nats.go"
)
// image is the bus's release (the Dockerfile's `# upstream:` line names the same one).
const image = "nats:2.11-alpine"
// snapshotUser is the bus's own module on a machine, as the controller names it.
const snapshotUser = "anchor.nats"
// snapshotGrants are what mesh-controller composes for the module holding mesh-broker
// (internal/broker BusSnapshotGrants), written out here because this repository does not import the
// controller. Its own test proves the same list against a real server; this one proves the program
// needs no more than it.
var snapshotGrants = struct{ publish, subscribe []string }{
publish: []string{
"$JS.API.STREAM.NAMES",
"$JS.API.STREAM.INFO.*",
"$JS.API.STREAM.SNAPSHOT.*",
"$JS.SNAPSHOT.ACK.>",
},
subscribe: []string{"_INBOX." + snapshotUser + ".>"},
}
func needDocker(t *testing.T) {
t.Helper()
if os.Getenv("MESH_TEST_DOCKER") != "1" {
t.Skip("MESH_TEST_DOCKER is not 1: these tests start throwaway nats containers")
}
}
func docker(t *testing.T, args ...string) string {
t.Helper()
out, err := exec.Command("docker", args...).CombinedOutput()
if err != nil {
t.Fatalf("docker %s: %v\n%s", strings.Join(args, " "), err, out)
}
return strings.TrimSpace(string(out))
}
func owner() string { return fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()) }
// server is one throwaway container.
type server struct {
name string
port string
}
// startServer runs the bus's release with a configuration, as this user so what it writes into a
// mounted directory is removable by the test.
func startServer(t *testing.T, conf string, mounts ...string) server {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "nats.conf"), []byte(conf), 0o644); err != nil {
t.Fatal(err)
}
name := fmt.Sprintf("mesh-snapshot-test-%d-%d", os.Getpid(), time.Now().UnixNano())
args := []string{"run", "-d", "--rm", "--name", name, "--user", owner(), "-p", "127.0.0.1::4222",
"-v", dir + ":/etc/nats:ro"}
data := false
for _, m := range mounts {
args = append(args, "-v", m)
data = data || strings.HasSuffix(m, ":/data")
}
if !data {
// The store on a directory of the test's, so the server writes it as this user and the
// test removes it.
args = append(args, "-v", t.TempDir()+":/data")
}
args = append(args, image, "-c", "/etc/nats/nats.conf")
docker(t, args...)
t.Cleanup(func() { _ = exec.Command("docker", "rm", "-f", name).Run() })
mapped := docker(t, "port", name, "4222/tcp")
_, port, _ := strings.Cut(strings.Split(mapped, "\n")[0], ":")
return server{name: name, port: port}
}
// selfSigned is a certificate with no names, the ordinary case for a mesh's bus, and its pin.
func selfSigned(t *testing.T, dir string) string {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "bus"},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
keyDER, _ := x509.MarshalECPrivateKey(key)
_ = os.WriteFile(filepath.Join(dir, "tls.crt"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644)
_ = os.WriteFile(filepath.Join(dir, "tls.key"), pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o644)
sum := sha256.Sum256(der)
return "sha256:" + hex.EncodeToString(sum[:])
}
func quotedList(subjects []string) string {
q := make([]string, len(subjects))
for i, s := range subjects {
q[i] = strconv.Quote(s)
}
return strings.Join(q, ", ")
}
// liveBusConf is a server shaped like the mesh's: TLS, the one account, an administrator to fill it,
// and the bus's own module with exactly the grants the controller composes for it.
func liveBusConf() string {
return fmt.Sprintf(`listen: "0.0.0.0:4222"
tls { cert_file: "/tls/tls.crt", key_file: "/tls/tls.key" }
jetstream { store_dir: "/data" }
accounts { MESH { jetstream: enabled, users: [
{ user: "admin", password: "admin" },
{ user: %q, password: "snap", permissions: {
publish: { allow: [%s] },
subscribe: { allow: [%s] } } }
] } }
`, snapshotUser, quotedList(snapshotGrants.publish), quotedList(snapshotGrants.subscribe))
}
// plainConf is a fresh server with the one account and an administrator, nothing in it.
func plainConf() string {
return `listen: "0.0.0.0:4222"
jetstream { store_dir: "/data" }
accounts { MESH { jetstream: enabled, users: [ { user: "admin", password: "admin" } ] } }
`
}
func dial(t *testing.T, url string, c Credential, errs chan<- error) *nats.Conn {
t.Helper()
var nc *nats.Conn
var err error
for deadline := time.Now().Add(20 * time.Second); ; {
nc, err = connect(url, c)
if err == nil {
break
}
if time.Now().After(deadline) {
t.Fatal(err)
}
time.Sleep(200 * time.Millisecond)
}
if errs != nil {
nc.SetErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) {
select {
case errs <- err:
default:
}
})
}
t.Cleanup(nc.Close)
return nc
}
func messageCount() int {
if n, err := strconv.Atoi(os.Getenv("MESH_TEST_SNAPSHOT_MESSAGES")); err == nil && n > 0 {
return n
}
return 6000
}
// fill gives the source what the mesh's bus holds: events with headers and a gap deleted from the
// middle, a work queue partly worked, key-value buckets with history, deletes and a purge, an
// object in chunks, durable consumers part way through, and a memory stream.
func fill(t *testing.T, js nats.JetStreamContext) {
t.Helper()
must := func(err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
_, err := js.AddStream(&nats.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>"},
MaxMsgsPerSubject: 10000, MaxAge: 7 * 24 * time.Hour})
must(err)
r := mrand.New(mrand.NewSource(1))
payload := make([]byte, 2048)
total := messageCount()
var acks []nats.PubAckFuture
for i := 0; i < total; i++ {
r.Read(payload)
m := nats.NewMsg(fmt.Sprintf("mesh.mod.m%d.event.happened", i%30))
m.Header.Set("Mesh-Seq", strconv.Itoa(i))
m.Data = append([]byte(nil), payload[:200+r.Intn(1800)]...)
f, err := js.PublishMsgAsync(m)
must(err)
acks = append(acks, f)
if len(acks) == 2000 {
<-js.PublishAsyncComplete()
acks = acks[:0]
}
}
<-js.PublishAsyncComplete()
for seq := uint64(100); seq < 120; seq++ {
must(js.DeleteMsg("EVENTS", seq))
}
_, err = js.AddConsumer("EVENTS", &nats.ConsumerConfig{Durable: "audit", AckPolicy: nats.AckExplicitPolicy})
must(err)
sub, err := js.PullSubscribe("mesh.mod.*.event.>", "audit", nats.Bind("EVENTS", "audit"))
must(err)
msgs, err := sub.Fetch(500, nats.MaxWait(5*time.Second))
must(err)
for _, m := range msgs {
must(m.Ack())
}
_, err = js.AddStream(&nats.StreamConfig{Name: "CONTROL", Subjects: []string{"mesh.control.*.report"},
Retention: nats.WorkQueuePolicy})
must(err)
for i := 0; i < 50; i++ {
_, err := js.Publish(fmt.Sprintf("mesh.control.n%d.report", i%4), []byte(fmt.Sprintf("report %d", i)))
must(err)
}
_, err = js.AddConsumer("CONTROL", &nats.ConsumerConfig{Durable: "controller", AckPolicy: nats.AckExplicitPolicy})
must(err)
wsub, err := js.PullSubscribe("mesh.control.*.report", "controller", nats.Bind("CONTROL", "controller"))
must(err)
worked, err := wsub.Fetch(20, nats.MaxWait(5*time.Second))
must(err)
for _, m := range worked {
must(m.AckSync())
}
conditions, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: "mesh-controller_conditions"})
must(err)
history, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: "mesh-controller_condition-history", History: 5})
must(err)
for i := 0; i < 200; i++ {
key := fmt.Sprintf("machine.n%d.silent", i%40)
_, err := conditions.Put(key, []byte(fmt.Sprintf(`{"raised":%d}`, i)))
must(err)
_, err = history.Put(key, []byte(fmt.Sprintf(`{"at":%d}`, i)))
must(err)
}
for i := 0; i < 5; i++ {
must(conditions.Delete(fmt.Sprintf("machine.n%d.silent", i)))
}
must(history.Purge("machine.n7.silent"))
_, err = js.CreateKeyValue(&nats.KeyValueConfig{Bucket: "mesh-controller_lease", TTL: time.Hour})
must(err)
objects, err := js.CreateObjectStore(&nats.ObjectStoreConfig{Bucket: "artifacts"})
must(err)
big := make([]byte, 3<<20)
r.Read(big)
_, err = objects.PutBytes("facts/latest", big)
must(err)
_, err = js.AddStream(&nats.StreamConfig{Name: "SCRATCH", Subjects: []string{"scratch.>"}, Storage: nats.MemoryStorage})
must(err)
_, err = js.Publish("scratch.x", []byte("gone at a restart"))
must(err)
}
// sameContent holds every stream the manifest names, in the restored server, to the source: the same
// state at the snapshot, every message by sequence (subject, headers, body, and a deleted one still
// deleted), and the durable consumers where they were.
func sameContent(t *testing.T, m *Manifest, src, dst nats.JetStreamContext) {
t.Helper()
for _, s := range m.Streams {
info, err := dst.StreamInfo(s.Name)
if err != nil {
t.Errorf("%s was not restored: %v", s.Name, err)
continue
}
if err := heldTo(streamState{Messages: info.State.Msgs, LastSeq: info.State.LastSeq}, s); err != nil {
t.Errorf("%s: %v", s.Name, err)
}
// **Up to the snapshot's last sequence, exactly the source**: every message by sequence —
// subject, headers and body — and a message deleted before the snapshot still deleted. Past it,
// what was written while the blocks were read out: a message there may or may not have made it
// (the server's state says the stream reached it before the block holding it was read), and
// one that did is the source's. The source only grows after the snapshot.
for seq := info.State.FirstSeq; seq <= info.State.LastSeq && seq > 0; seq++ {
want, werr := src.GetMsg(s.Name, seq)
got, gerr := dst.GetMsg(s.Name, seq)
switch {
case errors.Is(werr, nats.ErrMsgNotFound):
if gerr == nil {
t.Errorf("%s %d was deleted and came back", s.Name, seq)
}
continue
case errors.Is(gerr, nats.ErrMsgNotFound):
if seq <= s.LastSeq {
t.Errorf("%s %d was in the stream at its snapshot and is missing from the restore", s.Name, seq)
}
continue
case werr != nil || gerr != nil:
t.Fatalf("%s %d: source %v, restored %v", s.Name, seq, werr, gerr)
}
if got.Subject != want.Subject || !bytes.Equal(got.Data, want.Data) || fmt.Sprint(got.Header) != fmt.Sprint(want.Header) {
t.Fatalf("%s %d differs: %s %q vs %s %q", s.Name, seq, got.Subject, truncate(got.Data), want.Subject, truncate(want.Data))
}
}
if info.State.LastSeq > s.LastSeq {
t.Logf("%s: restored to sequence %d, written while the snapshot read it; the manifest said %d", s.Name, info.State.LastSeq, s.LastSeq)
}
for _, consumer := range []string{"audit", "controller"} {
wantC, err := src.ConsumerInfo(s.Name, consumer)
if err != nil {
continue
}
gotC, err := dst.ConsumerInfo(s.Name, consumer)
if err != nil {
t.Errorf("%s's consumer %s was not restored: %v", s.Name, consumer, err)
continue
}
if gotC.AckFloor.Stream != wantC.AckFloor.Stream {
t.Errorf("%s's consumer %s acknowledged up to %d, the source up to %d", s.Name, consumer,
gotC.AckFloor.Stream, wantC.AckFloor.Stream)
}
}
}
for _, s := range m.Skipped {
if _, err := dst.StreamInfo(s.Name); err == nil {
t.Errorf("%s was skipped and yet exists", s.Name)
}
}
// And the buckets work as buckets, not only as streams.
kv, err := dst.KeyValue("mesh-controller_condition-history")
if err != nil {
t.Fatalf("the condition history is not a bucket after the restore: %v", err)
}
h, err := kv.History("machine.n3.silent")
if err != nil || len(h) != 5 {
t.Errorf("the history of a key came back as %d entries (%v), not 5", len(h), err)
}
if _, err := kv.Get("machine.n7.silent"); !errors.Is(err, nats.ErrKeyNotFound) {
t.Errorf("a purged key came back: %v", err)
}
cond, _ := dst.KeyValue("mesh-controller_conditions")
if _, err := cond.Get("machine.n1.silent"); !errors.Is(err, nats.ErrKeyNotFound) {
t.Errorf("a deleted key came back: %v", err)
}
if e, err := cond.Get("machine.n10.silent"); err != nil || string(e.Value()) != `{"raised":170}` {
t.Errorf("a condition came back wrong: %v", err)
}
srcObj, _ := src.ObjectStore("artifacts")
dstObj, err := dst.ObjectStore("artifacts")
if err != nil {
t.Fatalf("the object store was not restored: %v", err)
}
want, _ := srcObj.GetBytes("facts/latest")
got, err := dstObj.GetBytes("facts/latest")
if err != nil || !bytes.Equal(got, want) {
t.Errorf("the object came back different (%d bytes, %v)", len(got), err)
}
}
func TestASnapshotOfALiveBusRestoresToTheSameContent(t *testing.T) {
needDocker(t)
tlsDir := t.TempDir()
pin := selfSigned(t, tlsDir)
source := startServer(t, liveBusConf(), tlsDir+":/tls:ro")
url := "tls://127.0.0.1:" + source.port
admin := dial(t, url, Credential{Fingerprint: pin, User: "admin", Password: "admin"}, nil)
srcJS, _ := admin.JetStream()
fill(t, srcJS)
// The bus goes on taking writes while it is snapshotted, and every one is accepted.
writer := dial(t, url, Credential{Fingerprint: pin, User: "admin", Password: "admin"}, nil)
writerJS, _ := writer.JetStream()
stop := make(chan struct{})
var wrote, failed atomic.Int64
var slowest atomic.Int64
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
began := time.Now()
if _, err := writerJS.Publish("mesh.mod.writer.event.tick", []byte("during")); err != nil {
failed.Add(1)
} else {
wrote.Add(1)
}
if d := time.Since(began).Milliseconds(); d > slowest.Load() {
slowest.Store(d)
}
time.Sleep(2 * time.Millisecond)
}
}()
snap := dial(t, url, Credential{Fingerprint: pin, User: snapshotUser, Password: "snap"}, nil)
var archive bytes.Buffer
var said []string
var sayMu sync.Mutex
m, err := Snapshot(context.Background(), snap, &archive, SnapshotOptions{StreamTimeout: time.Minute, Pause: 50 * time.Millisecond,
Log: func(f string, a ...any) { sayMu.Lock(); said = append(said, fmt.Sprintf(f, a...)); sayMu.Unlock() }})
time.Sleep(100 * time.Millisecond)
close(stop)
wg.Wait()
if err != nil {
t.Fatalf("the snapshot failed: %v\n%s", err, strings.Join(said, "\n"))
}
t.Logf("snapshot: %d streams, %d messages, %s of streams → %s archived in %.2fs; %d writes during it, %d refused, slowest %dms",
len(m.Streams), m.Messages, human(m.StreamBytes), human(m.ArchiveBytes), m.Seconds, wrote.Load(), failed.Load(), slowest.Load())
for _, line := range said {
t.Log(line)
}
if failed.Load() > 0 {
t.Errorf("%d writes to the bus failed while it was snapshotted", failed.Load())
}
if len(m.Skipped) != 1 || m.Skipped[0].Name != "SCRATCH" {
t.Errorf("the memory stream was not said as skipped: %+v", m.Skipped)
}
var names []string
for _, s := range m.Streams {
names = append(names, s.Name)
}
wantNames := "CONTROL EVENTS KV_mesh-controller_condition-history KV_mesh-controller_conditions KV_mesh-controller_lease OBJ_artifacts"
if strings.Join(names, " ") != wantNames {
t.Errorf("snapshotted %v, want %s", names, wantNames)
}
tarPath := filepath.Join(t.TempDir(), "bus.tar")
if err := os.WriteFile(tarPath, archive.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
a, err := ReadArchive(bytes.NewReader(archive.Bytes()), "")
if err != nil {
t.Fatal(err)
}
defer a.Close()
if err := a.Verify(); err != nil {
t.Fatalf("a fresh snapshot does not verify: %v", err)
}
t.Run("nothing is restored over a live stream", func(t *testing.T) {
err := RestoreAll(context.Background(), admin, a, t.Logf)
if err == nil || !strings.Contains(err.Error(), "already holds") {
t.Fatalf("restoring over the live streams was not refused: %v", err)
}
})
t.Run("into a fresh server", func(t *testing.T) {
fresh := startServer(t, plainConf())
nc := dial(t, "nats://127.0.0.1:"+fresh.port, Credential{User: "admin", Password: "admin"}, nil)
if err := RestoreAll(context.Background(), nc, a, t.Logf); err != nil {
t.Fatal(err)
}
dstJS, _ := nc.JetStream()
sameContent(t, m, srcJS, dstJS)
})
t.Run("into a new store, swapped in and served", func(t *testing.T) {
binary := filepath.Join(t.TempDir(), "mesh-nats-snapshot")
build := exec.Command("go", "build", "-o", binary, ".")
build.Env = append(os.Environ(), "CGO_ENABLED=0")
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("building the program: %v\n%s", err, out)
}
out := t.TempDir()
// The program in the bus's own image, as a person runs it: the archive in, a new store out.
docker(t, "run", "--rm", "--user", owner(),
"-v", binary+":/usr/local/bin/mesh-nats-snapshot:ro",
"-v", tarPath+":/in/bus.tar:ro", "-v", out+":/out",
"--entrypoint", "/usr/local/bin/mesh-nats-snapshot", image,
"restore", "--into", "/out/store", "--from", "/in/bus.tar")
if _, err := os.Stat(filepath.Join(out, "store", "jetstream", "MESH", "streams", "EVENTS")); err != nil {
t.Fatalf("the new store is not laid out as the bus's: %v", err)
}
served := startServer(t, plainConf(), filepath.Join(out, "store")+":/data")
nc := dial(t, "nats://127.0.0.1:"+served.port, Credential{User: "admin", Password: "admin"}, nil)
dstJS, _ := nc.JetStream()
sameContent(t, m, srcJS, dstJS)
})
t.Run("a damaged archive is refused", func(t *testing.T) {
damaged := append([]byte(nil), archive.Bytes()...)
// A byte in the last stream's archive, well past every header.
damaged[len(damaged)-2048] ^= 0xff
d, err := ReadArchive(bytes.NewReader(damaged), "")
if err == nil {
defer d.Close()
err = d.Verify()
}
if err == nil {
t.Fatal("a damaged archive verified")
}
})
}
// The bus's own module may snapshot and do nothing else: every API that changes a stream, every
// publish into one, and every subscription beyond its own inbox is refused by the server.
func TestTheSnapshotUserCannotChangeTheBus(t *testing.T) {
needDocker(t)
tlsDir := t.TempDir()
pin := selfSigned(t, tlsDir)
source := startServer(t, liveBusConf(), tlsDir+":/tls:ro")
url := "tls://127.0.0.1:" + source.port
admin := dial(t, url, Credential{Fingerprint: pin, User: "admin", Password: "admin"}, nil)
js, _ := admin.JetStream()
if _, err := js.AddStream(&nats.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>"}}); err != nil {
t.Fatal(err)
}
if _, err := js.Publish("mesh.mod.a.event.b", []byte("x")); err != nil {
t.Fatal(err)
}
if _, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: "mesh-controller_conditions"}); err != nil {
t.Fatal(err)
}
errs := make(chan error, 64)
snap := dial(t, url, Credential{Fingerprint: pin, User: snapshotUser, Password: "snap"}, errs)
for _, subject := range []string{
"$JS.API.STREAM.CREATE.NEW", "$JS.API.STREAM.UPDATE.EVENTS", "$JS.API.STREAM.DELETE.EVENTS",
"$JS.API.STREAM.PURGE.EVENTS", "$JS.API.STREAM.MSG.DELETE.EVENTS", "$JS.API.STREAM.RESTORE.NEW",
"$JS.API.CONSUMER.CREATE.EVENTS", "$JS.API.STREAM.MSG.GET.EVENTS",
"mesh.mod.a.event.b", "$KV.mesh-controller_conditions.k",
} {
_, err := snap.Request(subject, []byte(`{}`), 500*time.Millisecond)
if err == nil {
t.Errorf("%s was answered for the snapshot user", subject)
}
select {
case e := <-errs:
if !strings.Contains(strings.ToLower(e.Error()), "permissions violation") {
t.Errorf("%s: %v", subject, e)
}
case <-time.After(2 * time.Second):
t.Errorf("the server did not refuse %s", subject)
}
}
for _, subject := range []string{"mesh.>", "_INBOX.other.>", "$JS.API.>"} {
if _, err := snap.SubscribeSync(subject); err != nil {
t.Fatal(err)
}
_ = snap.Flush()
select {
case e := <-errs:
if !strings.Contains(strings.ToLower(e.Error()), "permissions violation") {
t.Errorf("subscribing %s: %v", subject, e)
}
case <-time.After(2 * time.Second):
t.Errorf("the server let the snapshot user subscribe %s", subject)
}
}
info, err := js.StreamInfo("EVENTS")
if err != nil || info.State.Msgs != 1 {
t.Fatalf("the stream changed: %v %+v", err, info)
}
// And what it may do, it can.
names, err := StreamNames(context.Background(), snap)
if err != nil || len(names) != 2 {
t.Fatalf("the snapshot user cannot list the streams: %v %v", names, err)
}
}