// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 package dynamicplugins import ( "context" "fmt" "sync" "testing" "time" "github.com/hashicorp/nomad/ci" "github.com/stretchr/testify/require" ) func TestPluginEventBroadcaster_SendsMessagesToAllClients(t *testing.T) { ci.Parallel(t) b := newPluginEventBroadcaster() defer close(b.stopCh) var rcv1, rcv2 bool ch1 := b.subscribe() ch2 := b.subscribe() var wg sync.WaitGroup wg.Add(1) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() go func() { defer wg.Done() for { select { case <-ctx.Done(): t.Errorf("did not receive event on both subscriptions before timeout") return case <-ch1: rcv1 = true case <-ch2: rcv2 = true } if rcv1 && rcv2 { return } } }() b.broadcast(&PluginUpdateEvent{}) wg.Wait() } func TestPluginEventBroadcaster_UnsubscribeWorks(t *testing.T) { ci.Parallel(t) b := newPluginEventBroadcaster() defer close(b.stopCh) ch1 := b.subscribe() var wg sync.WaitGroup wg.Add(1) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() go func() { defer wg.Done() for { select { case <-ctx.Done(): t.Errorf("did not receive unsubscribe event on subscription before timeout") return case <-ch1: return // done! } } }() b.unsubscribe(ch1) b.broadcast(&PluginUpdateEvent{}) wg.Wait() } func TestDynamicRegistry_RegisterPlugin_SendsUpdateEvents(t *testing.T) { ci.Parallel(t) r := NewRegistry(nil, nil) var wg sync.WaitGroup wg.Add(1) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() ch := r.PluginsUpdatedCh(ctx, "csi") go func() { defer wg.Done() for { select { case <-ctx.Done(): t.Errorf("did not receive registration event on subscription before timeout") return case e := <-ch: if e != nil && e.EventType == EventTypeRegistered { return } } } }() err := r.RegisterPlugin(&PluginInfo{ Type: "csi", Name: "my-plugin", ConnectionInfo: &PluginConnectionInfo{}, }) require.NoError(t, err) wg.Wait() } func TestDynamicRegistry_DeregisterPlugin_SendsUpdateEvents(t *testing.T) { ci.Parallel(t) r := NewRegistry(nil, nil) var wg sync.WaitGroup wg.Add(1) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() ch := r.PluginsUpdatedCh(ctx, "csi") go func() { defer wg.Done() for { select { case <-ctx.Done(): t.Errorf("did not receive deregistration event on subscription before timeout") return case e := <-ch: if e != nil && e.EventType == EventTypeDeregistered { return } } } }() err := r.RegisterPlugin(&PluginInfo{ Type: "csi", Name: "my-plugin", AllocID: "alloc-0", ConnectionInfo: &PluginConnectionInfo{}, }) require.NoError(t, err) err = r.DeregisterPlugin("csi", "my-plugin", "alloc-0") require.NoError(t, err) wg.Wait() } func TestDynamicRegistry_DispensePlugin_Works(t *testing.T) { ci.Parallel(t) dispenseFn := func(i *PluginInfo) (interface{}, error) { return struct{}{}, nil } registry := NewRegistry(nil, map[string]PluginDispenser{"csi": dispenseFn}) err := registry.RegisterPlugin(&PluginInfo{ Type: "csi", Name: "my-plugin", ConnectionInfo: &PluginConnectionInfo{}, }) require.NoError(t, err) result, err := registry.DispensePlugin("unknown-type", "unknown-name") require.Nil(t, result) require.EqualError(t, err, "no plugin dispenser found for type: unknown-type") result, err = registry.DispensePlugin("csi", "unknown-name") require.Nil(t, result) require.EqualError(t, err, "plugin unknown-name for type csi not found") result, err = registry.DispensePlugin("csi", "my-plugin") require.NotNil(t, result) require.NoError(t, err) } func TestDynamicRegistry_IsolatePluginTypes(t *testing.T) { ci.Parallel(t) r := NewRegistry(nil, nil) err := r.RegisterPlugin(&PluginInfo{ Type: PluginTypeCSIController, Name: "my-plugin", AllocID: "alloc-0", ConnectionInfo: &PluginConnectionInfo{}, }) require.NoError(t, err) err = r.RegisterPlugin(&PluginInfo{ Type: PluginTypeCSINode, Name: "my-plugin", AllocID: "alloc-1", ConnectionInfo: &PluginConnectionInfo{}, }) require.NoError(t, err) err = r.DeregisterPlugin(PluginTypeCSIController, "my-plugin", "alloc-0") require.NoError(t, err) require.Equal(t, 1, len(r.ListPlugins(PluginTypeCSINode))) require.Equal(t, 0, len(r.ListPlugins(PluginTypeCSIController))) } func TestDynamicRegistry_StateStore(t *testing.T) { ci.Parallel(t) dispenseFn := func(i *PluginInfo) (interface{}, error) { return i, nil } memdb := &MemDB{} oldR := NewRegistry(memdb, map[string]PluginDispenser{"csi": dispenseFn}) err := oldR.RegisterPlugin(&PluginInfo{ Type: "csi", Name: "my-plugin", ConnectionInfo: &PluginConnectionInfo{}, }) require.NoError(t, err) result, err := oldR.DispensePlugin("csi", "my-plugin") require.NotNil(t, result) require.NoError(t, err) // recreate the registry from the state store and query again newR := NewRegistry(memdb, map[string]PluginDispenser{"csi": dispenseFn}) result, err = newR.DispensePlugin("csi", "my-plugin") require.NotNil(t, result) require.NoError(t, err) } func TestDynamicRegistry_ConcurrentAllocs(t *testing.T) { ci.Parallel(t) dispenseFn := func(i *PluginInfo) (interface{}, error) { return i, nil } newPlugin := func(idx int) *PluginInfo { id := fmt.Sprintf("alloc-%d", idx) return &PluginInfo{ Name: "my-plugin", Type: PluginTypeCSINode, Version: fmt.Sprintf("v%d", idx), ConnectionInfo: &PluginConnectionInfo{ SocketPath: "/var/data/alloc/" + id + "/csi.sock"}, AllocID: id, } } dispensePlugin := func(t *testing.T, reg Registry) *PluginInfo { result, err := reg.DispensePlugin(PluginTypeCSINode, "my-plugin") require.NotNil(t, result) require.NoError(t, err) plugin := result.(*PluginInfo) return plugin } t.Run("restore races on client restart", func(t *testing.T) { plugin0 := newPlugin(0) plugin1 := newPlugin(1) memdb := &MemDB{} oldR := NewRegistry(memdb, map[string]PluginDispenser{PluginTypeCSINode: dispenseFn}) // add a plugin and a new alloc running the same plugin // (without stopping the old one) require.NoError(t, oldR.RegisterPlugin(plugin0)) require.NoError(t, oldR.RegisterPlugin(plugin1)) plugin := dispensePlugin(t, oldR) require.Equal(t, "alloc-1", plugin.AllocID) // client restarts and we load state from disk. // most recently inserted plugin is current newR := NewRegistry(memdb, map[string]PluginDispenser{PluginTypeCSINode: dispenseFn}) plugin = dispensePlugin(t, oldR) require.Equal(t, "/var/data/alloc/alloc-1/csi.sock", plugin.ConnectionInfo.SocketPath) require.Equal(t, "alloc-1", plugin.AllocID) // RestoreTask fires for all allocations, which runs the // plugin_supervisor_hook. But there's a race and the allocations // in this scenario are Restored in the opposite order they were // created require.NoError(t, newR.RegisterPlugin(plugin0)) plugin = dispensePlugin(t, newR) require.Equal(t, "/var/data/alloc/alloc-1/csi.sock", plugin.ConnectionInfo.SocketPath) require.Equal(t, "alloc-1", plugin.AllocID) }) t.Run("replacement races on host restart", func(t *testing.T) { plugin0 := newPlugin(0) plugin1 := newPlugin(1) plugin2 := newPlugin(2) memdb := &MemDB{} oldR := NewRegistry(memdb, map[string]PluginDispenser{PluginTypeCSINode: dispenseFn}) // add a plugin and a new alloc running the same plugin // (without stopping the old one) require.NoError(t, oldR.RegisterPlugin(plugin0)) require.NoError(t, oldR.RegisterPlugin(plugin1)) plugin := dispensePlugin(t, oldR) require.Equal(t, "alloc-1", plugin.AllocID) // client restarts and we load state from disk. // most recently inserted plugin is current newR := NewRegistry(memdb, map[string]PluginDispenser{PluginTypeCSINode: dispenseFn}) plugin = dispensePlugin(t, oldR) require.Equal(t, "/var/data/alloc/alloc-1/csi.sock", plugin.ConnectionInfo.SocketPath) require.Equal(t, "alloc-1", plugin.AllocID) // RestoreTask fires for all allocations but none of them are // running because we restarted the whole host. Server gives // us a replacement alloc require.NoError(t, newR.RegisterPlugin(plugin2)) plugin = dispensePlugin(t, newR) require.Equal(t, "/var/data/alloc/alloc-2/csi.sock", plugin.ConnectionInfo.SocketPath) require.Equal(t, "alloc-2", plugin.AllocID) }) t.Run("interleaved register and deregister", func(t *testing.T) { plugin0 := newPlugin(0) plugin1 := newPlugin(1) memdb := &MemDB{} reg := NewRegistry(memdb, map[string]PluginDispenser{PluginTypeCSINode: dispenseFn}) require.NoError(t, reg.RegisterPlugin(plugin0)) // replacement is registered before old plugin deregisters require.NoError(t, reg.RegisterPlugin(plugin1)) plugin := dispensePlugin(t, reg) require.Equal(t, "alloc-1", plugin.AllocID) reg.DeregisterPlugin(PluginTypeCSINode, "my-plugin", "alloc-0") plugin = dispensePlugin(t, reg) require.Equal(t, "alloc-1", plugin.AllocID) }) } // MemDB implements a StateDB that stores data in memory and should only be // used for testing. All methods are safe for concurrent use. This is a // partial implementation of the MemDB in the client/state package, copied // here to avoid circular dependencies. type MemDB struct { dynamicManagerPs *RegistryState mu sync.RWMutex } func (m *MemDB) GetDynamicPluginRegistryState() (*RegistryState, error) { m.mu.Lock() defer m.mu.Unlock() return m.dynamicManagerPs, nil } func (m *MemDB) PutDynamicPluginRegistryState(ps *RegistryState) error { m.mu.Lock() defer m.mu.Unlock() m.dynamicManagerPs = ps return nil }