410 lines
11 KiB
Go
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
|
|
}
|