55d7705c63
- 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}.
153 lines
4.8 KiB
Go
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() }
|