rdev/internal/service/project_service.go
jordan 72d16929ca feat: Implement hexagonal architecture with services, webhooks, queue, and telemetry
Major refactoring to hexagonal (ports & adapters) architecture:

- Add service layer (apikey_service, project_service) for business logic
- Add webhook system with dispatcher and delivery tracking
- Add command queue with priority-based processing
- Add rate limiting with sliding window algorithm
- Add audit logging for command execution
- Add OpenTelemetry integration (traces, metrics, spans)
- Add circuit breaker for fault tolerance
- Add cached repository wrapper for performance
- Add comprehensive validation package
- Add Kubernetes client integration for pod management
- Add database migrations (allowed_ips, audit_log, rate_limiting, queue, webhooks)
- Add network policy and PodDisruptionBudget for k8s
- Remove legacy executor and projects/registry packages
- Untrack secrets.yaml (now managed via envault)
- Add coverage.out to .gitignore
- Add e2e test infrastructure with docker-compose
- Add comprehensive documentation (API, architecture, operations, plans)
- Add golangci-lint config and pre-commit hook

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:57:46 -07:00

585 lines
17 KiB
Go

// Package service provides business logic / use cases for the application.
// Services orchestrate domain operations using port interfaces.
package service
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sync/atomic"
"time"
"github.com/google/uuid"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/metrics"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/sanitize"
)
// ProjectService handles project-related business logic.
type ProjectService struct {
projects port.ProjectRepository
executor port.CommandExecutor
streams port.StreamPublisher
auditLogger port.AuditLogger // Optional audit logger
queue port.CommandQueue // Optional command queue
webhookDispatcher port.WebhookDispatcher // Optional webhook dispatcher
logger *slog.Logger
cmdID atomic.Uint64
}
// NewProjectService creates a new project service.
func NewProjectService(
projects port.ProjectRepository,
executor port.CommandExecutor,
streams port.StreamPublisher,
) *ProjectService {
return &ProjectService{
projects: projects,
executor: executor,
streams: streams,
logger: slog.Default(),
}
}
// WithLogger sets a custom logger for the service.
func (s *ProjectService) WithLogger(logger *slog.Logger) *ProjectService {
s.logger = logger
return s
}
// WithAuditLogger sets an audit logger for the service.
func (s *ProjectService) WithAuditLogger(auditLogger port.AuditLogger) *ProjectService {
s.auditLogger = auditLogger
return s
}
// WithCommandQueue sets a command queue for async execution.
func (s *ProjectService) WithCommandQueue(queue port.CommandQueue) *ProjectService {
s.queue = queue
return s
}
// WithWebhookDispatcher sets a webhook dispatcher for event notifications.
func (s *ProjectService) WithWebhookDispatcher(dispatcher port.WebhookDispatcher) *ProjectService {
s.webhookDispatcher = dispatcher
return s
}
// AuditContext contains audit-related information from the request.
type AuditContext struct {
APIKeyID string
ClientIP string
UserAgent string
}
// List returns all available projects with refreshed status.
func (s *ProjectService) List(ctx context.Context) ([]domain.Project, error) {
// Refresh status from Kubernetes
if err := s.projects.RefreshStatus(ctx); err != nil {
s.logger.Warn("failed to refresh project status", "error", err)
}
return s.projects.List(ctx)
}
// Get returns a specific project by ID.
func (s *ProjectService) Get(ctx context.Context, id domain.ProjectID) (*domain.Project, error) {
project, err := s.projects.Get(ctx, id)
if err != nil {
return nil, err
}
// Refresh status
if refreshErr := s.projects.RefreshStatus(ctx); refreshErr != nil {
s.logger.Warn("failed to refresh project status", "project", id, "error", refreshErr)
}
return project, nil
}
// Exists checks if a project exists.
func (s *ProjectService) Exists(ctx context.Context, id domain.ProjectID) (bool, error) {
return s.projects.Exists(ctx, id)
}
// ExecuteClaudeRequest contains parameters for running a Claude command.
type ExecuteClaudeRequest struct {
ProjectID domain.ProjectID
Prompt string
StreamID string
Audit *AuditContext // Optional audit context
}
// ExecuteClaudeResult contains the result of queuing a Claude command.
type ExecuteClaudeResult struct {
CommandID domain.CommandID
StreamURL string
}
// ExecuteClaude runs a Claude command in the project's pod.
func (s *ProjectService) ExecuteClaude(ctx context.Context, req ExecuteClaudeRequest) (*ExecuteClaudeResult, error) {
// Validate project exists
project, err := s.projects.Get(ctx, req.ProjectID)
if err != nil {
return nil, err
}
// Validate prompt
if req.Prompt == "" {
return nil, fmt.Errorf("%w: prompt is required", domain.ErrInvalidCommand)
}
if err := sanitize.ClaudePrompt(req.Prompt); err != nil {
return nil, fmt.Errorf("%w: %v", domain.ErrCommandSanitization, err)
}
// Validate stream ID
if err := sanitize.StreamID(req.StreamID); err != nil {
return nil, fmt.Errorf("%w: %v", domain.ErrInvalidCommand, err)
}
// Generate command ID
cmdNum := s.cmdID.Add(1)
cmdID := domain.CommandID(fmt.Sprintf("cmd-%s-%03d", req.ProjectID, cmdNum))
if req.StreamID != "" {
cmdID = domain.CommandID(req.StreamID)
}
// Create command
cmd := &domain.Command{
ID: cmdID,
ProjectID: req.ProjectID,
Type: domain.CommandTypeClaude,
Args: []string{req.Prompt},
StartedAt: time.Now(),
}
// Log audit start if audit logger is configured
if s.auditLogger != nil && req.Audit != nil {
argsJSON, _ := json.Marshal(cmd.Args)
auditEntry := &domain.AuditLogEntry{
ID: uuid.New().String(),
APIKeyID: req.Audit.APIKeyID,
CommandID: string(cmdID),
ProjectID: string(req.ProjectID),
CommandType: domain.CommandTypeClaude,
Args: string(argsJSON),
ClientIP: req.Audit.ClientIP,
UserAgent: req.Audit.UserAgent,
StartedAt: cmd.StartedAt,
Status: domain.AuditStatusRunning,
}
if err := s.auditLogger.LogCommandStart(ctx, auditEntry); err != nil {
s.logger.Warn("failed to log audit start", "command_id", cmdID, "error", err)
}
}
// Execute in background
go s.executeCommand(project.PodName, cmd)
return &ExecuteClaudeResult{
CommandID: cmdID,
StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", req.ProjectID, cmdID),
}, nil
}
// ExecuteShellRequest contains parameters for running a shell command.
type ExecuteShellRequest struct {
ProjectID domain.ProjectID
Command string
StreamID string
Audit *AuditContext // Optional audit context
}
// ExecuteShellResult contains the result of queuing a shell command.
type ExecuteShellResult struct {
CommandID domain.CommandID
StreamURL string
}
// ExecuteShell runs a shell command in the project's pod.
func (s *ProjectService) ExecuteShell(ctx context.Context, req ExecuteShellRequest) (*ExecuteShellResult, error) {
// Validate project exists
project, err := s.projects.Get(ctx, req.ProjectID)
if err != nil {
return nil, err
}
// Validate command
if req.Command == "" {
return nil, fmt.Errorf("%w: command is required", domain.ErrInvalidCommand)
}
if err := sanitize.ShellCommand(req.Command); err != nil {
return nil, fmt.Errorf("%w: %v", domain.ErrCommandSanitization, err)
}
// Validate stream ID
if err := sanitize.StreamID(req.StreamID); err != nil {
return nil, fmt.Errorf("%w: %v", domain.ErrInvalidCommand, err)
}
// Generate command ID
cmdNum := s.cmdID.Add(1)
cmdID := domain.CommandID(fmt.Sprintf("cmd-%s-%03d", req.ProjectID, cmdNum))
if req.StreamID != "" {
cmdID = domain.CommandID(req.StreamID)
}
// Create command
cmd := &domain.Command{
ID: cmdID,
ProjectID: req.ProjectID,
Type: domain.CommandTypeShell,
Args: []string{req.Command},
StartedAt: time.Now(),
}
// Log audit start if audit logger is configured
if s.auditLogger != nil && req.Audit != nil {
argsJSON, _ := json.Marshal(cmd.Args)
auditEntry := &domain.AuditLogEntry{
ID: uuid.New().String(),
APIKeyID: req.Audit.APIKeyID,
CommandID: string(cmdID),
ProjectID: string(req.ProjectID),
CommandType: domain.CommandTypeShell,
Args: string(argsJSON),
ClientIP: req.Audit.ClientIP,
UserAgent: req.Audit.UserAgent,
StartedAt: cmd.StartedAt,
Status: domain.AuditStatusRunning,
}
if err := s.auditLogger.LogCommandStart(ctx, auditEntry); err != nil {
s.logger.Warn("failed to log audit start", "command_id", cmdID, "error", err)
}
}
// Execute in background
go s.executeCommand(project.PodName, cmd)
return &ExecuteShellResult{
CommandID: cmdID,
StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", req.ProjectID, cmdID),
}, nil
}
// ExecuteGitRequest contains parameters for running a git command.
type ExecuteGitRequest struct {
ProjectID domain.ProjectID
Args []string
StreamID string
Audit *AuditContext // Optional audit context
}
// ExecuteGitResult contains the result of queuing a git command.
type ExecuteGitResult struct {
CommandID domain.CommandID
StreamURL string
}
// ExecuteGit runs a git command in the project's pod.
func (s *ProjectService) ExecuteGit(ctx context.Context, req ExecuteGitRequest) (*ExecuteGitResult, error) {
// Validate project exists
project, err := s.projects.Get(ctx, req.ProjectID)
if err != nil {
return nil, err
}
// Validate args
if len(req.Args) == 0 {
return nil, fmt.Errorf("%w: args is required", domain.ErrInvalidCommand)
}
if err := sanitize.GitArgs(req.Args); err != nil {
return nil, fmt.Errorf("%w: %v", domain.ErrCommandSanitization, err)
}
// Validate stream ID
if err := sanitize.StreamID(req.StreamID); err != nil {
return nil, fmt.Errorf("%w: %v", domain.ErrInvalidCommand, err)
}
// Generate command ID
cmdNum := s.cmdID.Add(1)
cmdID := domain.CommandID(fmt.Sprintf("cmd-%s-%03d", req.ProjectID, cmdNum))
if req.StreamID != "" {
cmdID = domain.CommandID(req.StreamID)
}
// Create command
cmd := &domain.Command{
ID: cmdID,
ProjectID: req.ProjectID,
Type: domain.CommandTypeGit,
Args: req.Args,
StartedAt: time.Now(),
}
// Log audit start if audit logger is configured
if s.auditLogger != nil && req.Audit != nil {
argsJSON, _ := json.Marshal(cmd.Args)
auditEntry := &domain.AuditLogEntry{
ID: uuid.New().String(),
APIKeyID: req.Audit.APIKeyID,
CommandID: string(cmdID),
ProjectID: string(req.ProjectID),
CommandType: domain.CommandTypeGit,
Args: string(argsJSON),
ClientIP: req.Audit.ClientIP,
UserAgent: req.Audit.UserAgent,
StartedAt: cmd.StartedAt,
Status: domain.AuditStatusRunning,
}
if err := s.auditLogger.LogCommandStart(ctx, auditEntry); err != nil {
s.logger.Warn("failed to log audit start", "command_id", cmdID, "error", err)
}
}
// Execute in background
go s.executeCommand(project.PodName, cmd)
return &ExecuteGitResult{
CommandID: cmdID,
StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", req.ProjectID, cmdID),
}, nil
}
// executeCommand runs a command and streams output to subscribers.
func (s *ProjectService) executeCommand(podName string, cmd *domain.Command) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
streamID := string(cmd.ID)
var lastEventID string
var outputSizeBytes int64
// Dispatch command.started webhook event
s.dispatchWebhookEvent(ctx, string(cmd.ProjectID), domain.WebhookEventCommandStarted, &domain.CommandEventData{
CommandID: string(cmd.ID),
CommandType: cmd.Type,
ProjectID: string(cmd.ProjectID),
StartedAt: cmd.StartedAt,
})
result, _ := s.executor.Execute(ctx, cmd, podName, func(line domain.OutputLine) {
eventID := s.streams.Publish(streamID, port.StreamEvent{
Type: "output",
Data: map[string]any{
"line": line.Line,
"stream": line.Stream,
},
})
lastEventID = eventID
outputSizeBytes += int64(len(line.Line))
})
// Send completion event
eventID := s.streams.Publish(streamID, port.StreamEvent{
Type: "complete",
Data: map[string]any{
"exit_code": result.ExitCode,
"duration_ms": result.DurationMs,
},
})
// Record metrics
status := "success"
if result.ExitCode != 0 {
status = "error"
}
metrics.RecordCommand(string(cmd.ProjectID), string(cmd.Type), status, result.DurationMs)
// Log audit completion if audit logger is configured
if s.auditLogger != nil {
var auditStatus domain.AuditStatus
var errorMsg string
if result.Error != nil {
auditStatus = domain.AuditStatusError
errorMsg = result.Error.Error()
} else if result.ExitCode != 0 {
auditStatus = domain.AuditStatusError
} else {
auditStatus = domain.AuditStatusSuccess
}
auditResult := &domain.AuditResult{
ExitCode: result.ExitCode,
DurationMs: result.DurationMs,
Status: auditStatus,
ErrorMessage: errorMsg,
OutputSizeBytes: outputSizeBytes,
}
if err := s.auditLogger.LogCommandEnd(ctx, string(cmd.ID), auditResult); err != nil {
s.logger.Warn("failed to log audit end", "command_id", cmd.ID, "error", err)
}
}
// Dispatch command.completed or command.failed webhook event
completedAt := time.Now()
var webhookEventType domain.WebhookEventType
var errorMsg string
if result.Error != nil {
webhookEventType = domain.WebhookEventCommandFailed
errorMsg = result.Error.Error()
} else if result.ExitCode != 0 {
webhookEventType = domain.WebhookEventCommandFailed
} else {
webhookEventType = domain.WebhookEventCommandCompleted
}
s.dispatchWebhookEvent(ctx, string(cmd.ProjectID), webhookEventType, &domain.CommandEventData{
CommandID: string(cmd.ID),
CommandType: cmd.Type,
ProjectID: string(cmd.ProjectID),
StartedAt: cmd.StartedAt,
CompletedAt: completedAt,
ExitCode: result.ExitCode,
DurationMs: result.DurationMs,
Error: errorMsg,
})
s.logger.Debug("command completed",
"command_id", cmd.ID,
"exit_code", result.ExitCode,
"duration_ms", result.DurationMs,
"last_event_id", lastEventID,
"complete_event_id", eventID,
)
// Clean up stream after a delay
go func() {
time.Sleep(30 * time.Second)
s.streams.Close(streamID)
}()
}
// dispatchWebhookEvent dispatches a webhook event if a dispatcher is configured.
func (s *ProjectService) dispatchWebhookEvent(ctx context.Context, projectID string, eventType domain.WebhookEventType, data any) {
if s.webhookDispatcher == nil {
return
}
event := &domain.WebhookEvent{
Type: eventType,
Timestamp: time.Now(),
ProjectID: projectID,
Data: data,
}
if err := s.webhookDispatcher.Dispatch(ctx, projectID, event); err != nil {
s.logger.Warn("failed to dispatch webhook event",
"project_id", projectID,
"event_type", eventType,
"error", err,
)
}
}
// Subscribe returns a channel for receiving stream events.
func (s *ProjectService) Subscribe(streamID string) (<-chan port.StreamEvent, func()) {
return s.streams.Subscribe(streamID)
}
// SubscribeFromID returns a channel for receiving stream events, starting from a specific event ID.
// This is used for SSE reconnection with Last-Event-ID support.
func (s *ProjectService) SubscribeFromID(streamID, lastEventID string) (<-chan port.StreamEvent, func()) {
return s.streams.SubscribeFromID(streamID, lastEventID)
}
// EnqueueCommandRequest contains parameters for enqueueing a command.
type EnqueueCommandRequest struct {
ProjectID domain.ProjectID
Command string
CommandType domain.CommandType
WorkingDir string
Priority int
Audit *AuditContext
}
// EnqueueCommandResult contains the result of enqueueing a command.
type EnqueueCommandResult struct {
CommandID domain.QueuedCommandID
StreamURL string
Position int
}
// EnqueueCommand adds a command to the project's queue for async execution.
// Returns an error if no queue is configured.
func (s *ProjectService) EnqueueCommand(ctx context.Context, req EnqueueCommandRequest) (*EnqueueCommandResult, error) {
if s.queue == nil {
return nil, fmt.Errorf("command queue not configured")
}
// Validate project exists
exists, err := s.projects.Exists(ctx, req.ProjectID)
if err != nil {
return nil, err
}
if !exists {
return nil, domain.ErrProjectNotFound
}
// Create queued command
cmd := &domain.QueuedCommand{
ProjectID: string(req.ProjectID),
Command: req.Command,
CommandType: req.CommandType,
WorkingDir: req.WorkingDir,
Status: domain.QueueStatusPending,
Priority: req.Priority,
}
if req.Audit != nil {
cmd.APIKeyID = req.Audit.APIKeyID
}
// Enqueue
if err := s.queue.Enqueue(ctx, cmd); err != nil {
return nil, fmt.Errorf("enqueue command: %w", err)
}
// Get approximate position
pendingStatus := domain.QueueStatusPending
pending, _ := s.queue.List(ctx, string(req.ProjectID), &domain.QueueFilters{
Status: &pendingStatus,
Limit: 1000,
SortOrder: "asc",
})
return &EnqueueCommandResult{
CommandID: cmd.ID,
StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", req.ProjectID, cmd.ID),
Position: len(pending),
}, nil
}
// GetQueuedCommand retrieves a queued command by ID.
func (s *ProjectService) GetQueuedCommand(ctx context.Context, cmdID domain.QueuedCommandID) (*domain.QueuedCommand, error) {
if s.queue == nil {
return nil, fmt.Errorf("command queue not configured")
}
return s.queue.GetByID(ctx, cmdID)
}
// ListQueuedCommands returns queued commands for a project.
func (s *ProjectService) ListQueuedCommands(ctx context.Context, projectID domain.ProjectID, filters *domain.QueueFilters) ([]*domain.QueuedCommand, error) {
if s.queue == nil {
return nil, fmt.Errorf("command queue not configured")
}
return s.queue.List(ctx, string(projectID), filters)
}
// CancelQueuedCommand cancels a pending queued command.
func (s *ProjectService) CancelQueuedCommand(ctx context.Context, cmdID domain.QueuedCommandID) error {
if s.queue == nil {
return fmt.Errorf("command queue not configured")
}
return s.queue.Cancel(ctx, cmdID)
}
// GetQueueStats returns queue statistics for a project.
func (s *ProjectService) GetQueueStats(ctx context.Context, projectID domain.ProjectID) (*domain.QueueStats, error) {
if s.queue == nil {
return nil, fmt.Errorf("command queue not configured")
}
return s.queue.GetStats(ctx, string(projectID))
}