diff --git a/control-plane/internal/api/environments.go b/control-plane/internal/api/environments.go new file mode 100644 index 0000000..9a54996 --- /dev/null +++ b/control-plane/internal/api/environments.go @@ -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) + } +} diff --git a/control-plane/internal/api/routes.go b/control-plane/internal/api/routes.go new file mode 100644 index 0000000..f049148 --- /dev/null +++ b/control-plane/internal/api/routes.go @@ -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}) +} diff --git a/control-plane/internal/api/sandboxes.go b/control-plane/internal/api/sandboxes.go new file mode 100644 index 0000000..2687452 --- /dev/null +++ b/control-plane/internal/api/sandboxes.go @@ -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}) +} diff --git a/control-plane/internal/api/templates.go b/control-plane/internal/api/templates.go new file mode 100644 index 0000000..8b8ad62 --- /dev/null +++ b/control-plane/internal/api/templates.go @@ -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) + } +} diff --git a/control-plane/internal/store/environments.go b/control-plane/internal/store/environments.go new file mode 100644 index 0000000..e345a92 --- /dev/null +++ b/control-plane/internal/store/environments.go @@ -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 +} diff --git a/control-plane/internal/store/routes.go b/control-plane/internal/store/routes.go new file mode 100644 index 0000000..88ad0aa --- /dev/null +++ b/control-plane/internal/store/routes.go @@ -0,0 +1,82 @@ +package store + +import ( + "database/sql" +) + +// Route is one "_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 +} diff --git a/control-plane/internal/store/sandboxes.go b/control-plane/internal/store/sandboxes.go new file mode 100644 index 0000000..ff17847 --- /dev/null +++ b/control-plane/internal/store/sandboxes.go @@ -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 +} diff --git a/control-plane/internal/store/templates.go b/control-plane/internal/store/templates.go new file mode 100644 index 0000000..f45d5d3 --- /dev/null +++ b/control-plane/internal/store/templates.go @@ -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 +} +