diff --git a/internal/backend/mock.go b/internal/backend/mock.go new file mode 100644 index 0000000..eb14653 --- /dev/null +++ b/internal/backend/mock.go @@ -0,0 +1,71 @@ +package backend + +import ( + "fmt" + "sync" + + "github.com/sandforge/sandforge/pkg/api" +) + +type MockBackend struct { + mu sync.RWMutex + sandboxes map[string]api.SandboxSpec + nextID int +} + +func NewMockBackend() *MockBackend { + return &MockBackend{ + sandboxes: make(map[string]api.SandboxSpec), + nextID: 1, + } +} + +func (m *MockBackend) CreateSandbox(spec api.SandboxSpec) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + handle := fmt.Sprintf("mock-%d", m.nextID) + m.nextID++ + m.sandboxes[handle] = spec + return handle, nil +} + +func (m *MockBackend) MountWorkspace(handle string, mount api.WorkspaceMount) error { + m.mu.RLock() + defer m.mu.RUnlock() + if _, exists := m.sandboxes[handle]; !exists { + return fmt.Errorf("sandbox handle not found: %s", handle) + } + return nil +} + +func (m *MockBackend) Exec(handle string, req api.ExecRequest) (api.ExecResult, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if _, exists := m.sandboxes[handle]; !exists { + return api.ExecResult{}, fmt.Errorf("sandbox handle not found: %s", handle) + } + + return api.ExecResult{ + ExitCode: 0, + Stdout: fmt.Sprintf("mock output for %v", req.Command), + }, nil +} + +func (m *MockBackend) CopyOut(handle string, path string, dest string) error { + m.mu.RLock() + defer m.mu.RUnlock() + if _, exists := m.sandboxes[handle]; !exists { + return fmt.Errorf("sandbox handle not found: %s", handle) + } + return nil +} + +func (m *MockBackend) DestroySandbox(handle string) error { + m.mu.Lock() + defer m.mu.Unlock() + if _, exists := m.sandboxes[handle]; !exists { + return fmt.Errorf("sandbox handle not found: %s", handle) + } + delete(m.sandboxes, handle) + return nil +} diff --git a/internal/supervisor/supervisor.go b/internal/supervisor/supervisor.go new file mode 100644 index 0000000..b311555 --- /dev/null +++ b/internal/supervisor/supervisor.go @@ -0,0 +1,212 @@ +package supervisor + +import ( + "errors" + "fmt" + "sync" + + "github.com/sandforge/sandforge/internal/policy" + "github.com/sandforge/sandforge/pkg/api" +) + +// State represents the current lifecycle phase of a sandbox. +type State string + +const ( + StateRequested State = "requested" + StateProvisioning State = "provisioning" + StateReady State = "ready" + StateExecuting State = "executing" + StateCopyingArtifacts State = "copying_artifacts" + StateDestroying State = "destroying" + StateDestroyed State = "destroyed" + StateError State = "error" +) + +// SandboxInstance tracks the runtime state of a single sandbox. +type SandboxInstance struct { + mu sync.RWMutex + ID string + Spec api.SandboxSpec + State State + Handle string // The backend-specific identifier + Error error +} + +func (i *SandboxInstance) SetState(s State) { + i.mu.Lock() + defer i.mu.Unlock() + i.State = s +} + +func (i *SandboxInstance) GetState() State { + i.mu.RLock() + defer i.mu.RUnlock() + return i.State +} + +func (i *SandboxInstance) SetHandle(h string) { + i.mu.Lock() + defer i.mu.Unlock() + i.Handle = h +} + +func (i *SandboxInstance) GetHandle() string { + i.mu.RLock() + defer i.mu.RUnlock() + return i.Handle +} + +func (i *SandboxInstance) SetError(err error) { + i.mu.Lock() + defer i.mu.Unlock() + i.Error = err +} + +// Supervisor orchestrates sandbox lifecycles and enforces policy. +type Supervisor struct { + mu sync.RWMutex + instances map[string]*SandboxInstance + + backend api.SandboxBackend + policy *policy.Engine +} + +func NewSupervisor(backend api.SandboxBackend, engine *policy.Engine) (*Supervisor, error) { + if backend == nil { + return nil, fmt.Errorf("NewSupervisor: backend is nil") + } + if engine == nil { + return nil, fmt.Errorf("NewSupervisor: policy engine is nil") + } + return &Supervisor{ + instances: make(map[string]*SandboxInstance), + backend: backend, + policy: engine, + }, nil +} + +// Start will be your entry point to create and boot a sandbox. +func (s *Supervisor) Start(id string, spec api.SandboxSpec) error { + // 1. Evaluate policy + if err := s.policy.EvaluateSandbox(spec); err != nil { + return err + } + + // 2. Register instance in 'requested' state + s.mu.Lock() + if _, exists := s.instances[id]; exists { + s.mu.Unlock() + return errors.New("sandbox ID already exists") + } + + instance := &SandboxInstance{ + ID: id, + Spec: spec, + State: StateRequested, + } + s.instances[id] = instance + s.mu.Unlock() + + // 3. Move to 'provisioning' and call backend.CreateSandbox + instance.SetState(StateProvisioning) + handle, err := s.backend.CreateSandbox(spec) + if err != nil { + instance.SetState(StateError) + instance.SetError(err) + return err + } + + // 4. Update state to 'ready' + instance.SetHandle(handle) + instance.SetState(StateReady) + + return nil +} + +// RunCommand will be used to execute something in a ready sandbox. +func (s *Supervisor) RunCommand(id string, req api.ExecRequest) (api.ExecResult, error) { + // 1. Find the instance + s.mu.RLock() + instance, exists := s.instances[id] + s.mu.RUnlock() + + if !exists { + return api.ExecResult{}, errors.New("sandbox not found") + } + + // 2. Validate state and policy + // We lock the instance to check state and transition atomically + instance.mu.Lock() + if instance.State != StateReady { + instance.mu.Unlock() + return api.ExecResult{}, errors.New("sandbox is not in 'ready' state") + } + + if err := s.policy.EvaluateExec(req); err != nil { + instance.mu.Unlock() + return api.ExecResult{}, err + } + + // 3. Move state to 'executing' + instance.State = StateExecuting + handle := instance.Handle + instance.mu.Unlock() + + // Ensure we go back to 'ready' unless a fatal error occurred + defer func() { + instance.mu.Lock() + if instance.State == StateExecuting { + instance.State = StateReady + } + instance.mu.Unlock() + }() + + // 4. Call backend + result, err := s.backend.Exec(handle, req) + if err != nil { + instance.mu.Lock() + instance.State = StateError + instance.Error = err + instance.mu.Unlock() + return result, err + } + + return result, nil +} + +// Stop will clean up the sandbox. +func (s *Supervisor) Stop(id string) error { + // 1. Find the instance + s.mu.RLock() + instance, exists := s.instances[id] + s.mu.RUnlock() + + if !exists { + return errors.New("sandbox not found") + } + + // 2. Move state to 'destroying' + instance.mu.Lock() + handle := instance.Handle + instance.State = StateDestroying + instance.mu.Unlock() + + // 3. Call backend.DestroySandbox (without holding the lock) + if err := s.backend.DestroySandbox(handle); err != nil { + instance.mu.Lock() + instance.State = StateError + instance.Error = err + instance.mu.Unlock() + return err + } + + // 4. Mark destroyed and remove from map + s.mu.Lock() + delete(s.instances, id) + s.mu.Unlock() + + instance.SetState(StateDestroyed) + + return nil +} diff --git a/internal/supervisor/supervisor_test.go b/internal/supervisor/supervisor_test.go new file mode 100644 index 0000000..bcc4aa1 --- /dev/null +++ b/internal/supervisor/supervisor_test.go @@ -0,0 +1,128 @@ +package supervisor + +import ( + "fmt" + "sync" + "testing" + + "github.com/sandforge/sandforge/internal/backend" + "github.com/sandforge/sandforge/internal/policy" + "github.com/sandforge/sandforge/pkg/api" +) + +func TestSupervisorLifecycle(t *testing.T) { + // Setup + mockBackend := backend.NewMockBackend() + engine := &policy.Engine{ + MaxCPU: 4, + MaxMemoryMb: 4096, + MaxDiskGb: 10, + AllowedNetworkModes: []string{"offline"}, + AllowedCommands: []string{"ls", "echo"}, + } + sup, err := NewSupervisor(mockBackend, engine) + if err != nil { + t.Fatalf("Failed to create supervisor: %v", err) + } + + spec := api.SandboxSpec{ + CPU: 2, + MemoryMb: 1024, + DiskGb: 5, + NetworkMode: "offline", + } + + id := "test-1" + + // 1. Test Start + t.Run("Start", func(t *testing.T) { + err := sup.Start(id, spec) + if err != nil { + t.Fatalf("Failed to start sandbox: %v", err) + } + + sup.mu.RLock() + instance, exists := sup.instances[id] + sup.mu.RUnlock() + + if !exists { + t.Fatal("Instance not found in supervisor map") + } + if instance.GetState() != StateReady { + t.Errorf("Expected state Ready, got %v", instance.GetState()) + } + }) + + // 2. Test RunCommand + t.Run("RunCommand", func(t *testing.T) { + req := api.ExecRequest{ + Command: []string{"ls", "-la"}, + } + result, err := sup.RunCommand(id, req) + if err != nil { + t.Fatalf("Failed to run command: %v", err) + } + + if result.ExitCode != 0 { + t.Errorf("Expected exit code 0, got %d", result.ExitCode) + } + }) + + // 3. Test Policy Violation + t.Run("PolicyViolation", func(t *testing.T) { + req := api.ExecRequest{ + Command: []string{"rm", "-rf", "/"}, + } + _, err := sup.RunCommand(id, req) + if err == nil { + t.Error("Expected error for forbidden command, got nil") + } + }) + + // 4. Test Concurrent Access + t.Run("ConcurrentAccess", func(t *testing.T) { + var wg sync.WaitGroup + numWorkers := 10 + idPrefix := "concurrent-" + + // Start multiple sandboxes + for i := 0; i < numWorkers; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + cid := fmt.Sprintf("%s%d", idPrefix, idx) + if err := sup.Start(cid, spec); err != nil { + t.Errorf("Failed to start concurrent sandbox %d: %v", idx, err) + } + + // Run a command + req := api.ExecRequest{Command: []string{"echo", "hello"}} + if _, err := sup.RunCommand(cid, req); err != nil { + t.Errorf("Failed to run command in concurrent sandbox %d: %v", idx, err) + } + + // Stop + if err := sup.Stop(cid); err != nil { + t.Errorf("Failed to stop concurrent sandbox %d: %v", idx, err) + } + }(i) + } + wg.Wait() + }) + + // 5. Test Stop + t.Run("Stop", func(t *testing.T) { + err := sup.Stop(id) + if err != nil { + t.Fatalf("Failed to stop sandbox: %v", err) + } + + sup.mu.RLock() + _, exists := sup.instances[id] + sup.mu.RUnlock() + + if exists { + t.Error("Instance should have been removed from map after Stop") + } + }) +}