test: add 10 viperblockd coverage tests, fix daemon test sleep

viperblockd: add snapshot handler, sync/unmount/delete with VB instance,
invalid JSON, dual-publish, generic topic routing, and auxiliary volume
suffix tests. Coverage 43.1% → 53.6%.

daemon: write clean shutdown marker in restore tests to skip the 3s
crash-recovery sleep (no QEMU runs in tests). Suite 36s → 6s.
This commit is contained in:
Julian Sommer
2026-02-20 08:42:30 +10:00
parent e6ffecafca
commit eba702b1f2
3 changed files with 429 additions and 17 deletions
+20 -10
View File
@@ -61,6 +61,7 @@ func createDaemonWithJetStream(t *testing.T) *Daemon {
daemon.jsManager, err = NewJetStreamManager(nc, 1)
require.NoError(t, err)
require.NoError(t, daemon.jsManager.InitKVBucket())
require.NoError(t, daemon.jsManager.InitClusterStateBucket())
return daemon
}
@@ -588,6 +589,15 @@ func TestTransitionState_MultipleInstancesIndependent(t *testing.T) {
// running, isInstanceProcessRunning returns false for all instances, which
// exercises the recovery state resolution logic.
// simulateCleanRestore writes a clean shutdown marker then calls restoreInstances.
// Without the marker, restoreInstances sleeps 3s waiting for stale QEMU PIDs —
// unnecessary in tests since no QEMU process ever runs.
func simulateCleanRestore(t *testing.T, daemon *Daemon) {
t.Helper()
require.NoError(t, daemon.jsManager.WriteShutdownMarker(daemon.node))
daemon.restoreInstances()
}
// TestRestoreInstances_StoppingFinalizedToStopped verifies that an instance
// stuck in StateStopping when the daemon died gets finalized to StateStopped
// and migrated to shared KV.
@@ -605,7 +615,7 @@ func TestRestoreInstances_StoppingFinalizedToStopped(t *testing.T) {
daemon.Instances.VMS = make(map[string]*vm.VM)
// restoreInstances loads from JetStream and resolves transitional states.
daemon.restoreInstances()
simulateCleanRestore(t, daemon)
// Stopped instances are migrated to shared KV and removed from local map
_, ok := daemon.Instances.VMS["i-restore-stop"]
@@ -630,7 +640,7 @@ func TestRestoreInstances_ShuttingDownFinalizedToTerminated(t *testing.T) {
require.NoError(t, daemon.WriteState())
daemon.Instances.VMS = make(map[string]*vm.VM)
daemon.restoreInstances()
simulateCleanRestore(t, daemon)
instance, ok := daemon.Instances.VMS["i-restore-shut"]
require.True(t, ok, "instance should be loaded from state")
@@ -650,7 +660,7 @@ func TestRestoreInstances_TerminatedSkipped(t *testing.T) {
require.NoError(t, daemon.WriteState())
daemon.Instances.VMS = make(map[string]*vm.VM)
daemon.restoreInstances()
simulateCleanRestore(t, daemon)
instance, ok := daemon.Instances.VMS["i-restore-term"]
require.True(t, ok, "terminated instance should still be loaded")
@@ -673,7 +683,7 @@ func TestRestoreInstances_UserStoppedMigratedToSharedKV(t *testing.T) {
require.NoError(t, daemon.WriteState())
daemon.Instances.VMS = make(map[string]*vm.VM)
daemon.restoreInstances()
simulateCleanRestore(t, daemon)
// Stopped instances should be migrated to shared KV
_, ok := daemon.Instances.VMS["i-restore-userstop"]
@@ -700,7 +710,7 @@ func TestRestoreInstances_RunningResetToPending(t *testing.T) {
require.NoError(t, daemon.WriteState())
daemon.Instances.VMS = make(map[string]*vm.VM)
daemon.restoreInstances()
simulateCleanRestore(t, daemon)
instance, ok := daemon.Instances.VMS["i-restore-run"]
require.True(t, ok, "running instance should still be loaded")
@@ -732,7 +742,7 @@ func TestRestoreInstances_MixedStates(t *testing.T) {
require.NoError(t, daemon.WriteState())
daemon.Instances.VMS = make(map[string]*vm.VM)
daemon.restoreInstances()
simulateCleanRestore(t, daemon)
// Stopped instances are migrated to shared KV and removed from local map
assert.Nil(t, daemon.Instances.VMS["i-mix-stopping"],
@@ -771,7 +781,7 @@ func TestRestoreInstances_StatePersistsAfterRecovery(t *testing.T) {
// First restart: finalize transitional states.
daemon.Instances.VMS = make(map[string]*vm.VM)
daemon.restoreInstances()
simulateCleanRestore(t, daemon)
// stopping→stopped migrates to shared KV
assert.Nil(t, daemon.Instances.VMS["i-persist-stop"],
@@ -786,7 +796,7 @@ func TestRestoreInstances_StatePersistsAfterRecovery(t *testing.T) {
// Second restart: stopped instance is in shared KV (not per-node state),
// so local map won't have it. Terminated instance persists in per-node state.
daemon.Instances.VMS = make(map[string]*vm.VM)
daemon.restoreInstances()
simulateCleanRestore(t, daemon)
// Stopped instance should still be in shared KV
stoppedInst2, err := daemon.jsManager.LoadStoppedInstance("i-persist-stop")
@@ -844,7 +854,7 @@ func TestRestoreInstances_StoppedInstanceMigratedToSharedKV(t *testing.T) {
// Simulate daemon restart: clear in-memory state and restore from JetStream.
daemon.Instances.VMS = make(map[string]*vm.VM)
daemon.restoreInstances()
simulateCleanRestore(t, daemon)
// Instance should not be in local map (migrated to shared KV).
_, ok := daemon.Instances.VMS[instanceID]
@@ -876,7 +886,7 @@ func TestRestoreInstances_UnknownTypeMigratedToStoppedWithReason(t *testing.T) {
// Simulate daemon restart
daemon.Instances.VMS = make(map[string]*vm.VM)
daemon.restoreInstances()
simulateCleanRestore(t, daemon)
// Instance should not be in local map (migrated to shared KV)
_, ok := daemon.Instances.VMS[instanceID]
@@ -1,6 +1,6 @@
package viperblockd
// Tests for NATS message handlers: ebs.delete, ebs.sync, ebs.unmount (socket cleanup)
// Tests for NATS message handlers: ebs.delete, ebs.sync, ebs.unmount, ebs.snapshot
// These extend the existing integration tests to cover untested handler paths.
import (
@@ -11,11 +11,32 @@ import (
"time"
"github.com/mulgadc/hive/hive/config"
"github.com/mulgadc/viperblock/viperblock"
"github.com/mulgadc/viperblock/viperblock/backends/file"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// createTestVBWithState creates a real *viperblock.VB with file backend for handler tests.
func createTestVBWithState(t *testing.T, volumeName string) *viperblock.VB {
t.Helper()
baseDir := t.TempDir()
fileCfg := file.FileConfig{VolumeName: volumeName, BaseDir: baseDir}
vbCfg := &viperblock.VB{
VolumeName: volumeName,
VolumeSize: 1, // minimal
BaseDir: baseDir,
WALSyncInterval: -1, // disable background goroutine
Cache: viperblock.Cache{Config: viperblock.CacheConfig{Size: 0}},
}
vb, err := viperblock.New(vbCfg, "file", fileCfg)
require.NoError(t, err)
require.NoError(t, vb.Backend.Init())
require.NoError(t, vb.SaveState())
return vb
}
// --- ebs.delete handler tests ---
func TestIntegration_EBSDeleteMountedVolume(t *testing.T) {
@@ -299,6 +320,318 @@ func TestIntegration_EBSDeleteRemovesSocket(t *testing.T) {
assert.False(t, fileExistsCheck(socketPath))
}
// --- ebs.snapshot handler tests ---
func TestIntegration_SnapshotHandler_Success(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
ns, natsURL := setupEmbeddedNATS(t)
defer ns.Shutdown()
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
vb := createTestVBWithState(t, "vol-snap-ok")
snapSub, err := nc.Subscribe("ebs.snapshot.vol-snap-ok", makeSnapshotHandler(vb, "vol-snap-ok"))
require.NoError(t, err)
defer snapSub.Unsubscribe()
nc.Flush()
reqData, _ := json.Marshal(config.EBSSnapshotRequest{Volume: "vol-snap-ok", SnapshotID: "snap-001"})
msg, err := nc.Request("ebs.snapshot.vol-snap-ok", reqData, 3*time.Second)
require.NoError(t, err)
var resp config.EBSSnapshotResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
assert.True(t, resp.Success)
assert.Equal(t, "snap-001", resp.SnapshotID)
assert.Empty(t, resp.Error)
}
func TestIntegration_SnapshotHandler_InvalidJSON(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
ns, natsURL := setupEmbeddedNATS(t)
defer ns.Shutdown()
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
vb := createTestVBWithState(t, "vol-snap-badjson")
snapSub, err := nc.Subscribe("ebs.snapshot.vol-snap-badjson", makeSnapshotHandler(vb, "vol-snap-badjson"))
require.NoError(t, err)
defer snapSub.Unsubscribe()
nc.Flush()
msg, err := nc.Request("ebs.snapshot.vol-snap-badjson", []byte("not json {{{"), 3*time.Second)
require.NoError(t, err)
var resp config.EBSSnapshotResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
assert.Contains(t, resp.Error, "bad request:")
}
func TestIntegration_SnapshotHandler_CreateSnapshotFailure(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
ns, natsURL := setupEmbeddedNATS(t)
defer ns.Shutdown()
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
vb := createTestVBWithState(t, "vol-snap-fail")
// Make the base directory read-only so WriteTo fails with permission denied
require.NoError(t, os.Chmod(vb.BaseDir, 0555))
t.Cleanup(func() { os.Chmod(vb.BaseDir, 0755) })
snapSub, err := nc.Subscribe("ebs.snapshot.vol-snap-fail", makeSnapshotHandler(vb, "vol-snap-fail"))
require.NoError(t, err)
defer snapSub.Unsubscribe()
nc.Flush()
reqData, _ := json.Marshal(config.EBSSnapshotRequest{Volume: "vol-snap-fail", SnapshotID: "snap-fail-001"})
msg, err := nc.Request("ebs.snapshot.vol-snap-fail", reqData, 3*time.Second)
require.NoError(t, err)
var resp config.EBSSnapshotResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
assert.False(t, resp.Success)
assert.Contains(t, resp.Error, "snapshot failed:")
}
// --- ebs.sync with VB instance ---
func TestIntegration_EBSSyncWithVBInstance(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
ns, natsURL := setupEmbeddedNATS(t)
defer ns.Shutdown()
vb := createTestVBWithState(t, "vol-sync-vb")
cfg := setupTestConfig(t, natsURL)
cfg.MountedVolumes = []MountedVolume{
{Name: "vol-sync-vb", VB: vb},
}
go func() { launchService(cfg) }()
time.Sleep(500 * time.Millisecond)
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
reqData, _ := json.Marshal(config.EBSSyncRequest{Volume: "vol-sync-vb"})
msg, err := nc.Request("ebs.sync", reqData, 3*time.Second)
require.NoError(t, err)
var resp config.EBSSyncResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
assert.True(t, resp.Synced)
assert.Empty(t, resp.Error)
}
// --- ebs.unmount invalid JSON ---
func TestIntegration_EBSUnmountInvalidJSON(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
ns, natsURL := setupEmbeddedNATS(t)
defer ns.Shutdown()
cfg := setupTestConfig(t, natsURL)
go func() { launchService(cfg) }()
time.Sleep(500 * time.Millisecond)
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
// Current code silently returns without responding on unmarshal failure
_, err = nc.Request("ebs.test-node.unmount", []byte("not json {{{"), 1*time.Second)
assert.Error(t, err, "expected timeout because handler silently drops invalid JSON")
}
// --- ebs.delete with VB instance (StopWALSyncer path) ---
func TestIntegration_EBSDeleteWithVBInstance(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
ns, natsURL := setupEmbeddedNATS(t)
defer ns.Shutdown()
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
vb := createTestVBWithState(t, "vol-del-vb")
tmpDir := t.TempDir()
socketPath := filepath.Join(tmpDir, "vol-del-vb.sock")
require.NoError(t, os.WriteFile(socketPath, []byte("fake"), 0600))
snapSub, err := nc.Subscribe("ebs.snapshot.vol-del-vb", func(msg *nats.Msg) {})
require.NoError(t, err)
cfg := setupTestConfig(t, natsURL)
cfg.MountedVolumes = []MountedVolume{
{
Name: "vol-del-vb",
Socket: socketPath,
PID: 99999,
VB: vb,
SnapshotSub: snapSub,
},
}
go func() { launchService(cfg) }()
time.Sleep(500 * time.Millisecond)
reqData, _ := json.Marshal(config.EBSDeleteRequest{Volume: "vol-del-vb"})
msg, err := nc.Request("ebs.delete", reqData, 3*time.Second)
require.NoError(t, err)
var resp config.EBSDeleteResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
assert.True(t, resp.Success)
assert.Empty(t, resp.Error)
// Verify full cleanup
assert.False(t, fileExistsCheck(socketPath))
assert.False(t, snapSub.IsValid())
}
// --- ebs.unmount with VB instance + SnapshotSub ---
func TestIntegration_EBSUnmountWithVBInstance(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
ns, natsURL := setupEmbeddedNATS(t)
defer ns.Shutdown()
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
vb := createTestVBWithState(t, "vol-unmount-vb")
tmpDir := t.TempDir()
socketPath := filepath.Join(tmpDir, "vol-unmount-vb.sock")
require.NoError(t, os.WriteFile(socketPath, []byte("fake"), 0600))
snapSub, err := nc.Subscribe("ebs.snapshot.vol-unmount-vb", func(msg *nats.Msg) {})
require.NoError(t, err)
cfg := setupTestConfig(t, natsURL)
cfg.MountedVolumes = []MountedVolume{
{
Name: "vol-unmount-vb",
Socket: socketPath,
PID: 99999,
VB: vb,
SnapshotSub: snapSub,
},
}
go func() { launchService(cfg) }()
time.Sleep(500 * time.Millisecond)
reqData, _ := json.Marshal(config.EBSRequest{Name: "vol-unmount-vb"})
msg, err := nc.Request("ebs.test-node.unmount", reqData, 3*time.Second)
require.NoError(t, err)
var resp config.EBSUnMountResponse
require.NoError(t, json.Unmarshal(msg.Data, &resp))
assert.Equal(t, "vol-unmount-vb", resp.Volume)
assert.False(t, resp.Mounted)
assert.Empty(t, resp.Error)
// Verify cleanup
assert.False(t, fileExistsCheck(socketPath))
assert.False(t, snapSub.IsValid())
}
// --- ebs.unmount dual-publish verification ---
func TestIntegration_EBSUnmountDualPublish(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
ns, natsURL := setupEmbeddedNATS(t)
defer ns.Shutdown()
cfg := setupTestConfig(t, natsURL)
cfg.MountedVolumes = []MountedVolume{
{Name: "vol-dual-pub", PID: 99999},
}
go func() { launchService(cfg) }()
time.Sleep(500 * time.Millisecond)
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
// Subscribe to the broadcast response topic before sending unmount
broadcastCh := make(chan *nats.Msg, 1)
_, err = nc.Subscribe("ebs.unmount.response", func(msg *nats.Msg) {
broadcastCh <- msg
})
require.NoError(t, err)
nc.Flush()
reqData, _ := json.Marshal(config.EBSRequest{Name: "vol-dual-pub"})
msg, err := nc.Request("ebs.test-node.unmount", reqData, 3*time.Second)
require.NoError(t, err)
// Verify direct reply
var directResp config.EBSUnMountResponse
require.NoError(t, json.Unmarshal(msg.Data, &directResp))
assert.Equal(t, "vol-dual-pub", directResp.Volume)
assert.False(t, directResp.Mounted)
// Verify broadcast response received
select {
case broadcastMsg := <-broadcastCh:
var broadcastResp config.EBSUnMountResponse
require.NoError(t, json.Unmarshal(broadcastMsg.Data, &broadcastResp))
assert.Equal(t, "vol-dual-pub", broadcastResp.Volume)
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for broadcast on ebs.unmount.response")
}
}
// fileExistsCheck is a helper to check if a file exists on disk.
func fileExistsCheck(path string) bool {
_, err := os.Stat(path)
@@ -475,14 +475,83 @@ func TestIntegration_ServiceGracefulShutdown(t *testing.T) {
t.Log("Service is running and responsive")
}
// TestIntegration_NATSReconnection tests behavior when NATS connection is lost
func TestIntegration_NATSReconnection(t *testing.T) {
// TestIntegration_GenericTopicRouting tests that NodeName="" uses generic ebs.unmount with queue group
func TestIntegration_GenericTopicRouting(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
// This test would require stopping and restarting NATS server
// which is complex with the current architecture
// Marked as TODO for future enhancement
t.Skip("TODO: Implement NATS reconnection test")
ns, natsURL := setupEmbeddedNATS(t)
defer ns.Shutdown()
cfg := setupTestConfig(t, natsURL)
cfg.NodeName = "" // Single-node / generic mode
cfg.MountedVolumes = []MountedVolume{
{Name: "vol-generic", PID: 99999},
}
go func() { launchService(cfg) }()
time.Sleep(500 * time.Millisecond)
nc, err := nats.Connect(natsURL)
assert.NoError(t, err)
defer nc.Close()
// Send to generic topic (not ebs.{node}.unmount)
request := config.EBSRequest{Name: "vol-generic"}
requestData, _ := json.Marshal(request)
msg, err := nc.Request("ebs.unmount", requestData, 3*time.Second)
assert.NoError(t, err)
var response config.EBSUnMountResponse
assert.NoError(t, json.Unmarshal(msg.Data, &response))
assert.Equal(t, "vol-generic", response.Volume)
assert.False(t, response.Mounted)
assert.Empty(t, response.Error)
}
// TestIntegration_MountAuxiliaryVolumeSuffix tests that volumes with -cloudinit suffix
// reach the mount handler and trigger the auxiliary volume code path
func TestIntegration_MountAuxiliaryVolumeSuffix(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
ns, natsURL := setupEmbeddedNATS(t)
defer ns.Shutdown()
cfg := setupTestConfig(t, natsURL)
go func() { launchService(cfg) }()
time.Sleep(500 * time.Millisecond)
nc, err := nats.Connect(natsURL)
assert.NoError(t, err)
defer nc.Close()
nc.Flush()
// Send mount request for auxiliary volume (will fail at S3 backend, but validates routing)
request := config.EBSRequest{
Name: "vol-test-cloudinit",
VolType: "gp3",
}
requestData, _ := json.Marshal(request)
msg, err := nc.Request("ebs.test-node.mount", requestData, 5*time.Second)
if err != nil {
// Timeout is acceptable — service attempted to process but backend failed
t.Logf("Request error (expected with mocked S3 backend): %v", err)
return
}
var response config.EBSMountResponse
assert.NoError(t, json.Unmarshal(msg.Data, &response))
// Expect an error because S3 backend is mocked, but the request was processed
assert.NotEmpty(t, response.Error)
t.Logf("Auxiliary volume mount processed with expected error: %s", response.Error)
}