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:
Achmad
2026-06-24 03:59:02 +00:00
parent 55d7705c63
commit a7df9ffc6c
8 changed files with 1180 additions and 0 deletions
+108
View File
@@ -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)
}
}
+59
View File
@@ -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})
}
+257
View File
@@ -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})
}
+105
View File
@@ -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
}
+82
View File
@@ -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
}
+234
View File
@@ -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
}
+222
View File
@@ -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
}