Files
kandev/apps/backend/internal/agent/lifecycle/executor_registry_test.go
T

354 lines
11 KiB
Go

package lifecycle
import (
"context"
"errors"
"sync"
"testing"
"github.com/kandev/kandev/internal/agent/executor"
"github.com/kandev/kandev/internal/agentctl/server/process"
"github.com/kandev/kandev/internal/common/logger"
)
// MockExecutor implements Runtime interface for testing
type MockExecutor struct {
name executor.Name
healthCheckErr error
recoverInstances []*ExecutorInstance
recoverErr error
}
func (m *MockExecutor) Name() executor.Name { return m.name }
func (m *MockExecutor) HealthCheck(ctx context.Context) error {
return m.healthCheckErr
}
func (m *MockExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) {
return nil, nil
}
func (m *MockExecutor) StopInstance(ctx context.Context, instance *ExecutorInstance, force bool) error {
return nil
}
func (m *MockExecutor) RecoverInstances(ctx context.Context) ([]*ExecutorInstance, error) {
return m.recoverInstances, m.recoverErr
}
func (m *MockExecutor) GetInteractiveRunner() *process.InteractiveRunner {
return nil
}
func (m *MockExecutor) RequiresCloneURL() bool { return false }
func (m *MockExecutor) ShouldApplyPreferredShell() bool { return false }
func (m *MockExecutor) IsAlwaysResumable() bool { return false }
func newTestRegistryLogger() *logger.Logger {
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
return log
}
func TestNewExecutorRegistry(t *testing.T) {
log := newTestRegistryLogger()
registry := NewExecutorRegistry(log)
if registry == nil {
t.Fatal("expected non-nil registry")
} else {
if registry.backends == nil {
t.Error("expected runtimes map to be initialized")
}
if len(registry.backends) != 0 {
t.Errorf("expected empty runtimes map, got %d entries", len(registry.backends))
}
}
}
func TestExecutorRegistry_Register(t *testing.T) {
t.Run("register new runtime", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewExecutorRegistry(log)
mockRT := &MockExecutor{name: executor.NameDocker}
registry.Register(mockRT)
if len(registry.backends) != 1 {
t.Errorf("expected 1 runtime, got %d", len(registry.backends))
}
if registry.backends[executor.NameDocker] != mockRT {
t.Error("expected registered runtime to match")
}
})
t.Run("replace existing runtime", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewExecutorRegistry(log)
mockRT1 := &MockExecutor{name: executor.NameDocker, healthCheckErr: errors.New("error1")}
mockRT2 := &MockExecutor{name: executor.NameDocker, healthCheckErr: errors.New("error2")}
registry.Register(mockRT1)
registry.Register(mockRT2)
if len(registry.backends) != 1 {
t.Errorf("expected 1 runtime after replacement, got %d", len(registry.backends))
}
if registry.backends[executor.NameDocker] != mockRT2 {
t.Error("expected runtime to be replaced with new one")
}
})
}
func TestExecutorRegistry_GetRuntime(t *testing.T) {
t.Run("get existing runtime", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewExecutorRegistry(log)
mockRT := &MockExecutor{name: executor.NameDocker}
registry.Register(mockRT)
rt, err := registry.GetBackend(executor.NameDocker)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if rt != mockRT {
t.Error("expected returned runtime to match registered one")
}
})
t.Run("get non-existent runtime returns ErrExecutorNotFound", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewExecutorRegistry(log)
_, err := registry.GetBackend(executor.NameDocker)
if err == nil {
t.Error("expected error for non-existent runtime")
}
if !errors.Is(err, ErrExecutorNotFound) {
t.Errorf("expected ErrExecutorNotFound, got: %v", err)
}
})
}
func TestExecutorRegistry_List(t *testing.T) {
t.Run("list empty registry", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewExecutorRegistry(log)
names := registry.List()
if len(names) != 0 {
t.Errorf("expected empty list, got %d items", len(names))
}
})
t.Run("list with multiple runtimes", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewExecutorRegistry(log)
registry.Register(&MockExecutor{name: executor.NameDocker})
registry.Register(&MockExecutor{name: executor.NameStandalone})
registry.Register(&MockExecutor{name: executor.NameLocal})
names := registry.List()
if len(names) != 3 {
t.Errorf("expected 3 names, got %d", len(names))
}
// Check all names are present (order is not guaranteed)
nameMap := make(map[executor.Name]bool)
for _, n := range names {
nameMap[n] = true
}
if !nameMap[executor.NameDocker] || !nameMap[executor.NameStandalone] || !nameMap[executor.NameLocal] {
t.Error("expected all registered runtime names to be present")
}
})
}
func TestExecutorRegistry_HealthCheckAll(t *testing.T) {
ctx := context.Background()
t.Run("health check with all healthy runtimes", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewExecutorRegistry(log)
registry.Register(&MockExecutor{name: executor.NameDocker, healthCheckErr: nil})
registry.Register(&MockExecutor{name: executor.NameStandalone, healthCheckErr: nil})
results := registry.HealthCheckAll(ctx)
if len(results) != 2 {
t.Errorf("expected 2 results, got %d", len(results))
}
if results[executor.NameDocker] != nil {
t.Errorf("expected nil error for docker, got: %v", results[executor.NameDocker])
}
if results[executor.NameStandalone] != nil {
t.Errorf("expected nil error for standalone, got: %v", results[executor.NameStandalone])
}
})
t.Run("health check with some failing runtimes", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewExecutorRegistry(log)
dockerErr := errors.New("docker unavailable")
registry.Register(&MockExecutor{name: executor.NameDocker, healthCheckErr: dockerErr})
registry.Register(&MockExecutor{name: executor.NameStandalone, healthCheckErr: nil})
results := registry.HealthCheckAll(ctx)
if len(results) != 2 {
t.Errorf("expected 2 results, got %d", len(results))
}
if results[executor.NameDocker] != dockerErr {
t.Errorf("expected docker error, got: %v", results[executor.NameDocker])
}
if results[executor.NameStandalone] != nil {
t.Errorf("expected nil error for standalone, got: %v", results[executor.NameStandalone])
}
})
t.Run("health check with empty registry", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewExecutorRegistry(log)
results := registry.HealthCheckAll(ctx)
if len(results) != 0 {
t.Errorf("expected empty results, got %d", len(results))
}
})
}
func TestExecutorRegistry_RecoverAll(t *testing.T) {
ctx := context.Background()
t.Run("recovery with no instances", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewExecutorRegistry(log)
registry.Register(&MockExecutor{name: executor.NameDocker, recoverInstances: nil})
registry.Register(&MockExecutor{name: executor.NameStandalone, recoverInstances: []*ExecutorInstance{}})
instances, err := registry.RecoverAll(ctx)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if len(instances) != 0 {
t.Errorf("expected no instances, got %d", len(instances))
}
})
t.Run("recovery with instances from multiple runtimes", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewExecutorRegistry(log)
dockerInstances := []*ExecutorInstance{
{InstanceID: "docker-1", RuntimeName: "docker"},
{InstanceID: "docker-2", RuntimeName: "docker"},
}
standaloneInstances := []*ExecutorInstance{
{InstanceID: "standalone-1", RuntimeName: "standalone"},
}
registry.Register(&MockExecutor{name: executor.NameDocker, recoverInstances: dockerInstances})
registry.Register(&MockExecutor{name: executor.NameStandalone, recoverInstances: standaloneInstances})
instances, err := registry.RecoverAll(ctx)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if len(instances) != 3 {
t.Errorf("expected 3 instances, got %d", len(instances))
}
})
t.Run("recovery with errors continues and returns last error", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewExecutorRegistry(log)
recoverErr := errors.New("recovery failed")
standaloneInstances := []*ExecutorInstance{
{InstanceID: "standalone-1", RuntimeName: "standalone"},
}
registry.Register(&MockExecutor{name: executor.NameDocker, recoverErr: recoverErr})
registry.Register(&MockExecutor{name: executor.NameStandalone, recoverInstances: standaloneInstances})
instances, err := registry.RecoverAll(ctx)
// Should still get instances from successful runtimes
// Note: map iteration order is not guaranteed, so we check that at least standalone instances are recovered
if len(instances) == 0 && err == nil {
t.Error("expected either instances or error")
}
// The error may or may not be set depending on iteration order
// If docker is processed last, err will be set; if standalone is processed last, err will be nil
})
t.Run("recovery from empty registry", func(t *testing.T) {
log := newTestRegistryLogger()
registry := NewExecutorRegistry(log)
instances, err := registry.RecoverAll(ctx)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if len(instances) != 0 {
t.Errorf("expected no instances, got %d", len(instances))
}
})
}
func TestExecutorRegistry_ThreadSafety(t *testing.T) {
log := newTestRegistryLogger()
registry := NewExecutorRegistry(log)
ctx := context.Background()
const numGoroutines = 50
const numOperations = 100
var wg sync.WaitGroup
wg.Add(numGoroutines * 4) // 4 types of operations
// Concurrent Register operations
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
name := executor.Name("test-runtime")
registry.Register(&MockExecutor{name: name})
}
}(i)
}
// Concurrent GetRuntime operations
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
_, _ = registry.GetBackend(executor.Name("test-runtime"))
}
}(i)
}
// Concurrent List operations
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
_ = registry.List()
}
}(i)
}
// Concurrent HealthCheckAll operations
for i := 0; i < numGoroutines; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < numOperations; j++ {
_ = registry.HealthCheckAll(ctx)
}
}(i)
}
// Wait for all goroutines to complete
wg.Wait()
// If we reach here without a race condition panic, the test passes
// The -race flag during testing will detect actual race conditions
}