// 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/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.Post("/projects/{id}/repo", h.CreateRepo) r.Get("/projects/{id}/repo", h.GetRepo) r.Delete("/projects/{id}/repo", h.DeleteRepo) // Deployment endpoints r.Post("/projects/{id}/deploy", h.Deploy) r.Get("/projects/{id}/deploy/status", h.GetDeployStatus) r.Delete("/projects/{id}/deploy", h.Undeploy) r.Post("/projects/{id}/deploy/restart", h.RestartDeploy) r.Post("/projects/{id}/deploy/scale", h.ScaleDeploy) r.Get("/projects/{id}/deploy/logs", h.GetDeployLogs) // Domain endpoints (single) r.Post("/projects/{id}/domain", h.AddDomain) r.Delete("/projects/{id}/domain", h.RemoveDomain) // Domain alias management (multi-domain) r.Get("/projects/{id}/domains", h.ListDomains) r.Post("/projects/{id}/domains", h.AddDomainAlias) r.Delete("/projects/{id}/domains/{domain}", h.RemoveDomainAlias) // CI pipeline endpoints r.Get("/projects/{id}/pipelines", h.ListPipelines) r.Get("/projects/{id}/pipelines/{number}", h.GetPipeline) r.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 }