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 }