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

235 lines
6.2 KiB
Go

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
}