rdev/internal/handlers/infrastructure.go
jordan 56e3f83955 feat: add auth scopes, OpenAPI docs, SDLC guides, and code quality improvements
- Add auth.RequireScope() to all handler routes for proper authorization
- Add SDLC OpenAPI endpoint documentation (state, features, tasks, branches, merge, archive, orchestrator)
- Add SDLC documentation guides (getting-started, cli-reference, api-reference, command-catalog)
- Add artifact_test.go for SDLC artifact coverage
- Add CLAUDE.md rules: auth scopes requirement, error wrapping with %w
- Fix error wrapping to use %w instead of %v throughout codebase
- Improve CLI merge command with conflict detection and resolution
- Fix handler tests to include auth middleware for RequireScope
- Add cookbook tree runner scripts for automated testing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 13:55:50 -07:00

377 lines
12 KiB
Go

// Package handlers provides HTTP handlers for the rdev API.
package handlers
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/pkg/api"
)
// DomainAddRequest contains parameters for adding a domain.
type DomainAddRequest struct {
ProjectID string
Domain string
Type domain.DomainType
RecordType string
Proxied bool
}
// DomainService defines the interface for domain management operations.
// This interface is implemented by service.ProjectInfraService.
type DomainService interface {
ListDomains(ctx context.Context, projectID string) ([]*domain.ProjectDomain, error)
AddDomain(ctx context.Context, req DomainAddRequest) (*domain.ProjectDomain, error)
RemoveDomain(ctx context.Context, projectID, fqdn string) error
}
// InfrastructureHandler handles git, deployment, DNS, and CI pipeline endpoints.
type InfrastructureHandler struct {
gitRepo port.GitRepository
dns port.DNSProvider
deployer port.Deployer
projects port.ProjectRepository
ciProvider port.CIProvider
domainService DomainService
// Config
defaultGitOwner string
defaultDomain string
clusterIP string
}
// validateProjectID validates that a project ID is safe for use as repo/deployment name.
func validateProjectID(id string) error {
return domain.ValidateProjectID(id)
}
// InfrastructureConfig configures the infrastructure handler.
type InfrastructureConfig struct {
// DefaultGitOwner is the default org/user for new repos (e.g., "threesix")
DefaultGitOwner string
// DefaultDomain is the base domain for auto-generated URLs (e.g., "threesix.ai")
DefaultDomain string
// ClusterIP is the external IP address for DNS records (e.g., "208.122.204.172")
ClusterIP string
}
// NewInfrastructureHandler creates a new infrastructure handler.
func NewInfrastructureHandler(
gitRepo port.GitRepository,
dns port.DNSProvider,
deployer port.Deployer,
projects port.ProjectRepository,
ciProvider port.CIProvider,
domainService DomainService,
cfg InfrastructureConfig,
) *InfrastructureHandler {
return &InfrastructureHandler{
gitRepo: gitRepo,
dns: dns,
deployer: deployer,
projects: projects,
ciProvider: ciProvider,
domainService: domainService,
defaultGitOwner: cfg.DefaultGitOwner,
defaultDomain: cfg.DefaultDomain,
clusterIP: cfg.ClusterIP,
}
}
// Mount registers the infrastructure routes.
func (h *InfrastructureHandler) Mount(r api.Router) {
// Git repository endpoints
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/projects/{id}/repo", h.CreateRepo)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/projects/{id}/repo", h.GetRepo)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Delete("/projects/{id}/repo", h.DeleteRepo)
// Deployment endpoints
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/projects/{id}/deploy", h.Deploy)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/projects/{id}/deploy/status", h.GetDeployStatus)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Delete("/projects/{id}/deploy", h.Undeploy)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/projects/{id}/deploy/restart", h.RestartDeploy)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/projects/{id}/deploy/scale", h.ScaleDeploy)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/projects/{id}/deploy/logs", h.GetDeployLogs)
// Domain endpoints (single)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/projects/{id}/domain", h.AddDomain)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Delete("/projects/{id}/domain", h.RemoveDomain)
// Domain alias management (multi-domain)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/projects/{id}/domains", h.ListDomains)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/projects/{id}/domains", h.AddDomainAlias)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Delete("/projects/{id}/domains/{domain}", h.RemoveDomainAlias)
// CI pipeline endpoints (read-only)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/projects/{id}/pipelines", h.ListPipelines)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/projects/{id}/pipelines/{number}", h.GetPipeline)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/projects/{id}/pipelines/{number}/steps", h.GetPipelineSteps)
}
// CreateRepoRequest is the request body for POST /projects/{id}/repo.
type CreateRepoRequest struct {
Description string `json:"description,omitempty"`
Private bool `json:"private,omitempty"`
}
// CreateRepoResponse is the response for POST /projects/{id}/repo.
type CreateRepoResponse struct {
ID int64 `json:"id"`
Owner string `json:"owner"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description,omitempty"`
Private bool `json:"private"`
CloneSSH string `json:"clone_ssh"`
CloneHTTP string `json:"clone_http"`
HTMLURL string `json:"html_url"`
}
// CreateRepo creates a git repository for a project.
// POST /projects/{id}/repo
func (h *InfrastructureHandler) CreateRepo(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
// Validate project ID
if err := validateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
if h.gitRepo == nil {
api.WriteInternalError(w, r, "git repository not configured")
return
}
var req CreateRepoRequest
if err := api.DecodeJSON(r, &req); err != nil && !errors.Is(err, api.ErrEmptyBody) {
api.WriteBadRequest(w, r, "invalid request body")
return
}
// Create the repo
repo, err := h.gitRepo.CreateRepo(ctx, projectID, req.Description, req.Private)
if err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to create repo: %v", err))
return
}
// Note: Could update project with repo info here if we had a project repository
// For now, just return the repo info directly
_ = h.projects // Silence unused warning if present
api.WriteCreated(w, r, CreateRepoResponse{
ID: repo.ID,
Owner: repo.Owner,
Name: repo.Name,
FullName: repo.FullName,
Description: repo.Description,
Private: repo.Private,
CloneSSH: repo.CloneSSH,
CloneHTTP: repo.CloneHTTP,
HTMLURL: repo.HTMLURL,
})
}
// GetRepo returns the git repository for a project.
// GET /projects/{id}/repo
func (h *InfrastructureHandler) GetRepo(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup)
defer cancel()
// Validate project ID
if err := validateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
if h.gitRepo == nil {
api.WriteInternalError(w, r, "git repository not configured")
return
}
repo, err := h.gitRepo.GetRepo(ctx, h.defaultGitOwner, projectID)
if err != nil {
api.WriteNotFound(w, r, fmt.Sprintf("repo not found: %s/%s", h.defaultGitOwner, projectID))
return
}
api.WriteSuccess(w, r, CreateRepoResponse{
ID: repo.ID,
Owner: repo.Owner,
Name: repo.Name,
FullName: repo.FullName,
Description: repo.Description,
Private: repo.Private,
CloneSSH: repo.CloneSSH,
CloneHTTP: repo.CloneHTTP,
HTMLURL: repo.HTMLURL,
})
}
// DeleteRepo deletes the git repository for a project.
// DELETE /projects/{id}/repo
func (h *InfrastructureHandler) DeleteRepo(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
// Validate project ID
if err := validateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
if h.gitRepo == nil {
api.WriteInternalError(w, r, "git repository not configured")
return
}
err := h.gitRepo.DeleteRepo(ctx, h.defaultGitOwner, projectID)
if err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to delete repo: %v", err))
return
}
api.WriteSuccess(w, r, map[string]string{
"status": "deleted",
"project": projectID,
})
}
// AddDomainRequest is the request body for POST /projects/{id}/domain.
type AddDomainRequest struct {
Domain string `json:"domain"` // Custom domain (e.g., "myapp.example.com")
}
// AddDomain adds a custom domain to a project.
// POST /projects/{id}/domain
func (h *InfrastructureHandler) AddDomain(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
// Validate project ID
if err := validateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
var req AddDomainRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Domain == "" {
api.WriteBadRequest(w, r, "domain is required")
return
}
// Create DNS record if it's a threesix.ai subdomain
if h.dns != nil && h.clusterIP != "" && isSubdomain(req.Domain, h.defaultDomain) {
subdomain := getSubdomain(req.Domain, h.defaultDomain)
_, err := h.dns.CreateRecord(ctx, domain.DNSRecord{
Type: "A",
Name: subdomain,
Content: h.clusterIP,
TTL: 1,
Proxied: false,
})
if err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to create DNS record: %v", err))
return
}
}
// TODO: Update ingress with new domain
// This would require getting the current deployment and updating it
note := "Domain configured"
if !isSubdomain(req.Domain, h.defaultDomain) && h.clusterIP != "" {
note = fmt.Sprintf("External domain configured. Point your DNS to %s", h.clusterIP)
}
api.WriteCreated(w, r, map[string]string{
"project": projectID,
"domain": req.Domain,
"status": "configured",
"note": note,
})
}
// RemoveDomain removes a custom domain from a project.
// DELETE /projects/{id}/domain
func (h *InfrastructureHandler) RemoveDomain(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
// Validate project ID
if err := validateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
domainName := r.URL.Query().Get("domain")
if domainName == "" {
api.WriteBadRequest(w, r, "domain query parameter is required")
return
}
// Delete DNS record if it's a threesix.ai subdomain
if h.dns != nil && isSubdomain(domainName, h.defaultDomain) {
subdomain := getSubdomain(domainName, h.defaultDomain)
_ = h.dns.DeleteRecordByName(ctx, "A", subdomain)
}
// TODO: Update ingress to remove the domain
api.WriteSuccess(w, r, map[string]string{
"project": projectID,
"domain": domainName,
"status": "removed",
})
}
// Helper functions
func isSubdomain(domain, baseDomain string) bool {
suffix := "." + baseDomain
return len(domain) > len(suffix) && domain[len(domain)-len(suffix):] == suffix
}
func getSubdomain(domain, baseDomain string) string {
suffix := "." + baseDomain
if len(domain) > len(suffix) && domain[len(domain)-len(suffix):] == suffix {
return domain[:len(domain)-len(suffix)]
}
return domain
}