feat(0003c): membershipd migrate-to-kv (idempotent SQLite -> JetStream KV)
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.
This commit is contained in:
@@ -33,6 +33,13 @@ func main() {
|
||||
runUserCLI(os.Args[2:])
|
||||
return
|
||||
}
|
||||
// `membershipd migrate-to-kv` is the one-time, idempotent SQLite->JetStream KV
|
||||
// data move for decentralization (issue 0003c). Like the user CLI it runs on
|
||||
// the host and is dispatched before the server flag set parses os.Args.
|
||||
if len(os.Args) > 1 && os.Args[1] == "migrate-to-kv" {
|
||||
runMigrateCLI(os.Args[2:])
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
bind = flag.String("bind", "127.0.0.1", "network interface to bind the HTTP API and the embedded NATS to; use 0.0.0.0 to accept LAN/remote peers")
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/busauth"
|
||||
"github.com/enmanuel/unibus/pkg/membership"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// runMigrateCLI implements `membershipd migrate-to-kv`, the idempotent move of
|
||||
// the control-plane state from the local SQLite database into replicated
|
||||
// JetStream KV (issue 0003c). It backs up the SQLite file first (VACUUM INTO),
|
||||
// then connects to the target NATS and copies every room/member/key/user into
|
||||
// the KV buckets. Re-running it converges to the same state.
|
||||
//
|
||||
// It runs on the bus host (no auth on the control-plane side), connecting to the
|
||||
// cluster's NATS; --ca pins TLS when the data plane is secured.
|
||||
func runMigrateCLI(args []string) {
|
||||
fs := flag.NewFlagSet("migrate-to-kv", flag.ExitOnError)
|
||||
dbPath := fs.String("db", defaultDBPath, "SQLite database path to migrate FROM")
|
||||
natsURL := fs.String("nats-url", "", "NATS url of the cluster to migrate INTO (required)")
|
||||
ca := fs.String("ca", "", "CA cert to pin TLS on the NATS connection (optional)")
|
||||
replicas := fs.Int("replicas", 1, "KV replication factor (1 for a 1-2 node rollout, 3 for HA quorum)")
|
||||
noBackup := fs.Bool("no-backup", false, "skip the SQLite backup before migrating (NOT recommended)")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
if *natsURL == "" {
|
||||
fmt.Fprintln(os.Stderr, "membershipd migrate-to-kv: --nats-url is required (the cluster to write the KV buckets into)")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// Back up the SQLite database first so a botched migration can be undone.
|
||||
var backupPath string
|
||||
if !*noBackup {
|
||||
bak, err := membership.BackupSQLite(*dbPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "membershipd migrate-to-kv: backup failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
backupPath = bak
|
||||
fmt.Printf("backed up %s -> %s\n", *dbPath, backupPath)
|
||||
}
|
||||
|
||||
// Connect to the target NATS (optionally TLS-pinned to the bus CA).
|
||||
natsOpts := []nats.Option{nats.Name("unibus-migrate")}
|
||||
if *ca != "" {
|
||||
tlsCfg, err := busauth.LoadCATLSConfig(*ca)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "membershipd migrate-to-kv: load CA: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
natsOpts = append(natsOpts, nats.Secure(tlsCfg))
|
||||
}
|
||||
nc, err := nats.Connect(*natsURL, natsOpts...)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "membershipd migrate-to-kv: connect %q: %v\n", *natsURL, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer nc.Close()
|
||||
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "membershipd migrate-to-kv: jetstream: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
report, err := membership.MigrateSQLiteToKV(*dbPath, js, membership.JetStreamConfig{
|
||||
Replicas: *replicas,
|
||||
OpTimeout: 30 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "membershipd migrate-to-kv: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
report.BackupPath = backupPath
|
||||
|
||||
fmt.Printf("migrated to KV (replicas=%d): %d rooms, %d members, %d keys, %d users\n",
|
||||
*replicas, report.Rooms, report.Members, report.Keys, report.Users)
|
||||
if backupPath != "" {
|
||||
fmt.Printf("rollback: restore %s if needed\n", backupPath)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user