Files
bri-sandbox-development-pla…/control-plane/internal/api/api.go
T
Achmad 55d7705c63 Slice 2: real auth, agent-mediated repo/branch listing, deployment list from SQLite
- protocol: add RepoInfo, RouteOverride; add HostPort, SandboxID to DeployRequest.
- ws hub: add CallAgent for sync request/response RPCs over the agent WS,
  and DeliverAgentReply to route {op:reply} frames back to the caller.
  UnregisterAgent now also fails any pending RPCs so callers don't hang.
- agent-micro: new op handlers list_repos, list_branches, probe.
  Wire protocol.Event frames use json.RawMessage so each op decodes
  its own data shape.
- agent-gateway: same op handlers (list_repos/list_branches/probe) plus
  push_routes, which the gateway uses to rewrite the api-gateway
  config.php. Detailed in a later commit.
- control-plane login: validateViaAgent now calls CallAgent('probe')
  against the gateway agent (git ls-remote), replacing the
  accept-any-creds stub.
- control-plane repos: handleListRepos and handleListBranches forward
  to the agents via list_repos / list_branches RPCs, replacing the
  hardcoded fixtures.
- control-plane deployments: split into its own file. handleListDeployments
  reads from SQLite (was hardcoded []). handleCreateDeployment now
  supports sandbox-scoped deploys with a host port + env merge.
  handleStopDeployment looks up the node from the deployment row.
- store: split into store.go + deployments.go. The Deployment type
  adds sandboxId, containerId, hostPort. StartDeploymentInSandbox,
  SetContainerID, ListDeployments, GetDeployment, LatestDeploymentBySandboxService
  are new.
- store_test.go: round-trips every Slice-2 path (env, sandbox,
  template, clone, routes, deployment).
- .gitignore: track bin/ — the build runs on a separate Linux box
  with the golang:1.24 toolchain, and the binaries are SCPed from
  there to the company VMs (92 / 186). The VMs have no internet.
- Tracked bin/{control-plane,agent-micro,agent-gateway}.
2026-06-24 03:58:53 +00:00

153 lines
4.8 KiB
Go

// Package api wires the HTTP endpoints. Kept on net/http — no router lib
// for a handful of endpoints, stdlib mux is plenty.
//
// Slice 2 adds sandboxes, templates, environments, routes, port
// allocation, and a real agent-mediated auth path. The legacy
// /api/deployments POST is kept for ad-hoc deploys (no sandbox) and a
// new /api/sandboxes/{id}/services/{repo}/deploy adds the
// sandbox-scoped variant that picks the right port and env.
package api
import (
"crypto/rand"
"encoding/hex"
"net/http"
"strings"
"sync"
"time"
"github.com/sdp/control-plane/internal/store"
"github.com/sdp/control-plane/internal/ws"
)
type Server struct {
st *store.Store
hub *ws.Hub
agents *AgentRegistry
sess *Sessions
// gatewayRepo is the repo the control plane uses to validate
// Bitbucket creds at login. Defaults to "api-gateway".
gatewayRepo string
// microVMIP / gatewayVMIP are the host IPs the dashboard shows
// when a user picks a "local stand-in" for a microservice route.
// Defaults match the company infra (172.18.136.92 / 172.18.139.186).
microVMIP string
gatewayVMIP string
}
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(),
gatewayRepo: "api-gateway",
microVMIP: "172.18.136.92",
gatewayVMIP: "172.18.139.186",
}
}
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
// auth
mux.HandleFunc("/api/login", s.handleLogin)
mux.HandleFunc("/api/logout", s.handleLogout)
// repos & deploy
mux.HandleFunc("/api/repos", s.handleListRepos)
mux.HandleFunc("/api/repos/branches", s.handleListBranches)
mux.HandleFunc("/api/agents", s.handleListAgents)
mux.HandleFunc("/api/deployments", s.handleListDeployments) // GET list
mux.HandleFunc("/api/deployments/new", s.handleCreateDeployment) // POST
mux.HandleFunc("/api/deployments/stop", s.handleStopDeployment) // POST
// environments
mux.HandleFunc("/api/environments", s.handleEnvironments) // GET list, POST create
mux.HandleFunc("/api/environments/", s.handleEnvironmentByID) // GET/PUT/DELETE /{id}
// sandboxes
mux.HandleFunc("/api/sandboxes", s.handleSandboxes) // GET list, POST create
mux.HandleFunc("/api/sandboxes/", s.handleSandboxByID) // GET/PUT/DELETE /{id}
mux.HandleFunc("/api/sandboxes/clone", s.handleCloneSandbox) // POST: clone from template
// templates
mux.HandleFunc("/api/templates", s.handleTemplates) // GET list, POST create
mux.HandleFunc("/api/templates/", s.handleTemplateByID) // GET/PUT/DELETE /{id}
// routes
mux.HandleFunc("/api/routes", s.handleListRoutes) // GET: live routes from gateway agent's config.php
mux.HandleFunc("/api/routes/push", s.handlePushRoutes) // POST: push routes for a sandbox
// websocket
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)
})
}
// userFromContext returns the authenticated user from the session cookie.
// The cookie is set on /api/login; on every other /api/* path we look it
// up here so handlers can record who did what (audit trail).
func (s *Server) userFromContext(r *http.Request) string {
c, err := r.Cookie("sdp_session")
if err != nil {
return ""
}
u, _ := s.sess.User(c.Value)
return u
}
func newID() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = encodeJSON(w, v)
}
func writeErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// nowMs is a small helper so tests can stub it later.
func nowMs() int64 { return time.Now().UnixMilli() }