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>
8.0 KiB
8.0 KiB
Hexagonal Architecture (Ports & Adapters)
rdev implements hexagonal architecture to achieve clean separation of concerns, testability, and flexibility in infrastructure choices.
Overview
Hexagonal architecture organizes code into three layers:
- Domain - Core business logic and models
- Ports - Abstract interfaces defining capabilities
- Adapters - Concrete implementations of ports
┌─────────────────────────┐
│ Domain │
│ │
Driving │ ┌─────────────────┐ │ Driven
(Primary) │ │ Models │ │ (Secondary)
Adapters │ │ Project, Cmd │ │ Adapters
│ │ │ APIKey, Scope │ │ │
│ │ └─────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────┐ │ │
│ │ │ Ports │ │ │
▼ │ │ (Interfaces) │ │ ▼
┌───────┐ │ └─────────────────┘ │ ┌───────┐
│ HTTP │───────────▶│ │◀───────────│ K8s │
│Handler│ │ ┌─────────────────┐ │ │Adapter│
└───────┘ │ │ Services │ │ └───────┘
│ │ ProjectService │ │
│ │ AuthService │ │ ┌───────┐
│ └─────────────────┘ │◀───────────│ DB │
│ │ │Adapter│
└─────────────────────────┘ └───────┘
Domain Layer
Located in internal/domain/.
Models
// Project represents a development environment
type Project struct {
ID ProjectID
Name string
PodName string
Status ProjectStatus
LastSeen time.Time
Labels map[string]string
Annotations map[string]string
}
// Command represents an executable command
type Command struct {
ID CommandID
ProjectID ProjectID
Type CommandType
Args []string
Status CommandStatus
StartedAt time.Time
EndedAt *time.Time
ExitCode *int
}
Domain Errors
var (
ErrProjectNotFound = errors.New("project not found")
ErrCommandNotFound = errors.New("command not found")
ErrInvalidCommand = errors.New("invalid command")
ErrCommandSanitization = errors.New("command failed sanitization")
)
Ports Layer
Located in internal/port/.
Port Interfaces
// ProjectRepository defines project data access
type ProjectRepository interface {
List(ctx context.Context) ([]domain.Project, error)
Get(ctx context.Context, id domain.ProjectID) (*domain.Project, error)
Exists(ctx context.Context, id domain.ProjectID) (bool, error)
RefreshStatus(ctx context.Context) error
}
// CommandExecutor defines command execution capability
type CommandExecutor interface {
Execute(ctx context.Context, cmd *domain.Command, podName string,
outputFn func(domain.OutputLine)) (*domain.CommandResult, error)
Cancel(id domain.CommandID) error
ActiveCount() int
}
Benefits of Ports
- Testability: Mock implementations for unit tests
- Flexibility: Swap adapters without changing business logic
- Documentation: Interfaces define contracts clearly
Adapters Layer
Located in internal/adapter/.
Kubernetes Adapter
// kubernetes/project_repository.go
type ProjectRepository struct {
namespace string
client kubernetes.Interface
projects []domain.Project
mu sync.RWMutex
}
func (r *ProjectRepository) List(ctx context.Context) ([]domain.Project, error) {
// Uses K8s API to discover pods with rdev labels
}
// kubernetes/executor.go
type Executor struct {
namespace string
activeCommands map[domain.CommandID]context.CancelFunc
}
func (e *Executor) Execute(ctx context.Context, cmd *domain.Command,
podName string, outputFn func(domain.OutputLine)) (*domain.CommandResult, error) {
// Uses kubectl exec to run commands
}
Caching Adapter
// cached/project_repository.go
type ProjectRepository struct {
inner port.ProjectRepository
ttl time.Duration
projectsCache []domain.Project
lastFetch time.Time
}
func (r *ProjectRepository) List(ctx context.Context) ([]domain.Project, error) {
if r.isCacheFresh() {
return r.projectsCache, nil
}
return r.inner.List(ctx)
}
Service Layer
Located in internal/service/.
Services orchestrate domain logic using ports:
type ProjectService struct {
repo port.ProjectRepository
executor port.CommandExecutor
streams port.StreamManager
}
func (s *ProjectService) ExecuteClaude(ctx context.Context,
req ExecuteClaudeRequest) (*ExecuteResult, error) {
// 1. Validate project exists
project, err := s.repo.Get(ctx, req.ProjectID)
if err != nil {
return nil, err
}
// 2. Sanitize command
if err := sanitize.ClaudePrompt(req.Prompt); err != nil {
return nil, domain.ErrCommandSanitization
}
// 3. Execute via port
result, err := s.executor.Execute(ctx, cmd, project.PodName,
s.handleOutput)
return result, nil
}
Dependency Injection
Dependencies flow inward:
// cmd/rdev-api/main.go
func main() {
// Create adapters
k8sClient := kubernetes.NewClientset()
projectRepo := kubernetes.NewProjectRepositoryWithClient(namespace, k8sClient)
cachedRepo := cached.NewProjectRepository(projectRepo, 30*time.Second)
executor := kubernetes.NewExecutor(namespace)
// Create services with ports
projectService := service.NewProjectService(cachedRepo, executor)
// Create handlers with services
projectsHandler := handlers.NewProjectsHandlerWithService(projectService)
}
Testing
Unit Tests with Mocks
type mockProjectRepo struct {
projects []domain.Project
}
func (m *mockProjectRepo) Get(ctx context.Context, id domain.ProjectID) (*domain.Project, error) {
for _, p := range m.projects {
if p.ID == id {
return &p, nil
}
}
return nil, domain.ErrProjectNotFound
}
func TestProjectService_ExecuteClaude(t *testing.T) {
repo := &mockProjectRepo{projects: testProjects}
exec := &mockExecutor{}
svc := service.NewProjectService(repo, exec)
result, err := svc.ExecuteClaude(ctx, req)
// Assert...
}
Integration Tests with Real Adapters
func TestKubernetesAdapter_Execute(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
executor := kubernetes.NewExecutor("test-namespace")
// Test with real K8s...
}
Trade-offs
Benefits
- Clear separation of concerns
- Easy to test in isolation
- Flexible infrastructure choices
- Domain logic remains pure
Costs
- More interfaces and types
- Initial setup complexity
- Some indirection overhead
Related Patterns
- Repository Pattern: Abstracts data access
- Service Layer Pattern: Orchestrates business logic
- Dependency Injection: Decouples creation from usage