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 }