Files
bri-sandbox-development-pla…/control-plane/internal/api/sandboxes.go
T
Achmad a7df9ffc6c Slice 2: sandbox, template, environment, route CRUD
- store: add tables and CRUD for sandboxes (with services), templates
  (with services, clone-into-sandbox), environments (named key/value
  sets), and routes (per-sandbox <service>_url overrides).
- api: split into one file per resource. handleSandboxes/handleSandboxByID
  covers CRUD + 'clone from template' + 'deploy one service in a sandbox'
  (which merges the sandbox's env into the request, picks the port,
  and dispatches the deploy frame to the right node).
  handleTemplates/handleTemplateByID, handleEnvironments/handleEnvironmentByID,
  handlePushRoutes cover the rest. The control plane's repo->node
  resolution still lives in resolveNode (api-gateway -> gateway,
  everything else -> micro).
2026-06-24 03:59:02 +00:00

258 lines
7.0 KiB
Go

package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/sdp/control-plane/internal/store"
)
type sandboxReq struct {
Name string `json:"name"`
GatewayBranch string `json:"gatewayBranch"`
GatewayEnvID string `json:"gatewayEnvId"`
GatewayHostPort int `json:"gatewayHostPort"`
Services []store.SandboxService `json:"services"`
}
type cloneReq struct {
TemplateID string `json:"templateId"`
Name string `json:"name"`
}
func (s *Server) handleSandboxes(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
sbs, err := s.st.ListSandboxes()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
if sbs == nil {
sbs = []store.Sandbox{}
}
writeJSON(w, http.StatusOK, sbs)
case http.MethodPost:
var body sandboxReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if body.Name == "" {
writeErr(w, http.StatusBadRequest, "name required")
return
}
sb := store.Sandbox{
ID: newID(),
Name: body.Name,
GatewayBranch: body.GatewayBranch,
GatewayEnvID: body.GatewayEnvID,
GatewayHostPort: body.GatewayHostPort,
Services: body.Services,
}
if err := s.st.CreateSandbox(sb); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, sb)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleSandboxByID(w http.ResponseWriter, r *http.Request) {
rest := strings.TrimPrefix(r.URL.Path, "/api/sandboxes/")
parts := strings.Split(rest, "/")
id := parts[0]
if id == "" {
http.Error(w, "not found", http.StatusNotFound)
return
}
// /api/sandboxes/{id}/deploy/{repo}
if len(parts) == 3 && parts[1] == "deploy" {
s.handleSandboxDeploy(w, r, id, parts[2])
return
}
if len(parts) != 1 {
http.Error(w, "not found", http.StatusNotFound)
return
}
switch r.Method {
case http.MethodGet:
sb, err := s.st.GetSandbox(id)
if err == store.ErrNotFound {
http.Error(w, "not found", http.StatusNotFound)
return
}
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
// include most recent deploy per service
for i := range sb.Services {
d, _ := s.st.LatestDeploymentBySandboxService(sb.ID, sb.Services[i].Repo)
if d != nil {
sb.Services[i].Branch = d.Branch
}
}
writeJSON(w, http.StatusOK, sb)
case http.MethodPut:
var body sandboxReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
sb := store.Sandbox{
ID: id,
Name: body.Name,
GatewayBranch: body.GatewayBranch,
GatewayEnvID: body.GatewayEnvID,
GatewayHostPort: body.GatewayHostPort,
Services: body.Services,
}
if err := s.st.UpdateSandbox(sb); err == store.ErrNotFound {
http.Error(w, "not found", http.StatusNotFound)
return
} else if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, sb)
case http.MethodDelete:
if err := s.st.DeleteSandbox(id); err == store.ErrNotFound {
http.Error(w, "not found", http.StatusNotFound)
return
} else if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleCloneSandbox(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body cloneReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if body.TemplateID == "" || body.Name == "" {
writeErr(w, http.StatusBadRequest, "templateId and name required")
return
}
sb := store.Sandbox{ID: newID(), Name: body.Name}
if err := s.st.CloneTemplateIntoSandbox(body.TemplateID, sb); err == store.ErrNotFound {
http.Error(w, "template not found", http.StatusNotFound)
return
} else if err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, sb)
}
type sandboxDeployReq struct {
Branch string `json:"branch"`
Env map[string]string `json:"env,omitempty"`
EnvID string `json:"envId,omitempty"`
Username string `json:"username"`
Password string `json:"password"`
}
// handleSandboxDeploy deploys one service inside a sandbox. The
// sandbox's host_port is used unless the request overrides it. The
// sandbox's env is merged with the request's env.
func (s *Server) handleSandboxDeploy(w http.ResponseWriter, r *http.Request, sandboxID, repo string) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body sandboxDeployReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if body.Branch == "" {
writeErr(w, http.StatusBadRequest, "branch required")
return
}
sb, err := s.st.GetSandbox(sandboxID)
if err == store.ErrNotFound {
http.Error(w, "sandbox not found", http.StatusNotFound)
return
}
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
var svc *store.SandboxService
for i := range sb.Services {
if sb.Services[i].Repo == repo {
svc = &sb.Services[i]
break
}
}
if svc == nil {
writeErr(w, http.StatusBadRequest, "repo not in sandbox")
return
}
env := body.Env
if env == nil {
env = map[string]string{}
}
if svc.EnvID != "" {
if e, err := s.st.GetEnvironment(svc.EnvID); err == nil {
for k, v := range e.Values {
env[k] = v
}
}
}
if body.EnvID != "" {
if e, err := s.st.GetEnvironment(body.EnvID); err == nil {
for k, v := range e.Values {
env[k] = v
}
}
}
node, _ := s.resolveNode(repo, sandboxID)
s.agents.mu.RLock()
connected := s.agents.conns[node]
s.agents.mu.RUnlock()
if !connected {
writeErr(w, http.StatusServiceUnavailable, "agent "+node+" not connected")
return
}
user := s.userFromContext(r)
id := newID()
if err := s.st.StartDeploymentInSandbox(id, sandboxID, repo, body.Branch, user, svc.HostPort); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
req := map[string]any{
"deploymentId": id,
"sandboxId": sandboxID,
"repository": repo,
"branch": body.Branch,
"hostPort": svc.HostPort,
"env": env,
"username": body.Username,
"password": body.Password,
}
payload, _ := json.Marshal(map[string]any{"op": "deploy", "data": req})
if !s.hub.SendToAgent(node, payload) {
writeErr(w, http.StatusServiceUnavailable, "agent buffer full")
return
}
writeJSON(w, http.StatusOK, map[string]any{"id": id})
}