rdev/internal/handlers/claude_config.go
jordan 853ec4cf81 fix: go.work race condition with batch components and idempotent provisioning
Three coordinated fixes for CI pipeline race conditions:

1. Woodpecker step dependencies: Added depends_on: [deps] to all 6 component
   templates (service, worker, cli, app-astro, app-react, app-nextjs) so build
   steps wait for go work sync to complete.

2. Idempotent resource provisioning: Modified provisionResources() to check
   for existing database/cache before creating, preventing "already exists"
   errors on component re-adds.

3. Batch component endpoint: POST /projects/{id}/components/batch enables
   atomic multi-component additions in a single git commit. Validates all
   components upfront, provisions infra sequentially, commits code components
   atomically.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 12:31:40 -07:00

490 lines
16 KiB
Go

// Package handlers provides HTTP handlers for the rdev API.
package handlers
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/adapter/kubernetes"
"github.com/orchard9/rdev/internal/auth"
"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 (read)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/", h.Overview)
// Commands - read
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/commands", h.ListCommands)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/commands/{name}", h.GetCommand)
// Commands - write
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/commands", h.CreateCommand)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Put("/commands/{name}", h.UpdateCommand)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Delete("/commands/{name}", h.DeleteCommand)
// Skills - read
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/skills", h.ListSkills)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/skills/{name}", h.GetSkill)
// Skills - write
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/skills", h.CreateSkill)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Put("/skills/{name}", h.UpdateSkill)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Delete("/skills/{name}", h.DeleteSkill)
// Agents - read
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/agents", h.ListAgents)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/agents/{name}", h.GetAgent)
// Agents - write
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/agents", h.CreateAgent)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Put("/agents/{name}", h.UpdateAgent)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).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(r.Context(), project.PodName, "commands"),
Skills: h.listItems(r.Context(), project.PodName, "skills"),
Agents: h.listItems(r.Context(), 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(ctx context.Context, pod, itemType string) []string {
cmd := fmt.Sprintf("ls -1 /workspace/.claude/%s 2>/dev/null | sed 's/\\.md$//'", itemType)
output, err := h.executor.ExecSimple(ctx, 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(r.Context(), 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 := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
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(r.Context(), 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(r.Context(), 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(r.Context(), 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 := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
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(r.Context(), 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(r.Context(), 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(r.Context(), 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(r.Context(), 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, TimeoutFastLookup)
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
}