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.
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user