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>
485 lines
15 KiB
Go
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
|
|
}
|