Files
spinifex/tests/integration/shared_nats.go
T
Josh Siviour 7d2106bfb5 fix(test): reclaim fixture scratch dirs stranded in /tmp (#598)
* fix(test): reclaim fixture scratch dirs stranded in /tmp

The shared predastore fixture and the integration suite's JetStream store both
outlive any individual test, so neither can use t.TempDir() - the first test to
finish would delete files the daemon still has open. Both used os.MkdirTemp
with nothing removing the directory afterwards, so every run stranded one, and
a run killed by a timeout panic or the OOM killer stranded it regardless. /tmp
is a tmpfs on the dev boxes, so the leak is charged to RAM and swap, and
swapped tmpfs pages have no backing store to be dropped to.

Add a shared sweep that reclaims a directory only when it matches both the
prefix and an age no live run can reach. The NATS store also has a clean
shutdown point, so TestMain now removes it outright and the sweep is left to
cover the killed-run case. The predastore daemon runs until the process exits
and has no such point, so the sweep is its only mechanism.

* chore: repin viperblock to the dev tip carrying the scratch-dir fix

Test-only upstream change, so the build output is unchanged - this keeps
go.mod aligned with the submodule pointer rather than delivering a fix.
2026-07-29 14:57:50 +10:00

195 lines
6.6 KiB
Go

//go:build integration
package integration
import (
"fmt"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/mulgadc/spinifex/tests/fixtures/scratch"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// sharedNATSHarness is the package-wide handle TestMain installs before
// m.Run() and every StartGateway call reads from.
var sharedNATSHarness *sharedNATS
// sharedNATS owns the one embedded NATS+JetStream server shared by every
// test in the package, plus the dynamic per-account authenticator that
// gives each test its own isolated JetStream namespace on that server. See
// the TestMain doc comment (main_test.go) for why the server — and every
// connection into it — is shared across the whole package rather than
// booted fresh per test.
type sharedNATS struct {
srv *server.Server
auth *accountAuthenticator
storeDir string
connsMu sync.Mutex
conns []*nats.Conn
}
// natsStorePrefix names each run's JetStream store directory. It is a constant
// because the sweep below matches on it.
const natsStorePrefix = "spinifex-integration-nats-"
// startSharedNATS boots the one embedded, JetStream-enabled NATS server the
// whole package's tests connect into.
func startSharedNATS() (*sharedNATS, error) {
// The store cannot be a t.TempDir(): the server outlives every individual
// test, so the first test to finish would delete it out from under it.
scratch.SweepAbandoned(os.TempDir(), natsStorePrefix, scratch.DefaultMaxAge)
storeDir, err := os.MkdirTemp("", natsStorePrefix)
if err != nil {
return nil, fmt.Errorf("nats store tempdir: %w", err)
}
auth := newAccountAuthenticator()
opts := &server.Options{
Host: "127.0.0.1",
Port: -1,
JetStream: true,
StoreDir: storeDir,
NoLog: true,
NoSigs: true,
CustomClientAuthentication: auth,
}
ns, err := server.NewServer(opts)
if err != nil {
return nil, fmt.Errorf("new nats server: %w", err)
}
auth.srv = ns
go ns.Start()
if !ns.ReadyForConnections(10 * time.Second) {
return nil, fmt.Errorf("nats server not ready for connections")
}
return &sharedNATS{srv: ns, auth: auth, storeDir: storeDir}, nil
}
// removeStore reclaims the JetStream store once the server is fully down.
// Called after WaitForShutdown so nothing is still writing into it.
func (h *sharedNATS) removeStore() {
if h.storeDir == "" {
return
}
if err := os.RemoveAll(h.storeDir); err != nil {
fmt.Fprintf(os.Stderr, "tests/integration: remove nats store %s: %v\n", h.storeDir, err)
}
}
// connectIsolated opens a fresh connection scoped to its own dynamically
// provisioned NATS account keyed by name, giving the caller a JetStream
// namespace no other test's account can see or collide with. The returned
// connection is tracked and closed by TestMain once every test has
// finished, not by the caller — see the TestMain doc comment for why.
func (h *sharedNATS) connectIsolated(name string) (*nats.Conn, jetstream.JetStream, error) {
nc, err := nats.Connect(h.srv.ClientURL(), nats.UserInfo(name, "x"))
if err != nil {
return nil, nil, fmt.Errorf("connect: %w", err)
}
js, err := jetstream.New(nc)
if err != nil {
nc.Close()
return nil, nil, fmt.Errorf("jetstream: %w", err)
}
h.connsMu.Lock()
h.conns = append(h.conns, nc)
h.connsMu.Unlock()
return nc, js, nil
}
// closeAll closes every connection opened via connectIsolated. Only called
// from TestMain after every test has finished.
func (h *sharedNATS) closeAll() {
h.connsMu.Lock()
defer h.connsMu.Unlock()
for _, nc := range h.conns {
nc.Close()
}
}
// accountAuthenticator implements nats-server's Authentication interface
// (wired in via Options.CustomClientAuthentication). Every distinct
// username that connects gets its own dedicated, JetStream-enabled NATS
// account, created on first sight and reused after. This is what gives each
// test a private JetStream namespace on the one shared server: fixed KV
// bucket names production services hard-code (e.g. IAM's "iam-users")
// resolve to a distinct JetStream store per account instead of one global
// bucket every test would otherwise collide on. NATS subjects are equally
// account-scoped by default, so per-test daemon-side stubs (StubSubject)
// stay isolated the same way they were when each test had its own server.
type accountAuthenticator struct {
srv *server.Server
mu sync.Mutex
accounts map[string]*server.Account
}
func newAccountAuthenticator() *accountAuthenticator {
return &accountAuthenticator{accounts: make(map[string]*server.Account)}
}
// Check implements server.Authentication. The account name is the username
// the client connected with (see accountNameFor), so it is only ever
// created once even if a test somehow reconnected.
func (a *accountAuthenticator) Check(c server.ClientAuthentication) bool {
name := c.GetOpts().Username
if name == "" {
return false
}
acc, err := a.accountFor(name)
if err != nil {
return false
}
c.RegisterUser(&server.User{Username: name, Account: acc})
return true
}
func (a *accountAuthenticator) accountFor(name string) (*server.Account, error) {
a.mu.Lock()
defer a.mu.Unlock()
if acc, ok := a.accounts[name]; ok {
return acc, nil
}
acc, err := a.srv.RegisterAccount(name)
if err != nil {
return nil, fmt.Errorf("register account %s: %w", name, err)
}
if err := acc.EnableJetStream(nil, nil); err != nil {
return nil, fmt.Errorf("enable jetstream for account %s: %w", name, err)
}
a.accounts[name] = acc
return acc, nil
}
// accountNameReplacer strips characters NATS account names / on-disk
// JetStream store directories don't tolerate well (subtests separate their
// path with "/", and some test names contain spaces).
var accountNameReplacer = strings.NewReplacer("/", "_", " ", "_")
// accountSeq disambiguates repeat runs of one test. t.Name() is unique among
// tests in a binary but NOT across passes: -count=N replays identical names
// inside the same TestMain, so a name-only account key hands the second pass
// the first pass's JetStream namespace. That namespace still holds an ECR
// signing key sealed with the first pass's master key, which StartGateway
// regenerates per call — so the reused key failed to decrypt and took down
// every gateway-starting test on the second pass.
var accountSeq atomic.Uint64
// accountNameFor derives a fresh NATS account name for t, so each gateway gets
// an empty JetStream namespace no matter how often the test is replayed.
func accountNameFor(t *testing.T) string {
return fmt.Sprintf("%s_%d", accountNameReplacer.Replace(t.Name()), accountSeq.Add(1))
}