// Package api wires the HTTP endpoints. Kept on net/http — no router lib // for a handful of endpoints, stdlib mux is plenty. package api import ( "crypto/rand" "encoding/hex" "encoding/json" "net/http" "strings" "sync" "github.com/sdp/control-plane/internal/store" "github.com/sdp/control-plane/internal/ws" "github.com/sdp/protocol" ) type Server struct { st *store.Store hub *ws.Hub agents *AgentRegistry sess *Sessions } type AgentRegistry struct { mu sync.RWMutex conns map[string]bool // nodeIDs currently connected over WS } func New(st *store.Store, hub *ws.Hub) *Server { return &Server{ st: st, hub: hub, agents: &AgentRegistry{conns: make(map[string]bool)}, sess: NewSessions(), } } func (s *Server) Routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/api/login", s.handleLogin) mux.HandleFunc("/api/repos", s.handleListRepos) mux.HandleFunc("/api/repos/branches", s.handleListBranches) mux.HandleFunc("/api/deployments", s.handleDeployments) // GET list, POST create mux.HandleFunc("/api/deployments/stop", s.handleStopDeployment) // POST mux.Handle("/ws/agent", s.hub.AgentWS(s.st, func(nodeID string) { s.agents.mu.Lock() s.agents.conns[nodeID] = true s.agents.mu.Unlock() }, func(nodeID string) { s.agents.mu.Lock() delete(s.agents.conns, nodeID) s.agents.mu.Unlock() }, )) mux.HandleFunc("/ws/deployments/", s.hub.DeploymentWS(s.st)) return s.withAuth(mux) } // withAuth checks the session cookie on /api/* (skipping login). /ws/* is // protected at the handler — we don't pass auth in headers easily on the WS // upgrade from the browser, so the dashboard sends ?token=... instead. func (s *Server) withAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api/login" { next.ServeHTTP(w, r) return } c, err := r.Cookie("sdp_session") if err != nil || !s.sess.Valid(c.Value) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } next.ServeHTTP(w, r) }) } // ---- login ---- type loginReq struct { Username string `json:"username"` Password string `json:"password"` Repo string `json:"repo"` // optional: validate against this specific repo } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "POST only", http.StatusMethodNotAllowed) return } var body loginReq if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } // ponytail: trust boundary lives in the agent — it does the actual git // ls-remote. The control plane just hands off credentials per-op. // For login we ask any connected agent to validate. If none are // connected, fail. Real impl: pick a known bootstrap repo. ok := s.validateViaAgent(body.Username, body.Password, body.Repo) if !ok { http.Error(w, "login failed", http.StatusUnauthorized) return } tok := s.sess.Issue(body.Username) http.SetCookie(w, &http.Cookie{ Name: "sdp_session", Value: tok, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 12 * 3600, }) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"ok":true}`)) } // validateViaAgent does a git ls-remote through one of the connected agents. // The agent holds the repos; the control plane never touches git directly. // // ponytail: MVP stub. Returns true if any agent is connected so the smoke // flow can run. Real impl will send a "probe" frame over the agent's WS // and wait for a reply. func (s *Server) validateViaAgent(user, pass, repo string) bool { _ = user _ = pass _ = repo s.agents.mu.RLock() defer s.agents.mu.RUnlock() return len(s.agents.conns) > 0 } // ---- repos ---- type repoInfo struct { Name string `json:"name"` Node string `json:"node"` Path string `json:"path"` } func (s *Server) handleListRepos(w http.ResponseWriter, r *http.Request) { // ponytail: real impl asks the connected agents for their repo list. // For MVP smoke, stub with the spec's example. repos := []repoInfo{ {Name: "account", Node: "micro", Path: "/home/user/AppGolang/account"}, {Name: "payment", Node: "micro", Path: "/home/user/AppGolang/payment"}, {Name: "user", Node: "micro", Path: "/home/user/AppGolang/user"}, {Name: "notification", Node: "micro", Path: "/home/user/AppGolang/notification"}, {Name: "api-gateway", Node: "gateway", Path: "/home/user/SDP"}, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(repos) } func (s *Server) handleListBranches(w http.ResponseWriter, r *http.Request) { repo := r.URL.Query().Get("repo") if repo == "" { http.Error(w, "repo required", http.StatusBadRequest) return } // ponytail: real impl forwards to the agent. For MVP, stub. branches := []string{"main", "develop", "feature/login-error"} w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(branches) } // ---- deployments ---- type deployReq struct { Repository string `json:"repository"` Branch string `json:"branch"` Env map[string]string `json:"env,omitempty"` Username string `json:"username"` Password string `json:"password"` } func (s *Server) handleDeployments(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: // ponytail: list from SQLite. Real impl: SELECT with filter. w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`[]`)) case http.MethodPost: var body deployReq if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } // resolve repo -> node node := "micro" if body.Repository == "api-gateway" { node = "gateway" } // ensure agent connected s.agents.mu.RLock() connected := s.agents.conns[node] s.agents.mu.RUnlock() if !connected { http.Error(w, "agent "+node+" not connected", http.StatusServiceUnavailable) return } id := newID() _ = s.st.StartDeployment(id, body.Repository, body.Branch, body.Username) // send deploy request to agent over its WS req := protocol.DeployRequest{ DeploymentID: id, Repository: body.Repository, Branch: body.Branch, Env: body.Env, Username: body.Username, Password: body.Password, } payload, _ := json.Marshal(map[string]any{ "op": "deploy", "data": req, }) if !s.hub.SendToAgent(node, payload) { http.Error(w, "agent buffer full", http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"id": id}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } 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 struct { ID string `json:"id"` Node string `json:"node"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } payload, _ := json.Marshal(map[string]any{"op": "stop", "id": body.ID}) if !s.hub.SendToAgent(body.Node, payload) { http.Error(w, "agent not reachable", http.StatusServiceUnavailable) return } w.WriteHeader(http.StatusOK) } func newID() string { b := make([]byte, 8) _, _ = rand.Read(b) return hex.EncodeToString(b) }