dev-pod-api-build/internal/api/pods.go
2026-04-16 04:16:36 +00:00

410 lines
11 KiB
Go

package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/k8s"
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
)
// normalizeTools applies the same speckit auto-add logic as dev-pod-create.sh:
// empty → "speckit", "none" → "", otherwise auto-prepend speckit if missing.
func normalizeTools(tools string) string {
if tools == "none" {
return ""
}
if tools == "" {
return "speckit"
}
for _, t := range strings.Split(tools, ",") {
name, _, _ := strings.Cut(strings.TrimSpace(t), "@")
if name == "speckit" {
return tools
}
}
return "speckit," + tools
}
func (s *Server) handleCreatePod(w http.ResponseWriter, r *http.Request) {
var req model.CreatePodRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if err := req.Validate(); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
authUser := UserFromContext(r.Context())
role := RoleFromContext(r.Context())
if role != model.RoleAdmin && authUser.ID != req.User {
writeError(w, http.StatusForbidden, "cannot create pods for other users")
return
}
targetUser, err := s.Users.GetUser(r.Context(), req.User)
if err != nil {
writeError(w, http.StatusNotFound, "user not found")
return
}
// Check monthly pod-hours budget
if s.Usage != nil {
now := time.Now()
usage, usageErr := s.Usage.GetUsage(r.Context(), req.User, now.Year(), now.Month(), targetUser.Quota.MonthlyPodHours)
if usageErr != nil {
s.Logger.Error("failed to check usage", "user", req.User, "error", usageErr)
} else if usage.BudgetUsedPct >= 100 {
writeError(w, http.StatusTooManyRequests, "monthly pod-hours budget exceeded")
return
}
}
cpuReq := req.CPUReq
if cpuReq == "" {
cpuReq = "2"
}
cpuLimit := req.CPULimit
if cpuLimit == "" {
cpuLimit = cpuReq
}
memReq := req.MemReq
if memReq == "" {
memReq = "4Gi"
}
memLimit := req.MemLimit
if memLimit == "" {
memLimit = memReq
}
var forgejoToken string
if tok, tokErr := s.Users.GetForgejoToken(r.Context(), req.User); tokErr == nil {
forgejoToken = tok
}
// Persist tailscale key for future pod updates (best-effort)
if req.TailscaleKey != "" {
if saveErr := s.Users.SaveTailscaleKey(r.Context(), req.User, req.TailscaleKey); saveErr != nil {
s.Logger.Error("failed to save tailscale key", "user", req.User, "error", saveErr)
}
}
pod, err := s.K8s.CreatePod(r.Context(), k8s.CreatePodOpts{
User: req.User,
Pod: req.Pod,
Tools: normalizeTools(req.Tools),
Task: req.Task,
CPUReq: cpuReq,
CPULimit: cpuLimit,
MemReq: memReq,
MemLimit: memLimit,
MaxConcurrentPods: targetUser.Quota.MaxConcurrentPods,
MaxCPUPerPod: targetUser.Quota.MaxCPUPerPod,
MaxRAMGBPerPod: targetUser.Quota.MaxRAMGBPerPod,
ForgejoToken: forgejoToken,
TailscaleKey: req.TailscaleKey,
})
if err != nil {
switch {
case errors.Is(err, k8s.ErrPodAlreadyExists):
writeError(w, http.StatusConflict, "pod already exists")
case errors.Is(err, k8s.ErrQuotaExceeded):
writeError(w, http.StatusTooManyRequests, err.Error())
default:
s.Logger.Error("failed to create pod", "user", req.User, "pod", req.Pod, "error", err)
writeError(w, http.StatusInternalServerError, "failed to create pod")
}
return
}
// Record pod start for usage tracking
if s.Usage != nil {
if recErr := s.Usage.RecordPodStart(r.Context(), req.User, req.Pod); recErr != nil {
s.Logger.Error("failed to record pod start", "user", req.User, "pod", req.Pod, "error", recErr)
}
}
writeJSON(w, http.StatusCreated, pod)
}
func (s *Server) handleDeletePod(w http.ResponseWriter, r *http.Request) {
user := chi.URLParam(r, "user")
pod := chi.URLParam(r, "pod")
if !canAccess(r, user) {
writeError(w, http.StatusForbidden, "access denied")
return
}
err := s.K8s.DeletePod(r.Context(), user, pod)
if err != nil {
if errors.Is(err, k8s.ErrPodNotFound) {
writeError(w, http.StatusNotFound, "pod not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to delete pod")
return
}
// Record pod stop for usage tracking
if s.Usage != nil {
if recErr := s.Usage.RecordPodStop(r.Context(), user, pod); recErr != nil {
s.Logger.Error("failed to record pod stop", "user", user, "pod", pod, "error", recErr)
}
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeleteAllPods(w http.ResponseWriter, r *http.Request) {
user := chi.URLParam(r, "user")
if !canAccess(r, user) {
writeError(w, http.StatusForbidden, "access denied")
return
}
// List pods before deletion to record stops
var podNames []string
if s.Usage != nil {
if pods, listErr := s.K8s.ListPods(r.Context(), user); listErr == nil {
for _, p := range pods {
podNames = append(podNames, p.Name)
}
}
}
if err := s.K8s.DeleteAllPods(r.Context(), user); err != nil {
writeError(w, http.StatusInternalServerError, "failed to delete pods")
return
}
// Record pod stops for usage tracking
if s.Usage != nil {
for _, name := range podNames {
if recErr := s.Usage.RecordPodStop(r.Context(), user, name); recErr != nil {
s.Logger.Error("failed to record pod stop", "user", user, "pod", name, "error", recErr)
}
}
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleListAllPods(w http.ResponseWriter, r *http.Request) {
authUser := UserFromContext(r.Context())
role := RoleFromContext(r.Context())
if role == model.RoleAdmin {
users, err := s.Users.ListUsers(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list users")
return
}
var allPods []model.Pod
for _, u := range users {
pods, err := s.K8s.ListPods(r.Context(), u.ID)
if err != nil {
s.Logger.Error("failed to list pods for user", "user", u.ID, "error", err)
continue
}
allPods = append(allPods, pods...)
}
if allPods == nil {
allPods = []model.Pod{}
}
writeJSON(w, http.StatusOK, allPods)
return
}
pods, err := s.K8s.ListPods(r.Context(), authUser.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list pods")
return
}
if pods == nil {
pods = []model.Pod{}
}
writeJSON(w, http.StatusOK, pods)
}
func (s *Server) handleListUserPods(w http.ResponseWriter, r *http.Request) {
user := chi.URLParam(r, "user")
if !canAccess(r, user) {
writeError(w, http.StatusForbidden, "access denied")
return
}
pods, err := s.K8s.ListPods(r.Context(), user)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list pods")
return
}
if pods == nil {
pods = []model.Pod{}
}
writeJSON(w, http.StatusOK, pods)
}
func (s *Server) handleGetPod(w http.ResponseWriter, r *http.Request) {
user := chi.URLParam(r, "user")
pod := chi.URLParam(r, "pod")
if !canAccess(r, user) {
writeError(w, http.StatusForbidden, "access denied")
return
}
result, err := s.K8s.GetPod(r.Context(), user, pod)
if err != nil {
if errors.Is(err, k8s.ErrPodNotFound) {
writeError(w, http.StatusNotFound, "pod not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to get pod")
return
}
writeJSON(w, http.StatusOK, result)
}
func (s *Server) handleUpdatePod(w http.ResponseWriter, r *http.Request) {
user := chi.URLParam(r, "user")
pod := chi.URLParam(r, "pod")
if !canAccess(r, user) {
writeError(w, http.StatusForbidden, "access denied")
return
}
var req model.UpdatePodRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if err := req.Validate(); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
existing, err := s.K8s.GetPod(r.Context(), user, pod)
if err != nil {
if errors.Is(err, k8s.ErrPodNotFound) {
writeError(w, http.StatusNotFound, "pod not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to get pod")
return
}
targetUser, err := s.Users.GetUser(r.Context(), user)
if err != nil {
writeError(w, http.StatusNotFound, "user not found")
return
}
tools := existing.Tools
if req.Tools != nil {
tools = normalizeTools(*req.Tools)
}
cpuReq := existing.CPUReq
if req.CPUReq != nil {
cpuReq = *req.CPUReq
}
cpuLimit := existing.CPULimit
if req.CPULimit != nil {
cpuLimit = *req.CPULimit
}
memReq := existing.MemReq
if req.MemReq != nil {
memReq = *req.MemReq
}
memLimit := existing.MemLimit
if req.MemLimit != nil {
memLimit = *req.MemLimit
}
task := existing.Task
if req.Task != nil {
task = *req.Task
}
// Validate per-pod resource quota before deleting the existing pod to avoid
// destroying it when the new values would be rejected.
if err := k8s.ValidatePodQuota(cpuLimit, targetUser.Quota.MaxCPUPerPod, memLimit, targetUser.Quota.MaxRAMGBPerPod); err != nil {
if errors.Is(err, k8s.ErrQuotaExceeded) {
writeError(w, http.StatusForbidden, err.Error())
return
}
writeError(w, http.StatusInternalServerError, "failed to validate quota")
return
}
if err := s.K8s.DeletePod(r.Context(), user, pod); err != nil && !errors.Is(err, k8s.ErrPodNotFound) {
writeError(w, http.StatusInternalServerError, "failed to delete existing pod")
return
}
if s.Usage != nil {
if recErr := s.Usage.RecordPodStop(r.Context(), user, pod); recErr != nil {
s.Logger.Error("failed to record pod stop", "user", user, "pod", pod, "error", recErr)
}
}
var updateForgejoToken string
if tok, tokErr := s.Users.GetForgejoToken(r.Context(), user); tokErr == nil {
updateForgejoToken = tok
}
var updateTailscaleKey string
if key, keyErr := s.Users.GetTailscaleKey(r.Context(), user); keyErr == nil {
updateTailscaleKey = key
}
newPod, err := s.K8s.CreatePod(r.Context(), k8s.CreatePodOpts{
User: user,
Pod: pod,
Tools: tools,
Task: task,
CPUReq: cpuReq,
CPULimit: cpuLimit,
MemReq: memReq,
MemLimit: memLimit,
MaxConcurrentPods: targetUser.Quota.MaxConcurrentPods,
MaxCPUPerPod: targetUser.Quota.MaxCPUPerPod,
MaxRAMGBPerPod: targetUser.Quota.MaxRAMGBPerPod,
ForgejoToken: updateForgejoToken,
TailscaleKey: updateTailscaleKey,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to recreate pod")
return
}
if s.Usage != nil {
if recErr := s.Usage.RecordPodStart(r.Context(), user, pod); recErr != nil {
s.Logger.Error("failed to record pod start", "user", user, "pod", pod, "error", recErr)
}
}
writeJSON(w, http.StatusOK, newPod)
}
// canAccess checks if the authenticated user can operate on the target user's resources.
func canAccess(r *http.Request, targetUser string) bool {
role := RoleFromContext(r.Context())
if role == model.RoleAdmin {
return true
}
authUser := UserFromContext(r.Context())
return authUser != nil && authUser.ID == targetUser
}