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>
274 lines
8.0 KiB
Markdown
274 lines
8.0 KiB
Markdown
# 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:
|
|
|
|
1. **Domain** - Core business logic and models
|
|
2. **Ports** - Abstract interfaces defining capabilities
|
|
3. **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
|
|
|
|
```go
|
|
// 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
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
// 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
|
|
|
|
1. **Testability**: Mock implementations for unit tests
|
|
2. **Flexibility**: Swap adapters without changing business logic
|
|
3. **Documentation**: Interfaces define contracts clearly
|
|
|
|
## Adapters Layer
|
|
|
|
Located in `internal/adapter/`.
|
|
|
|
### Kubernetes Adapter
|
|
|
|
```go
|
|
// 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
|
|
|
|
```go
|
|
// 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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
// 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
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
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
|