Files
bri-sandbox-development-pla…/control-plane/internal/api/api.go
T
Achmad Setyabudi Susilo 3d99940658 Initial SDP skeleton
Sandbox Deployment Platform — Go control plane + agents, NextJS dashboard,
nginx reverse proxy. Cross-compile via Docker; deploy via sshpass to
172.18.136.92 (micro) and 172.18.139.186 (gateway).

- control-plane: HTTP API, WS hub, SQLite (modernc.org/sqlite) for
  progress, .log files for log persistence
- agent-micro / agent-gateway: alpine:3.20 + bind-mounted repo,
  binary exec'd in container, no Dockerfile build step
- dashboard: NextJS static export + shadcn/ui components, single
  WebSocket hook
- docker-compose.yml: three services on alpine:latest with docker
  socket bind for agents
- scripts/: build.sh (golang:1.23-alpine cross-compile), deploy.sh,
  patch-nginx.sh (idempotent nginx splice), ssh wrappers

Runtime model: pass-through Bitbucket creds per deploy, never logged or
persisted on the agent. Control plane never touches git or docker
directly — agents do all the work locally.
2026-06-24 07:25:01 +07:00

257 lines
7.5 KiB
Go

// 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)
}