package api import ( "encoding/json" "errors" "net/http" "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" "github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/store" ) func (s *Server) handleCreateRunner(w http.ResponseWriter, r *http.Request) { if s.Runners == nil { writeError(w, http.StatusServiceUnavailable, "runner management not configured") return } var req model.CreateRunnerRequest 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 runners for other users") return } if _, err := s.Users.GetUser(r.Context(), req.User); err != nil { writeError(w, http.StatusNotFound, "user not found") return } if req.WebhookDeliveryID != "" { isDupe, err := s.Runners.IsDeliveryProcessed(r.Context(), req.WebhookDeliveryID) if err != nil { s.Logger.Error("check delivery dedupe", "error", err) writeError(w, http.StatusInternalServerError, "failed to check delivery") return } if isDupe { writeError(w, http.StatusConflict, "webhook delivery already processed") return } } runnerID, err := store.GenerateRunnerID() if err != nil { s.Logger.Error("generate runner id", "error", err) writeError(w, http.StatusInternalServerError, "failed to generate runner id") return } branch := req.Branch if branch == "" { branch = "main" } cpuReq := req.CPUReq if cpuReq == "" { cpuReq = "2" } memReq := req.MemReq if memReq == "" { memReq = "4Gi" } now := time.Now().UTC() runner := &model.Runner{ ID: runnerID, User: req.User, RepoURL: req.Repo, Branch: branch, Tools: req.Tools, Task: req.Task, Status: model.RunnerStatusReceived, WebhookDeliveryID: req.WebhookDeliveryID, CPUReq: cpuReq, MemReq: memReq, CreatedAt: now, } if err := s.Runners.CreateRunner(r.Context(), runner); err != nil { if errors.Is(err, store.ErrDuplicate) { writeError(w, http.StatusConflict, "runner already exists") return } s.Logger.Error("create runner record", "error", err) writeError(w, http.StatusInternalServerError, "failed to create runner") return } if err := s.Runners.UpdateRunnerStatus(r.Context(), runnerID, model.RunnerStatusPodCreating, ""); err != nil { s.Logger.Error("update runner status to pod_creating", "error", err) writeError(w, http.StatusInternalServerError, "failed to update runner status") return } if s.RunnerPods != nil { podName, podErr := s.RunnerPods.CreateRunnerPod(r.Context(), k8s.CreateRunnerPodOpts{ User: req.User, RunnerID: runnerID, Tools: req.Tools, Task: req.Task, RepoURL: req.Repo, Branch: branch, CPUReq: cpuReq, MemReq: memReq, ForgejoRunnerToken: s.ForgejoRunnerToken, }) if podErr != nil { s.Logger.Error("create runner pod", "runner", runnerID, "error", podErr) _ = s.Runners.UpdateRunnerStatus(r.Context(), runnerID, model.RunnerStatusFailed, "") writeError(w, http.StatusInternalServerError, "failed to create runner pod") return } runner.PodName = podName } runner.Status = model.RunnerStatusPodCreating writeJSON(w, http.StatusCreated, runner) } func (s *Server) handleListRunners(w http.ResponseWriter, r *http.Request) { if s.Runners == nil { writeError(w, http.StatusServiceUnavailable, "runner management not configured") return } role := RoleFromContext(r.Context()) authUser := UserFromContext(r.Context()) userFilter := r.URL.Query().Get("user") statusFilter := r.URL.Query().Get("status") if role != model.RoleAdmin { userFilter = authUser.ID } runners, err := s.Runners.ListRunners(r.Context(), userFilter, statusFilter) if err != nil { s.Logger.Error("list runners", "error", err) writeError(w, http.StatusInternalServerError, "failed to list runners") return } if runners == nil { runners = []model.Runner{} } writeJSON(w, http.StatusOK, runners) } func (s *Server) handleDeleteRunner(w http.ResponseWriter, r *http.Request) { if s.Runners == nil { writeError(w, http.StatusServiceUnavailable, "runner management not configured") return } id := chi.URLParam(r, "id") runner, err := s.Runners.GetRunner(r.Context(), id) if err != nil { if errors.Is(err, store.ErrNotFound) { writeError(w, http.StatusNotFound, "runner not found") return } writeError(w, http.StatusInternalServerError, "failed to get runner") return } if !canAccess(r, runner.User) { writeError(w, http.StatusForbidden, "access denied") return } if runner.PodName != "" && s.RunnerPods != nil { if podErr := s.RunnerPods.DeleteRunnerPod(r.Context(), runner.User, runner.PodName); podErr != nil { s.Logger.Error("delete runner pod", "runner", id, "pod", runner.PodName, "error", podErr) } } if err := s.Runners.DeleteRunner(r.Context(), id); err != nil { s.Logger.Error("delete runner record", "id", id, "error", err) writeError(w, http.StatusInternalServerError, "failed to delete runner") return } w.WriteHeader(http.StatusNoContent) } func (s *Server) handleUpdateRunnerStatus(w http.ResponseWriter, r *http.Request) { if s.Runners == nil { writeError(w, http.StatusServiceUnavailable, "runner management not configured") return } id := chi.URLParam(r, "id") var req model.UpdateRunnerStatusRequest 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 } runner, err := s.Runners.GetRunner(r.Context(), id) if err != nil { if errors.Is(err, store.ErrNotFound) { writeError(w, http.StatusNotFound, "runner not found") return } writeError(w, http.StatusInternalServerError, "failed to get runner") return } if !canAccess(r, runner.User) { writeError(w, http.StatusForbidden, "access denied") return } if err := s.Runners.UpdateRunnerStatus(r.Context(), id, req.Status, req.ForgejoRunnerID); err != nil { s.Logger.Error("update runner status", "id", id, "status", req.Status, "error", err) writeError(w, http.StatusBadRequest, err.Error()) return } updated, err := s.Runners.GetRunner(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, "failed to get updated runner") return } writeJSON(w, http.StatusOK, updated) }