package api import ( "context" "encoding/json" "net/http" "strconv" "time" "github.com/sdp/control-plane/internal/store" "github.com/sdp/protocol" ) // handleListDeployments reads deployment history from SQLite. Filterable // by sandbox via ?sandbox=ID. Limit via ?limit=N (default 100, max 500). func (s *Server) handleListDeployments(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "GET only", http.StatusMethodNotAllowed) return } sandboxID := r.URL.Query().Get("sandbox") limit := 100 if l := r.URL.Query().Get("limit"); l != "" { if n, err := strconv.Atoi(l); err == nil { limit = n } } deps, err := s.st.ListDeployments(sandboxID, limit) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } if deps == nil { deps = []store.Deployment{} // never null in JSON } writeJSON(w, http.StatusOK, deps) } type createDeployReq struct { Repository string `json:"repository"` Branch string `json:"branch"` Env map[string]string `json:"env,omitempty"` Username string `json:"username"` Password string `json:"password"` // SandboxID optionally ties this deploy to a sandbox. When set, the // sandbox's host_port for the repo is used as the container's // published port and the env is the union of (sandbox env) and // (request env). SandboxID string `json:"sandboxId,omitempty"` // HostPort overrides the auto-allocated port. Use 0 to allocate. HostPort int `json:"hostPort,omitempty"` } // handleCreateDeployment is the Slice-2-friendly deploy path. It // resolves the repo to a node, looks up the sandbox's port (if any), // and dispatches the deploy frame. func (s *Server) handleCreateDeployment(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return } var body createDeployReq if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if body.Repository == "" || body.Branch == "" { writeErr(w, http.StatusBadRequest, "repository and branch required") return } node, err := s.resolveNode(body.Repository, body.SandboxID) if err != nil { writeErr(w, http.StatusBadRequest, err.Error()) return } 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() hostPort := body.HostPort env := body.Env if body.SandboxID != "" { sb, err := s.st.GetSandbox(body.SandboxID) if err == nil { for _, svc := range sb.Services { if svc.Repo == body.Repository { if hostPort == 0 { hostPort = svc.HostPort } if svc.EnvID != "" { if e, err := s.st.GetEnvironment(svc.EnvID); err == nil { if env == nil { env = map[string]string{} } for k, v := range e.Values { env[k] = v } } } break } } } } if err := s.st.StartDeploymentInSandbox(id, body.SandboxID, body.Repository, body.Branch, user, hostPort); err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return } req := protocol.DeployRequest{ DeploymentID: id, SandboxID: body.SandboxID, Repository: body.Repository, Branch: body.Branch, HostPort: 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}) } type stopDeployReq struct { ID string `json:"id"` Node string `json:"node"` } func (s *Server) handleStopDeployment(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return } var body stopDeployReq if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if body.Node == "" { // try to find the node from the deployment row if d, err := s.st.GetDeployment(body.ID); err == nil && d != nil { body.Node, _ = s.resolveNode(d.Repository, d.SandboxID) } } if body.Node == "" { writeErr(w, http.StatusBadRequest, "node required") return } payload, _ := json.Marshal(map[string]any{"op": "stop", "id": body.ID}) if !s.hub.SendToAgent(body.Node, payload) { writeErr(w, http.StatusServiceUnavailable, "agent not reachable") return } _ = s.st.FinishDeployment(body.ID, "STOPPED") writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } // resolveNode picks which agent serves a repo. The gateway agent owns // the api-gateway repo. Everything else is the micro agent. func (s *Server) resolveNode(repo, sandboxID string) (string, error) { if repo == "api-gateway" { return "gateway", nil } return "micro", nil } // callAgentPushRoutes pushes a routes table to the gateway agent. func (s *Server) callAgentPushRoutes(ctx context.Context, sandboxID string, payload []protocol.RouteOverride) error { ctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() data := map[string]any{ "sandboxId": sandboxID, "routes": payload, } raw, err := s.hub.CallAgent(ctx, "gateway", "push_routes", data, 0) if err != nil { return err } var reply struct { OK bool `json:"ok"` Err string `json:"error,omitempty"` } if err := json.Unmarshal(raw, &reply); err != nil { return err } if !reply.OK { return wsError(reply.Err) } return nil }