package api import ( "encoding/json" "net/http" "strings" "github.com/sdp/control-plane/internal/store" ) type sandboxReq struct { Name string `json:"name"` GatewayBranch string `json:"gatewayBranch"` GatewayEnvID string `json:"gatewayEnvId"` GatewayHostPort int `json:"gatewayHostPort"` Services []store.SandboxService `json:"services"` } type cloneReq struct { TemplateID string `json:"templateId"` Name string `json:"name"` } func (s *Server) handleSandboxes(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: sbs, err := s.st.ListSandboxes() if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } if sbs == nil { sbs = []store.Sandbox{} } writeJSON(w, http.StatusOK, sbs) case http.MethodPost: var body sandboxReq if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if body.Name == "" { writeErr(w, http.StatusBadRequest, "name required") return } sb := store.Sandbox{ ID: newID(), Name: body.Name, GatewayBranch: body.GatewayBranch, GatewayEnvID: body.GatewayEnvID, GatewayHostPort: body.GatewayHostPort, Services: body.Services, } if err := s.st.CreateSandbox(sb); err != nil { writeErr(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, sb) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (s *Server) handleSandboxByID(w http.ResponseWriter, r *http.Request) { rest := strings.TrimPrefix(r.URL.Path, "/api/sandboxes/") parts := strings.Split(rest, "/") id := parts[0] if id == "" { http.Error(w, "not found", http.StatusNotFound) return } // /api/sandboxes/{id}/deploy/{repo} if len(parts) == 3 && parts[1] == "deploy" { s.handleSandboxDeploy(w, r, id, parts[2]) return } if len(parts) != 1 { http.Error(w, "not found", http.StatusNotFound) return } switch r.Method { case http.MethodGet: sb, err := s.st.GetSandbox(id) if err == store.ErrNotFound { http.Error(w, "not found", http.StatusNotFound) return } if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } // include most recent deploy per service for i := range sb.Services { d, _ := s.st.LatestDeploymentBySandboxService(sb.ID, sb.Services[i].Repo) if d != nil { sb.Services[i].Branch = d.Branch } } writeJSON(w, http.StatusOK, sb) case http.MethodPut: var body sandboxReq if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } sb := store.Sandbox{ ID: id, Name: body.Name, GatewayBranch: body.GatewayBranch, GatewayEnvID: body.GatewayEnvID, GatewayHostPort: body.GatewayHostPort, Services: body.Services, } if err := s.st.UpdateSandbox(sb); err == store.ErrNotFound { http.Error(w, "not found", http.StatusNotFound) return } else if err != nil { writeErr(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, sb) case http.MethodDelete: if err := s.st.DeleteSandbox(id); err == store.ErrNotFound { http.Error(w, "not found", http.StatusNotFound) return } else if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (s *Server) handleCloneSandbox(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return } var body cloneReq if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if body.TemplateID == "" || body.Name == "" { writeErr(w, http.StatusBadRequest, "templateId and name required") return } sb := store.Sandbox{ID: newID(), Name: body.Name} if err := s.st.CloneTemplateIntoSandbox(body.TemplateID, sb); err == store.ErrNotFound { http.Error(w, "template not found", http.StatusNotFound) return } else if err != nil { writeErr(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, sb) } type sandboxDeployReq struct { Branch string `json:"branch"` Env map[string]string `json:"env,omitempty"` EnvID string `json:"envId,omitempty"` Username string `json:"username"` Password string `json:"password"` } // handleSandboxDeploy deploys one service inside a sandbox. The // sandbox's host_port is used unless the request overrides it. The // sandbox's env is merged with the request's env. func (s *Server) handleSandboxDeploy(w http.ResponseWriter, r *http.Request, sandboxID, repo string) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return } var body sandboxDeployReq if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if body.Branch == "" { writeErr(w, http.StatusBadRequest, "branch required") return } sb, err := s.st.GetSandbox(sandboxID) if err == store.ErrNotFound { http.Error(w, "sandbox not found", http.StatusNotFound) return } if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } var svc *store.SandboxService for i := range sb.Services { if sb.Services[i].Repo == repo { svc = &sb.Services[i] break } } if svc == nil { writeErr(w, http.StatusBadRequest, "repo not in sandbox") return } env := body.Env if env == nil { env = map[string]string{} } if svc.EnvID != "" { if e, err := s.st.GetEnvironment(svc.EnvID); err == nil { for k, v := range e.Values { env[k] = v } } } if body.EnvID != "" { if e, err := s.st.GetEnvironment(body.EnvID); err == nil { for k, v := range e.Values { env[k] = v } } } node, _ := s.resolveNode(repo, sandboxID) s.agents.mu.RLock() connected := s.agents.conns[node] s.agents.mu.RUnlock() if !connected { writeErr(w, http.StatusServiceUnavailable, "agent "+node+" not connected") return } user := s.userFromContext(r) id := newID() if err := s.st.StartDeploymentInSandbox(id, sandboxID, repo, body.Branch, user, svc.HostPort); err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } req := map[string]any{ "deploymentId": id, "sandboxId": sandboxID, "repository": repo, "branch": body.Branch, "hostPort": svc.HostPort, "env": env, "username": body.Username, "password": body.Password, } payload, _ := json.Marshal(map[string]any{"op": "deploy", "data": req}) if !s.hub.SendToAgent(node, payload) { writeErr(w, http.StatusServiceUnavailable, "agent buffer full") return } writeJSON(w, http.StatusOK, map[string]any{"id": id}) }