rdev/internal/handlers/claude_config.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

485 lines
15 KiB
Go

// Package handlers provides HTTP handlers for the rdev API.
package handlers
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/adapter/kubernetes"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/internal/validate"
"github.com/orchard9/rdev/pkg/api"
)
// maxContentSize limits the size of content that can be written (1MB).
const maxContentSize = 1 << 20
// ClaudeConfigHandler handles Claude config management endpoints.
// Commands, skills, and agents live in /workspace/.claude/ (per-project, in git).
// Credentials live in /root/.claude/ (shared PVC).
type ClaudeConfigHandler struct {
projectRepo *kubernetes.ProjectRepository
executor *kubernetes.Executor
projectService *service.ProjectService
}
// NewClaudeConfigHandler creates a new claude config handler with injected dependencies.
func NewClaudeConfigHandler(projectRepo *kubernetes.ProjectRepository, exec *kubernetes.Executor) *ClaudeConfigHandler {
return &ClaudeConfigHandler{
projectRepo: projectRepo,
executor: exec,
}
}
// NewClaudeConfigHandlerWithService creates a new claude config handler with injected dependencies.
// This maintains proper DI by receiving all dependencies from the caller.
func NewClaudeConfigHandlerWithService(
projectService *service.ProjectService,
projectRepo *kubernetes.ProjectRepository,
exec *kubernetes.Executor,
) *ClaudeConfigHandler {
return &ClaudeConfigHandler{
projectService: projectService,
projectRepo: projectRepo,
executor: exec,
}
}
// Mount registers the claude-config routes.
func (h *ClaudeConfigHandler) Mount(r api.Router) {
r.Route("/projects/{id}/claude-config", func(r chi.Router) {
// Overview
r.Get("/", h.Overview)
// Commands
r.Get("/commands", h.ListCommands)
r.Post("/commands", h.CreateCommand)
r.Get("/commands/{name}", h.GetCommand)
r.Put("/commands/{name}", h.UpdateCommand)
r.Delete("/commands/{name}", h.DeleteCommand)
// Skills
r.Get("/skills", h.ListSkills)
r.Post("/skills", h.CreateSkill)
r.Get("/skills/{name}", h.GetSkill)
r.Put("/skills/{name}", h.UpdateSkill)
r.Delete("/skills/{name}", h.DeleteSkill)
// Agents
r.Get("/agents", h.ListAgents)
r.Post("/agents", h.CreateAgent)
r.Get("/agents/{name}", h.GetAgent)
r.Put("/agents/{name}", h.UpdateAgent)
r.Delete("/agents/{name}", h.DeleteAgent)
})
}
// ConfigOverview is the response for GET /projects/{id}/claude-config
type ConfigOverview struct {
Project string `json:"project"`
Path string `json:"path"`
Commands []string `json:"commands"`
Skills []string `json:"skills"`
Agents []string `json:"agents"`
}
// Overview returns an overview of the project's Claude config.
// GET /projects/{id}/claude-config
func (h *ClaudeConfigHandler) Overview(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
project, err := h.getProject(r.Context(), domain.ProjectID(id))
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id))
return
}
api.WriteInternalError(w, r, "failed to get project")
return
}
overview := ConfigOverview{
Project: id,
Path: "/workspace/.claude",
Commands: h.listItems(project.PodName, "commands"),
Skills: h.listItems(project.PodName, "skills"),
Agents: h.listItems(project.PodName, "agents"),
}
api.WriteSuccess(w, r, overview)
}
// ConfigItem represents a command, skill, or agent.
type ConfigItem struct {
Name string `json:"name"`
Type string `json:"type"`
Content string `json:"content"`
}
// ConfigItemRequest is the request body for creating/updating items.
type ConfigItemRequest struct {
Name string `json:"name,omitempty"` // Optional for POST (can be in URL)
Content string `json:"content"`
}
// --- Commands ---
// ListCommands returns all commands for a project.
// GET /projects/{id}/claude-config/commands
func (h *ClaudeConfigHandler) ListCommands(w http.ResponseWriter, r *http.Request) {
h.listType(w, r, "commands")
}
// CreateCommand creates a new command.
// POST /projects/{id}/claude-config/commands
func (h *ClaudeConfigHandler) CreateCommand(w http.ResponseWriter, r *http.Request) {
h.createItem(w, r, "commands")
}
// GetCommand returns a specific command.
// GET /projects/{id}/claude-config/commands/{name}
func (h *ClaudeConfigHandler) GetCommand(w http.ResponseWriter, r *http.Request) {
h.getItem(w, r, "commands")
}
// UpdateCommand updates a command.
// PUT /projects/{id}/claude-config/commands/{name}
func (h *ClaudeConfigHandler) UpdateCommand(w http.ResponseWriter, r *http.Request) {
h.updateItem(w, r, "commands")
}
// DeleteCommand deletes a command.
// DELETE /projects/{id}/claude-config/commands/{name}
func (h *ClaudeConfigHandler) DeleteCommand(w http.ResponseWriter, r *http.Request) {
h.deleteItem(w, r, "commands")
}
// --- Skills ---
// ListSkills returns all skills for a project.
// GET /projects/{id}/claude-config/skills
func (h *ClaudeConfigHandler) ListSkills(w http.ResponseWriter, r *http.Request) {
h.listType(w, r, "skills")
}
// CreateSkill creates a new skill.
// POST /projects/{id}/claude-config/skills
func (h *ClaudeConfigHandler) CreateSkill(w http.ResponseWriter, r *http.Request) {
h.createItem(w, r, "skills")
}
// GetSkill returns a specific skill.
// GET /projects/{id}/claude-config/skills/{name}
func (h *ClaudeConfigHandler) GetSkill(w http.ResponseWriter, r *http.Request) {
h.getItem(w, r, "skills")
}
// UpdateSkill updates a skill.
// PUT /projects/{id}/claude-config/skills/{name}
func (h *ClaudeConfigHandler) UpdateSkill(w http.ResponseWriter, r *http.Request) {
h.updateItem(w, r, "skills")
}
// DeleteSkill deletes a skill.
// DELETE /projects/{id}/claude-config/skills/{name}
func (h *ClaudeConfigHandler) DeleteSkill(w http.ResponseWriter, r *http.Request) {
h.deleteItem(w, r, "skills")
}
// --- Agents ---
// ListAgents returns all agents for a project.
// GET /projects/{id}/claude-config/agents
func (h *ClaudeConfigHandler) ListAgents(w http.ResponseWriter, r *http.Request) {
h.listType(w, r, "agents")
}
// CreateAgent creates a new agent.
// POST /projects/{id}/claude-config/agents
func (h *ClaudeConfigHandler) CreateAgent(w http.ResponseWriter, r *http.Request) {
h.createItem(w, r, "agents")
}
// GetAgent returns a specific agent.
// GET /projects/{id}/claude-config/agents/{name}
func (h *ClaudeConfigHandler) GetAgent(w http.ResponseWriter, r *http.Request) {
h.getItem(w, r, "agents")
}
// UpdateAgent updates an agent.
// PUT /projects/{id}/claude-config/agents/{name}
func (h *ClaudeConfigHandler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
h.updateItem(w, r, "agents")
}
// DeleteAgent deletes an agent.
// DELETE /projects/{id}/claude-config/agents/{name}
func (h *ClaudeConfigHandler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
h.deleteItem(w, r, "agents")
}
// --- Helper methods ---
// listItems returns the names of items in a directory.
func (h *ClaudeConfigHandler) listItems(pod, itemType string) []string {
cmd := fmt.Sprintf("ls -1 /workspace/.claude/%s 2>/dev/null | sed 's/\\.md$//'", itemType)
output, err := h.executor.ExecSimple(pod, cmd)
if err != nil {
return []string{}
}
items := []string{}
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
if line != "" {
items = append(items, line)
}
}
return items
}
// listType handles GET /projects/{id}/claude-config/{type}
func (h *ClaudeConfigHandler) listType(w http.ResponseWriter, r *http.Request, itemType string) {
id := chi.URLParam(r, "id")
project, err := h.getProject(r.Context(), domain.ProjectID(id))
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id))
return
}
api.WriteInternalError(w, r, "failed to get project")
return
}
items := h.listItems(project.PodName, itemType)
api.WriteSuccess(w, r, items)
}
// createItem handles POST /projects/{id}/claude-config/{type}
func (h *ClaudeConfigHandler) createItem(w http.ResponseWriter, r *http.Request, itemType string) {
id := chi.URLParam(r, "id")
project, err := h.getProject(r.Context(), domain.ProjectID(id))
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id))
return
}
api.WriteInternalError(w, r, "failed to get project")
return
}
// Limit request body size to prevent DoS
r.Body = http.MaxBytesReader(w, r.Body, maxContentSize)
var req ConfigItemRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.WriteBadRequest(w, r, "invalid request body or content too large")
return
}
v := validate.New()
v.Required(req.Name, "name")
v.Required(req.Content, "content")
if err := v.Error(); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Validate name (alphanumeric, dashes, underscores only, 1-64 chars)
if err := validate.Name(req.Name, "name"); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Ensure directory exists
dirCmd := fmt.Sprintf("mkdir -p /workspace/.claude/%s", itemType)
if _, err := h.executor.ExecSimple(project.PodName, dirCmd); err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to create directory: %v", err))
return
}
// Write file using base64 encoding to prevent shell injection
// This avoids heredoc terminator injection attacks
filePath := fmt.Sprintf("/workspace/.claude/%s/%s.md", itemType, req.Name)
encoded := base64.StdEncoding.EncodeToString([]byte(req.Content))
writeCmd := fmt.Sprintf("echo '%s' | base64 -d > %s", encoded, filePath)
if _, err := h.executor.ExecSimple(project.PodName, writeCmd); err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to write file: %v", err))
return
}
item := ConfigItem{
Name: req.Name,
Type: itemType,
Content: req.Content,
}
api.WriteJSON(w, r, http.StatusCreated, item)
}
// getItem handles GET /projects/{id}/claude-config/{type}/{name}
func (h *ClaudeConfigHandler) getItem(w http.ResponseWriter, r *http.Request, itemType string) {
id := chi.URLParam(r, "id")
name := chi.URLParam(r, "name")
project, err := h.getProject(r.Context(), domain.ProjectID(id))
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id))
return
}
api.WriteInternalError(w, r, "failed to get project")
return
}
if err := validate.Name(name, "name"); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
filePath := fmt.Sprintf("/workspace/.claude/%s/%s.md", itemType, name)
cmd := fmt.Sprintf("cat %s 2>/dev/null", filePath)
output, err := h.executor.ExecSimple(project.PodName, cmd)
if err != nil || output == "" {
api.WriteNotFound(w, r, fmt.Sprintf("%s not found: %s", itemType, name))
return
}
item := ConfigItem{
Name: name,
Type: itemType,
Content: strings.TrimSpace(output),
}
api.WriteSuccess(w, r, item)
}
// updateItem handles PUT /projects/{id}/claude-config/{type}/{name}
func (h *ClaudeConfigHandler) updateItem(w http.ResponseWriter, r *http.Request, itemType string) {
id := chi.URLParam(r, "id")
name := chi.URLParam(r, "name")
project, err := h.getProject(r.Context(), domain.ProjectID(id))
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id))
return
}
api.WriteInternalError(w, r, "failed to get project")
return
}
if err := validate.Name(name, "name"); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Limit request body size to prevent DoS
r.Body = http.MaxBytesReader(w, r.Body, maxContentSize)
var req ConfigItemRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.WriteBadRequest(w, r, "invalid request body or content too large")
return
}
if err := validate.Required(req.Content, "content"); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Check file exists
filePath := fmt.Sprintf("/workspace/.claude/%s/%s.md", itemType, name)
checkCmd := fmt.Sprintf("test -f %s && echo exists", filePath)
output, _ := h.executor.ExecSimple(project.PodName, checkCmd)
if strings.TrimSpace(output) != "exists" {
api.WriteNotFound(w, r, fmt.Sprintf("%s not found: %s", itemType, name))
return
}
// Write file using base64 encoding to prevent shell injection
encoded := base64.StdEncoding.EncodeToString([]byte(req.Content))
writeCmd := fmt.Sprintf("echo '%s' | base64 -d > %s", encoded, filePath)
if _, err := h.executor.ExecSimple(project.PodName, writeCmd); err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to write file: %v", err))
return
}
item := ConfigItem{
Name: name,
Type: itemType,
Content: req.Content,
}
api.WriteSuccess(w, r, item)
}
// deleteItem handles DELETE /projects/{id}/claude-config/{type}/{name}
func (h *ClaudeConfigHandler) deleteItem(w http.ResponseWriter, r *http.Request, itemType string) {
id := chi.URLParam(r, "id")
name := chi.URLParam(r, "name")
project, err := h.getProject(r.Context(), domain.ProjectID(id))
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id))
return
}
api.WriteInternalError(w, r, "failed to get project")
return
}
if err := validate.Name(name, "name"); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
filePath := fmt.Sprintf("/workspace/.claude/%s/%s.md", itemType, name)
// Check file exists
checkCmd := fmt.Sprintf("test -f %s && echo exists", filePath)
output, _ := h.executor.ExecSimple(project.PodName, checkCmd)
if strings.TrimSpace(output) != "exists" {
api.WriteNotFound(w, r, fmt.Sprintf("%s not found: %s", itemType, name))
return
}
// Delete file
deleteCmd := fmt.Sprintf("rm %s", filePath)
if _, err := h.executor.ExecSimple(project.PodName, deleteCmd); err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to delete file: %v", err))
return
}
api.WriteSuccess(w, r, map[string]string{"deleted": name})
}
// getProject retrieves a project by ID using available methods.
// It prefers the project service if available, otherwise falls back to the project repository.
func (h *ClaudeConfigHandler) getProject(ctx context.Context, id domain.ProjectID) (*domain.Project, error) {
// Add timeout for project lookup
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// Use service if available
if h.projectService != nil {
return h.projectService.Get(ctx, id)
}
// Fall back to direct repository access
if h.projectRepo != nil {
return h.projectRepo.Get(ctx, id)
}
return nil, domain.ErrProjectNotFound
}