9013ea5e33
The one-time data move decentralization needs (issue 0003c): copy the entire control-plane state from the local SQLite database into the replicated JetStream KV buckets, with a backup taken first. pkg/membership: - Snapshot / SealedKeyRecord: a backend-agnostic dump of the whole control plane (rooms with their real epoch, members, every sealed-key row across epochs, users with status). - (*sqliteStore).ExportSnapshot and (*jetstreamStore).ExportSnapshot read a full Snapshot from each backend; (*jetstreamStore).importSnapshot writes one with raw Puts (preserving epoch/status, not resetting to defaults) so the migration is faithful and idempotent (every write is an overwrite, so re-running converges). - MigrateSQLiteToKV orchestrates export -> import; BackupSQLite makes a consistent copy via SQLite's VACUUM INTO before any migration. cmd/membershipd: - `membershipd migrate-to-kv --db <path> --nats-url <url> [--replicas N] [--ca <cert>] [--no-backup]` backs up the SQLite file, connects to the cluster's NATS, and migrates. Dispatched on the host like `user`. Tests (DoD: golden + edge + parity): - TestMigrateSQLiteToKVParity: seed a representative SQLite (two rooms, one rekeyed to epoch 2, members, a revoked user); after migration the KV ExportSnapshot equals the SQLite ExportSnapshot. - TestMigrateSQLiteToKVIdempotent: running the migration twice yields the same KV state. - TestBackupSQLiteCreatesConsistentCopy: the backup reopens with identical data. Plus a binary smoke (seed user -> run server -> migrate-to-kv -> re-run): backup written, 1 user migrated, second run identical.
196 lines
6.1 KiB
Go
196 lines
6.1 KiB
Go
package membership
|
|
|
|
import (
|
|
"path/filepath"
|
|
"reflect"
|
|
"sort"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/enmanuel/unibus/pkg/embeddednats"
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/nats-io/nats.go/jetstream"
|
|
)
|
|
|
|
// seedSQLite populates a SQLite store with a representative control plane: two
|
|
// rooms (one rekeyed to epoch 2 with a removed member's keys left behind), a few
|
|
// members and sealed keys, and a user allowlist with one revoked entry. It
|
|
// returns the populated *sqliteStore and its file path.
|
|
func seedSQLite(t *testing.T) (*sqliteStore, string) {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "seed.db")
|
|
s, err := openSQLite(path)
|
|
if err != nil {
|
|
t.Fatalf("openSQLite: %v", err)
|
|
}
|
|
|
|
r1 := RoomInfo{RoomID: newULID(), Subject: "room.alpha", Encrypt: true, Persist: true, SignMsgs: true, OwnerEndpoint: "ep-owner1"}
|
|
if err := s.CreateRoom(r1, []byte("o1-sign"), []byte("o1-kex"), []byte("o1-sealed-e1")); err != nil {
|
|
t.Fatalf("create r1: %v", err)
|
|
}
|
|
if err := s.AddMember(r1.RoomID, Member{Endpoint: "ep-bob", Role: "member", SignPub: []byte("bob-sign"), KexPub: []byte("bob-kex")}, 1, []byte("bob-sealed-e1")); err != nil {
|
|
t.Fatalf("add bob: %v", err)
|
|
}
|
|
// Rekey r1 to epoch 2 (owner keeps a key at the new epoch).
|
|
if err := s.BumpEpoch(r1.RoomID, 2); err != nil {
|
|
t.Fatalf("bump: %v", err)
|
|
}
|
|
if err := s.PutSealedKeys(r1.RoomID, 2, map[string][]byte{"ep-owner1": []byte("o1-sealed-e2")}); err != nil {
|
|
t.Fatalf("put keys e2: %v", err)
|
|
}
|
|
|
|
r2 := RoomInfo{RoomID: newULID(), Subject: "room.beta", Encrypt: false, Persist: false, SignMsgs: false, OwnerEndpoint: "ep-owner2"}
|
|
if err := s.CreateRoom(r2, []byte("o2-sign"), []byte("o2-kex"), nil); err != nil {
|
|
t.Fatalf("create r2: %v", err)
|
|
}
|
|
|
|
if err := s.AddUser("aa11", "alice", RoleAdmin); err != nil {
|
|
t.Fatalf("add alice: %v", err)
|
|
}
|
|
if err := s.AddUser("bb22", "bob", RoleMember); err != nil {
|
|
t.Fatalf("add bob user: %v", err)
|
|
}
|
|
if err := s.AddUser("cc33", "carol", RoleMember); err != nil {
|
|
t.Fatalf("add carol: %v", err)
|
|
}
|
|
if err := s.RevokeUser("cc33"); err != nil {
|
|
t.Fatalf("revoke carol: %v", err)
|
|
}
|
|
return s, path
|
|
}
|
|
|
|
// normalizeSnapshot sorts every slice in a Snapshot so two snapshots from
|
|
// different backends can be compared regardless of enumeration order.
|
|
func normalizeSnapshot(snap *Snapshot) {
|
|
sort.Slice(snap.Rooms, func(i, j int) bool { return snap.Rooms[i].RoomID < snap.Rooms[j].RoomID })
|
|
for _, ms := range snap.Members {
|
|
sort.Slice(ms, func(i, j int) bool { return ms[i].Endpoint < ms[j].Endpoint })
|
|
}
|
|
sort.Slice(snap.Keys, func(i, j int) bool {
|
|
a, b := snap.Keys[i], snap.Keys[j]
|
|
if a.RoomID != b.RoomID {
|
|
return a.RoomID < b.RoomID
|
|
}
|
|
if a.Endpoint != b.Endpoint {
|
|
return a.Endpoint < b.Endpoint
|
|
}
|
|
return a.Epoch < b.Epoch
|
|
})
|
|
sort.Slice(snap.Users, func(i, j int) bool { return snap.Users[i].SignPub < snap.Users[j].SignPub })
|
|
}
|
|
|
|
func newJS(t *testing.T) jetstream.JetStream {
|
|
t.Helper()
|
|
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
|
|
StoreDir: t.TempDir(),
|
|
Host: "127.0.0.1",
|
|
Port: kvFreePort(t),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("embedded nats: %v", err)
|
|
}
|
|
nc, err := nats.Connect(ns.ClientURL())
|
|
if err != nil {
|
|
ns.Shutdown()
|
|
t.Fatalf("nats connect: %v", err)
|
|
}
|
|
js, err := jetstream.New(nc)
|
|
if err != nil {
|
|
nc.Close()
|
|
ns.Shutdown()
|
|
t.Fatalf("jetstream: %v", err)
|
|
}
|
|
t.Cleanup(func() { nc.Close(); ns.Shutdown(); ns.WaitForShutdown() })
|
|
return js
|
|
}
|
|
|
|
// TestMigrateSQLiteToKVParity is the parity test the issue mandates: after the
|
|
// migration, the KV store holds exactly the SQLite source's state.
|
|
func TestMigrateSQLiteToKVParity(t *testing.T) {
|
|
src, path := seedSQLite(t)
|
|
srcSnap, err := src.ExportSnapshot()
|
|
if err != nil {
|
|
t.Fatalf("export sqlite: %v", err)
|
|
}
|
|
src.Close() // release the file before the migration reopens it
|
|
|
|
js := newJS(t)
|
|
report, err := MigrateSQLiteToKV(path, js, JetStreamConfig{Replicas: 1, OpTimeout: 5 * time.Second})
|
|
if err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
if report.Rooms != 2 || report.Users != 3 {
|
|
t.Fatalf("report mismatch: %+v", report)
|
|
}
|
|
|
|
kv, err := OpenJetStream(js, JetStreamConfig{Replicas: 1, OpTimeout: 5 * time.Second})
|
|
if err != nil {
|
|
t.Fatalf("open kv: %v", err)
|
|
}
|
|
kvSnap, err := kv.(*jetstreamStore).ExportSnapshot()
|
|
if err != nil {
|
|
t.Fatalf("export kv: %v", err)
|
|
}
|
|
|
|
normalizeSnapshot(srcSnap)
|
|
normalizeSnapshot(kvSnap)
|
|
if !reflect.DeepEqual(srcSnap, kvSnap) {
|
|
t.Fatalf("parity mismatch after migration:\n sqlite=%+v\n kv= %+v", srcSnap, kvSnap)
|
|
}
|
|
}
|
|
|
|
// TestMigrateSQLiteToKVIdempotent: running the migration twice converges to the
|
|
// same KV state (every write is an overwrite). A second run must not duplicate
|
|
// or corrupt anything.
|
|
func TestMigrateSQLiteToKVIdempotent(t *testing.T) {
|
|
src, path := seedSQLite(t)
|
|
srcSnap, _ := src.ExportSnapshot()
|
|
src.Close()
|
|
|
|
js := newJS(t)
|
|
if _, err := MigrateSQLiteToKV(path, js, JetStreamConfig{Replicas: 1}); err != nil {
|
|
t.Fatalf("migrate run 1: %v", err)
|
|
}
|
|
if _, err := MigrateSQLiteToKV(path, js, JetStreamConfig{Replicas: 1}); err != nil {
|
|
t.Fatalf("migrate run 2: %v", err)
|
|
}
|
|
|
|
kv, _ := OpenJetStream(js, JetStreamConfig{Replicas: 1})
|
|
kvSnap, err := kv.(*jetstreamStore).ExportSnapshot()
|
|
if err != nil {
|
|
t.Fatalf("export kv: %v", err)
|
|
}
|
|
normalizeSnapshot(srcSnap)
|
|
normalizeSnapshot(kvSnap)
|
|
if !reflect.DeepEqual(srcSnap, kvSnap) {
|
|
t.Fatalf("idempotency broken: a second migration changed the KV state\n sqlite=%+v\n kv= %+v", srcSnap, kvSnap)
|
|
}
|
|
}
|
|
|
|
// TestBackupSQLiteCreatesConsistentCopy verifies the pre-migration backup is a
|
|
// real, openable copy holding the same data.
|
|
func TestBackupSQLiteCreatesConsistentCopy(t *testing.T) {
|
|
src, path := seedSQLite(t)
|
|
srcSnap, _ := src.ExportSnapshot()
|
|
src.Close()
|
|
|
|
bak, err := BackupSQLite(path)
|
|
if err != nil {
|
|
t.Fatalf("backup: %v", err)
|
|
}
|
|
restored, err := openSQLite(bak)
|
|
if err != nil {
|
|
t.Fatalf("open backup: %v", err)
|
|
}
|
|
defer restored.Close()
|
|
bakSnap, err := restored.ExportSnapshot()
|
|
if err != nil {
|
|
t.Fatalf("export backup: %v", err)
|
|
}
|
|
normalizeSnapshot(srcSnap)
|
|
normalizeSnapshot(bakSnap)
|
|
if !reflect.DeepEqual(srcSnap, bakSnap) {
|
|
t.Fatalf("backup is not a faithful copy")
|
|
}
|
|
}
|