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).
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/sdp/control-plane/internal/store"
|
||||
)
|
||||
|
||||
type envReq struct {
|
||||
Name string `json:"name"`
|
||||
Values map[string]string `json:"values"`
|
||||
}
|
||||
|
||||
func (s *Server) handleEnvironments(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
envs, err := s.st.ListEnvironments()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if envs == nil {
|
||||
envs = []store.Environment{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, envs)
|
||||
case http.MethodPost:
|
||||
var body envReq
|
||||
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
|
||||
}
|
||||
if body.Values == nil {
|
||||
body.Values = map[string]string{}
|
||||
}
|
||||
e := store.Environment{ID: newID(), Name: body.Name, Values: body.Values}
|
||||
if err := s.st.CreateEnvironment(e); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, e)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleEnvironmentByID(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/environments/")
|
||||
if id == "" || strings.Contains(id, "/") {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
e, err := s.st.GetEnvironment(id)
|
||||
if err == store.ErrNotFound {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, e)
|
||||
case http.MethodPut:
|
||||
var body envReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
e, err := s.st.GetEnvironment(id)
|
||||
if err == store.ErrNotFound {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if body.Name != "" {
|
||||
e.Name = body.Name
|
||||
}
|
||||
if body.Values != nil {
|
||||
e.Values = body.Values
|
||||
}
|
||||
if err := s.st.UpdateEnvironment(*e); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, e)
|
||||
case http.MethodDelete:
|
||||
if err := s.st.DeleteEnvironment(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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/sdp/control-plane/internal/store"
|
||||
"github.com/sdp/protocol"
|
||||
)
|
||||
|
||||
// handlePushRoutes rewrites the gateway's config.php by sending a
|
||||
// push_routes RPC to the gateway agent. The body lists the routes that
|
||||
// should be in effect for the given sandbox; rows with targetOcp=true
|
||||
// are restored from the snapshot the agent keeps.
|
||||
type pushRoutesReq struct {
|
||||
SandboxID string `json:"sandboxId"`
|
||||
Routes []store.Route `json:"routes"`
|
||||
}
|
||||
|
||||
func (s *Server) handlePushRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body pushRoutesReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.SandboxID == "" {
|
||||
writeErr(w, http.StatusBadRequest, "sandboxId required")
|
||||
return
|
||||
}
|
||||
if _, err := s.st.GetSandbox(body.SandboxID); err == store.ErrNotFound {
|
||||
http.Error(w, "sandbox not found", http.StatusNotFound)
|
||||
return
|
||||
} else if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.st.SetRoutes(body.SandboxID, body.Routes); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
overrides := make([]protocol.RouteOverride, 0, len(body.Routes))
|
||||
for _, r := range body.Routes {
|
||||
overrides = append(overrides, protocol.RouteOverride{
|
||||
Key: r.Key,
|
||||
Value: r.Value,
|
||||
TargetOCP: r.TargetOCP,
|
||||
})
|
||||
}
|
||||
if err := s.callAgentPushRoutes(context.Background(), body.SandboxID, overrides); err != nil {
|
||||
writeErr(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
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})
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/sdp/control-plane/internal/store"
|
||||
)
|
||||
|
||||
type templateReq struct {
|
||||
Name string `json:"name"`
|
||||
GatewayBranch string `json:"gatewayBranch"`
|
||||
Services []store.TemplateService `json:"services"`
|
||||
}
|
||||
|
||||
func (s *Server) handleTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
ts, err := s.st.ListTemplates()
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if ts == nil {
|
||||
ts = []store.Template{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ts)
|
||||
case http.MethodPost:
|
||||
var body templateReq
|
||||
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
|
||||
}
|
||||
t := store.Template{
|
||||
ID: newID(),
|
||||
Name: body.Name,
|
||||
GatewayBranch: body.GatewayBranch,
|
||||
Services: body.Services,
|
||||
}
|
||||
if err := s.st.CreateTemplate(t); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleTemplateByID(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/templates/")
|
||||
if id == "" || strings.Contains(id, "/") {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
t, err := s.st.GetTemplate(id)
|
||||
if err == store.ErrNotFound {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
case http.MethodPut:
|
||||
var body templateReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
t := store.Template{
|
||||
ID: id,
|
||||
Name: body.Name,
|
||||
GatewayBranch: body.GatewayBranch,
|
||||
Services: body.Services,
|
||||
}
|
||||
if err := s.st.UpdateTemplate(t); 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, t)
|
||||
case http.MethodDelete:
|
||||
if err := s.st.DeleteTemplate(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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user