build source
This commit is contained in:
commit
ee1fec43ed
4171 changed files with 1351288 additions and 0 deletions
410
internal/api/pods.go
Normal file
410
internal/api/pods.go
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue