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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Environment is a named set of env vars that can be attached to a
|
||||||
|
// deploy, a sandbox service, or a sandbox's gateway.
|
||||||
|
type Environment struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Values map[string]string `json:"values"`
|
||||||
|
CreatedAt int64 `json:"createdAt"`
|
||||||
|
UpdatedAt int64 `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateEnvironment persists a new env. Caller assigns id and timestamps.
|
||||||
|
func (s *Store) CreateEnvironment(e Environment) error {
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
if e.CreatedAt == 0 {
|
||||||
|
e.CreatedAt = now
|
||||||
|
}
|
||||||
|
e.UpdatedAt = now
|
||||||
|
json, err := marshalJSON(e.Values)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.db.Exec(
|
||||||
|
`INSERT INTO environments(id, name, values_json, created_at, updated_at) VALUES(?,?,?,?,?)`,
|
||||||
|
e.ID, e.Name, json, e.CreatedAt, e.UpdatedAt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateEnvironment replaces name + values. ErrNotFound if missing.
|
||||||
|
func (s *Store) UpdateEnvironment(e Environment) error {
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
e.UpdatedAt = now
|
||||||
|
json, err := marshalJSON(e.Values)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, err := s.db.Exec(
|
||||||
|
`UPDATE environments SET name=?, values_json=?, updated_at=? WHERE id=?`,
|
||||||
|
e.Name, json, e.UpdatedAt, e.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEnvironment returns one env. ErrNotFound if missing.
|
||||||
|
func (s *Store) GetEnvironment(id string) (*Environment, error) {
|
||||||
|
row := s.db.QueryRow(
|
||||||
|
`SELECT id, name, values_json, created_at, updated_at FROM environments WHERE id=?`, id)
|
||||||
|
var e Environment
|
||||||
|
var raw string
|
||||||
|
err := row.Scan(&e.ID, &e.Name, &raw, &e.CreatedAt, &e.UpdatedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
v, err := unmarshalJSON(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.Values = v
|
||||||
|
return &e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListEnvironments returns all envs, newest first.
|
||||||
|
func (s *Store) ListEnvironments() ([]Environment, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id, name, values_json, created_at, updated_at FROM environments ORDER BY created_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Environment
|
||||||
|
for rows.Next() {
|
||||||
|
var e Environment
|
||||||
|
var raw string
|
||||||
|
if err := rows.Scan(&e.ID, &e.Name, &raw, &e.CreatedAt, &e.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
v, err := unmarshalJSON(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.Values = v
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteEnvironment removes one env. ErrNotFound if missing.
|
||||||
|
func (s *Store) DeleteEnvironment(id string) error {
|
||||||
|
res, err := s.db.Exec(`DELETE FROM environments WHERE id=?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Route is one "<service>_url" line attached to a sandbox. The agent
|
||||||
|
// pushes the whole set to the gateway by replacing any matching lines
|
||||||
|
// in the gateway's config.php.
|
||||||
|
type Route struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SandboxID string `json:"sandboxId"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
TargetOCP bool `json:"targetOcp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRoutes atomically replaces the routing table for a sandbox. The
|
||||||
|
// gateway agent's "push" frame is the whole set.
|
||||||
|
func (s *Store) SetRoutes(sandboxID string, routes []Route) error {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.Exec(`DELETE FROM routes WHERE sandbox_id=?`, sandboxID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range routes {
|
||||||
|
r := routes[i]
|
||||||
|
r.SandboxID = sandboxID
|
||||||
|
if r.ID == "" {
|
||||||
|
r.ID = sandboxID + "-" + r.Key
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO routes(id, sandbox_id, key, value, target_ocp) VALUES(?,?,?,?,?)`,
|
||||||
|
r.ID, r.SandboxID, r.Key, r.Value, boolInt(r.TargetOCP)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRoutes returns the routes for one sandbox.
|
||||||
|
func (s *Store) ListRoutes(sandboxID string) ([]Route, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id, sandbox_id, key, COALESCE(value,''), target_ocp
|
||||||
|
FROM routes WHERE sandbox_id=? ORDER BY key`, sandboxID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Route
|
||||||
|
for rows.Next() {
|
||||||
|
var r Route
|
||||||
|
var targetOCP int
|
||||||
|
if err := rows.Scan(&r.ID, &r.SandboxID, &r.Key, &r.Value, &targetOCP); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.TargetOCP = targetOCP == 1
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRoute returns one route by sandbox+key, or ErrNotFound.
|
||||||
|
func (s *Store) GetRoute(sandboxID, key string) (*Route, error) {
|
||||||
|
row := s.db.QueryRow(
|
||||||
|
`SELECT id, sandbox_id, key, COALESCE(value,''), target_ocp
|
||||||
|
FROM routes WHERE sandbox_id=? AND key=?`, sandboxID, key)
|
||||||
|
var r Route
|
||||||
|
var targetOCP int
|
||||||
|
err := row.Scan(&r.ID, &r.SandboxID, &r.Key, &r.Value, &targetOCP)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.TargetOCP = targetOCP == 1
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sandbox is a named deployment context. It owns a single gateway branch
|
||||||
|
// and a set of microservice branches, each of which can be marked
|
||||||
|
// "use_ocp" (i.e. leave the routing alone) or pointed at a local
|
||||||
|
// stand-in.
|
||||||
|
type Sandbox struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
GatewayBranch string `json:"gatewayBranch,omitempty"`
|
||||||
|
GatewayEnvID string `json:"gatewayEnvId,omitempty"`
|
||||||
|
GatewayHostPort int `json:"gatewayHostPort,omitempty"`
|
||||||
|
CreatedAt int64 `json:"createdAt"`
|
||||||
|
UpdatedAt int64 `json:"updatedAt"`
|
||||||
|
Services []SandboxService `json:"services"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SandboxService is one microservice entry inside a sandbox.
|
||||||
|
type SandboxService struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SandboxID string `json:"sandboxId"`
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
Branch string `json:"branch,omitempty"`
|
||||||
|
EnvID string `json:"envId,omitempty"`
|
||||||
|
HostPort int `json:"hostPort,omitempty"`
|
||||||
|
UseOCP bool `json:"useOcp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("not found")
|
||||||
|
|
||||||
|
// CreateSandbox persists a sandbox and its services. The caller assigns
|
||||||
|
// IDs; this function does not generate them. The whole insert is in one
|
||||||
|
// transaction.
|
||||||
|
func (s *Store) CreateSandbox(sb Sandbox) error {
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
if sb.CreatedAt == 0 {
|
||||||
|
sb.CreatedAt = now
|
||||||
|
}
|
||||||
|
sb.UpdatedAt = now
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO sandboxes(id, name, gateway_branch, gateway_env_id, gateway_host_port, created_at, updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?)`,
|
||||||
|
sb.ID, sb.Name, sb.GatewayBranch, nullString(sb.GatewayEnvID), nullInt(sb.GatewayHostPort),
|
||||||
|
sb.CreatedAt, sb.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range sb.Services {
|
||||||
|
svc := &sb.Services[i]
|
||||||
|
if svc.ID == "" {
|
||||||
|
svc.ID = sb.ID + "-" + svc.Repo
|
||||||
|
svc.SandboxID = sb.ID
|
||||||
|
}
|
||||||
|
if err := insertSandboxService(tx, *svc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSandbox replaces the sandbox's mutable fields. Services are
|
||||||
|
// replaced wholesale — the caller passes the full desired list.
|
||||||
|
func (s *Store) UpdateSandbox(sb Sandbox) error {
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
sb.UpdatedAt = now
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
res, err := tx.Exec(
|
||||||
|
`UPDATE sandboxes SET name=?, gateway_branch=?, gateway_env_id=?, gateway_host_port=?, updated_at=? WHERE id=?`,
|
||||||
|
sb.Name, sb.GatewayBranch, nullString(sb.GatewayEnvID), nullInt(sb.GatewayHostPort),
|
||||||
|
sb.UpdatedAt, sb.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`DELETE FROM sandbox_services WHERE sandbox_id=?`, sb.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range sb.Services {
|
||||||
|
svc := &sb.Services[i]
|
||||||
|
svc.SandboxID = sb.ID
|
||||||
|
if svc.ID == "" {
|
||||||
|
svc.ID = sb.ID + "-" + svc.Repo
|
||||||
|
}
|
||||||
|
if err := insertSandboxService(tx, *svc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSandbox returns one sandbox with its services. ErrNotFound if missing.
|
||||||
|
func (s *Store) GetSandbox(id string) (*Sandbox, error) {
|
||||||
|
row := s.db.QueryRow(
|
||||||
|
`SELECT id, name, COALESCE(gateway_branch,''), COALESCE(gateway_env_id,''),
|
||||||
|
COALESCE(gateway_host_port,0), created_at, updated_at
|
||||||
|
FROM sandboxes WHERE id=?`, id)
|
||||||
|
var sb Sandbox
|
||||||
|
err := row.Scan(&sb.ID, &sb.Name, &sb.GatewayBranch, &sb.GatewayEnvID,
|
||||||
|
&sb.GatewayHostPort, &sb.CreatedAt, &sb.UpdatedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.loadSandboxServices(&sb); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &sb, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSandboxes returns all sandboxes, newest first.
|
||||||
|
func (s *Store) ListSandboxes() ([]Sandbox, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id, name, COALESCE(gateway_branch,''), COALESCE(gateway_env_id,''),
|
||||||
|
COALESCE(gateway_host_port,0), created_at, updated_at
|
||||||
|
FROM sandboxes ORDER BY created_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Sandbox
|
||||||
|
for rows.Next() {
|
||||||
|
var sb Sandbox
|
||||||
|
if err := rows.Scan(&sb.ID, &sb.Name, &sb.GatewayBranch, &sb.GatewayEnvID,
|
||||||
|
&sb.GatewayHostPort, &sb.CreatedAt, &sb.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, sb)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range out {
|
||||||
|
if err := s.loadSandboxServices(&out[i]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSandbox removes the sandbox row and its services. Active
|
||||||
|
// deployments are NOT stopped — the caller is responsible for that.
|
||||||
|
func (s *Store) DeleteSandbox(id string) error {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.Exec(`DELETE FROM sandbox_services WHERE sandbox_id=?`, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, err := tx.Exec(`DELETE FROM sandboxes WHERE id=?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) loadSandboxServices(sb *Sandbox) error {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id, sandbox_id, repo, COALESCE(branch,''), COALESCE(env_id,''),
|
||||||
|
COALESCE(host_port,0), COALESCE(use_ocp,0)
|
||||||
|
FROM sandbox_services WHERE sandbox_id=? ORDER BY repo`, sb.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var svc SandboxService
|
||||||
|
var useOCP int
|
||||||
|
if err := rows.Scan(&svc.ID, &svc.SandboxID, &svc.Repo, &svc.Branch,
|
||||||
|
&svc.EnvID, &svc.HostPort, &useOCP); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
svc.UseOCP = useOCP == 1
|
||||||
|
sb.Services = append(sb.Services, svc)
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertSandboxService(tx *sql.Tx, svc SandboxService) error {
|
||||||
|
_, err := tx.Exec(
|
||||||
|
`INSERT INTO sandbox_services(id, sandbox_id, repo, branch, env_id, host_port, use_ocp)
|
||||||
|
VALUES(?,?,?,?,?,?,?)`,
|
||||||
|
svc.ID, svc.SandboxID, svc.Repo, svc.Branch, nullString(svc.EnvID),
|
||||||
|
nullInt(svc.HostPort), boolInt(svc.UseOCP),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullString(s string) interface{} {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullInt(n int) interface{} {
|
||||||
|
if n == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Template is a reusable sandbox configuration. Cloning a template into
|
||||||
|
// a sandbox materializes a sandbox row + sandbox_services from the
|
||||||
|
// template rows.
|
||||||
|
type Template struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
GatewayBranch string `json:"gatewayBranch,omitempty"`
|
||||||
|
CreatedAt int64 `json:"createdAt"`
|
||||||
|
UpdatedAt int64 `json:"updatedAt"`
|
||||||
|
Services []TemplateService `json:"services"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateService is one microservice entry in a template.
|
||||||
|
type TemplateService struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TemplateID string `json:"templateId"`
|
||||||
|
Repo string `json:"repo"`
|
||||||
|
Branch string `json:"branch,omitempty"`
|
||||||
|
EnvID string `json:"envId,omitempty"`
|
||||||
|
HostPort int `json:"hostPort,omitempty"`
|
||||||
|
UseOCP bool `json:"useOcp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTemplate persists a template and its services. Caller assigns IDs.
|
||||||
|
func (s *Store) CreateTemplate(t Template) error {
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
if t.CreatedAt == 0 {
|
||||||
|
t.CreatedAt = now
|
||||||
|
}
|
||||||
|
t.UpdatedAt = now
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
`INSERT INTO templates(id, name, gateway_branch, created_at, updated_at)
|
||||||
|
VALUES(?,?,?,?,?)`,
|
||||||
|
t.ID, t.Name, t.GatewayBranch, t.CreatedAt, t.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range t.Services {
|
||||||
|
svc := &t.Services[i]
|
||||||
|
if svc.ID == "" {
|
||||||
|
svc.ID = t.ID + "-" + svc.Repo
|
||||||
|
svc.TemplateID = t.ID
|
||||||
|
}
|
||||||
|
if err := insertTemplateService(tx, *svc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTemplate replaces the template's mutable fields. Services are
|
||||||
|
// replaced wholesale.
|
||||||
|
func (s *Store) UpdateTemplate(t Template) error {
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
t.UpdatedAt = now
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
res, err := tx.Exec(
|
||||||
|
`UPDATE templates SET name=?, gateway_branch=?, updated_at=? WHERE id=?`,
|
||||||
|
t.Name, t.GatewayBranch, t.UpdatedAt, t.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`DELETE FROM template_services WHERE template_id=?`, t.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range t.Services {
|
||||||
|
svc := &t.Services[i]
|
||||||
|
svc.TemplateID = t.ID
|
||||||
|
if svc.ID == "" {
|
||||||
|
svc.ID = t.ID + "-" + svc.Repo
|
||||||
|
}
|
||||||
|
if err := insertTemplateService(tx, *svc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTemplate returns one template with its services. ErrNotFound if missing.
|
||||||
|
func (s *Store) GetTemplate(id string) (*Template, error) {
|
||||||
|
row := s.db.QueryRow(
|
||||||
|
`SELECT id, name, COALESCE(gateway_branch,''), created_at, updated_at
|
||||||
|
FROM templates WHERE id=?`, id)
|
||||||
|
var t Template
|
||||||
|
err := row.Scan(&t.ID, &t.Name, &t.GatewayBranch, &t.CreatedAt, &t.UpdatedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.loadTemplateServices(&t); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTemplates returns all templates, newest first.
|
||||||
|
func (s *Store) ListTemplates() ([]Template, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id, name, COALESCE(gateway_branch,''), created_at, updated_at
|
||||||
|
FROM templates ORDER BY created_at DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Template
|
||||||
|
for rows.Next() {
|
||||||
|
var t Template
|
||||||
|
if err := rows.Scan(&t.ID, &t.Name, &t.GatewayBranch, &t.CreatedAt, &t.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for i := range out {
|
||||||
|
if err := s.loadTemplateServices(&out[i]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTemplate removes the template and its services. ErrNotFound if missing.
|
||||||
|
func (s *Store) DeleteTemplate(id string) error {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.Exec(`DELETE FROM template_services WHERE template_id=?`, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, err := tx.Exec(`DELETE FROM templates WHERE id=?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloneTemplateIntoSandbox materializes a sandbox from a template. The
|
||||||
|
// caller assigns the new sandbox id and name. The gateway_branch and
|
||||||
|
// each service are copied; hostPort is preserved as-is (the caller may
|
||||||
|
// then update it to claim a fresh port via SetSandboxServicePort).
|
||||||
|
func (s *Store) CloneTemplateIntoSandbox(templateID string, sb Sandbox) error {
|
||||||
|
t, err := s.GetTemplate(templateID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sb.GatewayBranch = t.GatewayBranch
|
||||||
|
sb.Services = make([]SandboxService, 0, len(t.Services))
|
||||||
|
for _, ts := range t.Services {
|
||||||
|
sb.Services = append(sb.Services, SandboxService{
|
||||||
|
SandboxID: sb.ID,
|
||||||
|
Repo: ts.Repo,
|
||||||
|
Branch: ts.Branch,
|
||||||
|
EnvID: ts.EnvID,
|
||||||
|
HostPort: ts.HostPort,
|
||||||
|
UseOCP: ts.UseOCP,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return s.CreateSandbox(sb)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) loadTemplateServices(t *Template) error {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id, template_id, repo, COALESCE(branch,''), COALESCE(env_id,''),
|
||||||
|
COALESCE(host_port,0), COALESCE(use_ocp,0)
|
||||||
|
FROM template_services WHERE template_id=? ORDER BY repo`, t.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var svc TemplateService
|
||||||
|
var useOCP int
|
||||||
|
if err := rows.Scan(&svc.ID, &svc.TemplateID, &svc.Repo, &svc.Branch,
|
||||||
|
&svc.EnvID, &svc.HostPort, &useOCP); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
svc.UseOCP = useOCP == 1
|
||||||
|
t.Services = append(t.Services, svc)
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertTemplateService(tx *sql.Tx, svc TemplateService) error {
|
||||||
|
_, err := tx.Exec(
|
||||||
|
`INSERT INTO template_services(id, template_id, repo, branch, env_id, host_port, use_ocp)
|
||||||
|
VALUES(?,?,?,?,?,?,?)`,
|
||||||
|
svc.ID, svc.TemplateID, svc.Repo, svc.Branch, nullString(svc.EnvID),
|
||||||
|
nullInt(svc.HostPort), boolInt(svc.UseOCP),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user