rdev/internal/handlers/builds.go
jordan 4f01015132
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: implement project access enforcement and management API
- Fix no-op RequireProjectAccess middleware to enforce project_ids
- Apply project access middleware to all project-scoped routes
- Filter GET /projects by allowed project IDs for restricted keys
- Add GET /me endpoint with key identity, scopes, and project access info
- Add PATCH /keys/{id} for partial key updates (name, scopes, project_ids, allowed_ips, expires_in)
- Add GET/POST/DELETE /projects/{id}/access for project-centric access management
- Auto-grant creating key access when using POST /project/create-and-build
- Accept grant_to_key_ids in create-and-build to grant multiple keys on project creation
- Move newProvisionerWithDeps test helper from production code to test file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 15:38:37 -07:00

254 lines
7.9 KiB
Go

// Package handlers provides HTTP handlers for the rdev API.
package handlers
import (
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/pkg/api"
)
// maxRequestBodySize is the maximum allowed size for request bodies (1MB).
const maxRequestBodySize = 1 << 20
// BuildsHandler handles project-scoped build endpoints.
type BuildsHandler struct {
buildService *service.BuildService
}
// NewBuildsHandler creates a new builds handler.
func NewBuildsHandler(buildService *service.BuildService) *BuildsHandler {
return &BuildsHandler{
buildService: buildService,
}
}
// Mount registers the build routes.
func (h *BuildsHandler) Mount(r api.Router) {
// Project-scoped build endpoints
r.With(auth.RequireScope(auth.ScopeBuildWrite, auth.ScopeAdmin), auth.RequireProjectAccess("id")).
Post("/projects/{id}/builds", h.StartBuild)
r.With(auth.RequireScope(auth.ScopeBuildRead, auth.ScopeAdmin), auth.RequireProjectAccess("id")).
Get("/projects/{id}/builds", h.ListBuilds)
// Build detail by task ID (no project ID in URL, no project access check needed)
r.With(auth.RequireScope(auth.ScopeBuildRead, auth.ScopeAdmin)).
Get("/builds/{taskId}", h.GetBuild)
}
// StartBuildRequest is the request body for POST /projects/{id}/builds.
type StartBuildRequest struct {
Prompt string `json:"prompt"`
Template string `json:"template,omitempty"`
Variables map[string]string `json:"variables,omitempty"`
AutoCommit bool `json:"auto_commit"`
AutoPush bool `json:"auto_push"`
CallbackURL string `json:"callback_url,omitempty"`
GitCloneURL string `json:"git_clone_url,omitempty"` // Required when auto_commit or auto_push is true
TimeoutSeconds int `json:"timeout_seconds,omitempty"` // 0 = default (10m), valid range: 60-5400
}
// StartBuildResponse is the response for POST /projects/{id}/builds.
type StartBuildResponse struct {
TaskID string `json:"task_id"`
ProjectID string `json:"project_id"`
Status string `json:"status"`
StatusURL string `json:"status_url"`
StreamURL string `json:"stream_url"` // SSE endpoint for real-time build events
}
// BuildAuditDTO is the data transfer object for build audit entries.
type BuildAuditDTO struct {
TaskID string `json:"task_id"`
ProjectID string `json:"project_id"`
WorkerID string `json:"worker_id,omitempty"`
Status string `json:"status"`
Prompt string `json:"prompt"`
Template string `json:"template,omitempty"`
AutoCommit bool `json:"auto_commit"`
AutoPush bool `json:"auto_push"`
Result *BuildResultDTO `json:"result,omitempty"`
StartedAt string `json:"started_at"`
CompletedAt string `json:"completed_at,omitempty"`
StreamURL string `json:"stream_url"` // SSE endpoint for real-time build events
}
// BuildResultDTO is the data transfer object for build results.
type BuildResultDTO struct {
Success bool `json:"success"`
Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"`
ErrorCode string `json:"error_code,omitempty"` // Categorized error type for programmatic handling
CommitSHA string `json:"commit_sha,omitempty"`
FilesChanged []string `json:"files_changed,omitempty"`
DurationMs int64 `json:"duration_ms"`
Artifacts map[string]string `json:"artifacts,omitempty"`
}
func toBuildAuditDTO(e *domain.BuildAuditEntry) *BuildAuditDTO {
if e == nil {
return nil
}
dto := &BuildAuditDTO{
TaskID: e.TaskID,
ProjectID: e.ProjectID,
WorkerID: e.WorkerID,
Status: string(e.Status),
Prompt: e.Spec.Prompt,
Template: e.Spec.Template,
AutoCommit: e.Spec.AutoCommit,
AutoPush: e.Spec.AutoPush,
StartedAt: e.StartedAt.Format("2006-01-02T15:04:05Z07:00"),
StreamURL: "/projects/" + e.ProjectID + "/events?stream_id=" + e.TaskID,
}
if e.CompletedAt != nil {
dto.CompletedAt = e.CompletedAt.Format("2006-01-02T15:04:05Z07:00")
}
if e.Result != nil {
dto.Result = &BuildResultDTO{
Success: e.Result.Success,
Output: e.Result.Output,
Error: e.Result.Error,
ErrorCode: string(e.Result.ErrorCode),
CommitSHA: e.Result.CommitSHA,
FilesChanged: e.Result.FilesChanged,
DurationMs: e.Result.DurationMs,
Artifacts: e.Result.Artifacts,
}
}
return dto
}
// StartBuild enqueues a build task for a project.
// POST /projects/{id}/builds
func (h *BuildsHandler) StartBuild(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if projectID == "" {
api.WriteBadRequest(w, r, "project ID is required")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
var req StartBuildRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Prompt == "" {
api.WriteBadRequest(w, r, "prompt is required")
return
}
// Validate callback URL to prevent SSRF
if req.CallbackURL != "" {
if err := domain.ValidateCallbackURL(req.CallbackURL); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
}
spec := domain.BuildSpec{
Prompt: req.Prompt,
Template: req.Template,
Variables: req.Variables,
AutoCommit: req.AutoCommit,
AutoPush: req.AutoPush,
CallbackURL: req.CallbackURL,
GitCloneURL: req.GitCloneURL,
TimeoutSeconds: req.TimeoutSeconds,
}
// Validate git_clone_url is provided when auto_commit or auto_push is enabled
if (req.AutoCommit || req.AutoPush) && req.GitCloneURL == "" {
api.WriteBadRequest(w, r, "git_clone_url is required when auto_commit or auto_push is enabled")
return
}
taskID, err := h.buildService.StartBuild(r.Context(), projectID, spec)
if err != nil {
if errors.Is(err, domain.ErrPromptRequired) {
api.WriteBadRequest(w, r, err.Error())
return
}
api.WriteInternalError(w, r, "failed to start build")
return
}
api.WriteCreated(w, r, StartBuildResponse{
TaskID: taskID,
ProjectID: projectID,
Status: "pending",
StatusURL: "/builds/" + taskID,
StreamURL: "/projects/" + projectID + "/events?stream_id=" + taskID,
})
}
// ListBuilds returns build history for a project.
// GET /projects/{id}/builds?limit=50
func (h *BuildsHandler) ListBuilds(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if projectID == "" {
api.WriteBadRequest(w, r, "project ID is required")
return
}
limit := 50
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
l, err := strconv.Atoi(limitStr)
if err != nil {
api.WriteBadRequest(w, r, "limit must be a valid integer")
return
}
if l < 1 || l > 200 {
api.WriteBadRequest(w, r, "limit must be between 1 and 200")
return
}
limit = l
}
builds, err := h.buildService.ListBuilds(r.Context(), projectID, limit)
if err != nil {
api.WriteInternalError(w, r, "failed to list builds")
return
}
dtos := make([]*BuildAuditDTO, len(builds))
for i, b := range builds {
dtos[i] = toBuildAuditDTO(b)
}
api.WriteSuccess(w, r, map[string]any{
"builds": dtos,
"project_id": projectID,
"total": len(dtos),
})
}
// GetBuild returns the status of a specific build.
// GET /builds/{taskId}
func (h *BuildsHandler) GetBuild(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskId")
if taskID == "" {
api.WriteBadRequest(w, r, "task ID is required")
return
}
entry, err := h.buildService.GetBuildStatus(r.Context(), taskID)
if err != nil {
if errors.Is(err, domain.ErrBuildNotFound) {
api.WriteNotFound(w, r, "build not found: "+taskID)
return
}
api.WriteInternalError(w, r, "failed to get build status")
return
}
api.WriteSuccess(w, r, toBuildAuditDTO(entry))
}