Extract shared service layer for sandbox, API key, and template operations

Moves business logic from API handlers into internal/service/ so that
both the REST API and the upcoming dashboard can share the same operations
without duplicating code. API handlers now delegate to the service layer
and only handle HTTP-specific concerns (request parsing, response formatting).
This commit is contained in:
2026-03-16 04:13:11 +06:00
parent 931b7d54b3
commit f38d5812d1
8 changed files with 389 additions and 239 deletions

View File

@ -8,15 +8,15 @@ import (
"git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id" "git.omukk.dev/wrenn/sandbox/internal/service"
) )
type apiKeyHandler struct { type apiKeyHandler struct {
db *db.Queries svc *service.APIKeyService
} }
func newAPIKeyHandler(db *db.Queries) *apiKeyHandler { func newAPIKeyHandler(svc *service.APIKeyService) *apiKeyHandler {
return &apiKeyHandler{db: db} return &apiKeyHandler{svc: svc}
} }
type createAPIKeyRequest struct { type createAPIKeyRequest struct {
@ -60,32 +60,14 @@ func (h *apiKeyHandler) Create(w http.ResponseWriter, r *http.Request) {
return return
} }
if req.Name == "" { result, err := h.svc.Create(r.Context(), ac.TeamID, ac.UserID, req.Name)
req.Name = "Unnamed API Key"
}
plaintext, hash, err := auth.GenerateAPIKey()
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate API key") writeError(w, http.StatusInternalServerError, "internal_error", "failed to create API key")
return return
} }
keyID := id.NewAPIKeyID() resp := apiKeyToResponse(result.Row)
row, err := h.db.InsertAPIKey(r.Context(), db.InsertAPIKeyParams{ resp.Key = &result.Plaintext
ID: keyID,
TeamID: ac.TeamID,
Name: req.Name,
KeyHash: hash,
KeyPrefix: auth.APIKeyPrefix(plaintext),
CreatedBy: ac.UserID,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to create API key")
return
}
resp := apiKeyToResponse(row)
resp.Key = &plaintext
writeJSON(w, http.StatusCreated, resp) writeJSON(w, http.StatusCreated, resp)
} }
@ -94,7 +76,7 @@ func (h *apiKeyHandler) Create(w http.ResponseWriter, r *http.Request) {
func (h *apiKeyHandler) List(w http.ResponseWriter, r *http.Request) { func (h *apiKeyHandler) List(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
keys, err := h.db.ListAPIKeysByTeam(r.Context(), ac.TeamID) keys, err := h.svc.List(r.Context(), ac.TeamID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list API keys") writeError(w, http.StatusInternalServerError, "db_error", "failed to list API keys")
return return
@ -113,10 +95,7 @@ func (h *apiKeyHandler) Delete(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
keyID := chi.URLParam(r, "id") keyID := chi.URLParam(r, "id")
if err := h.db.DeleteAPIKey(r.Context(), db.DeleteAPIKeyParams{ if err := h.svc.Delete(r.Context(), keyID, ac.TeamID); err != nil {
ID: keyID,
TeamID: ac.TeamID,
}); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete API key") writeError(w, http.StatusInternalServerError, "db_error", "failed to delete API key")
return return
} }

View File

@ -2,30 +2,22 @@ package api
import ( import (
"encoding/json" "encoding/json"
"fmt"
"log/slog"
"net/http" "net/http"
"time" "time"
"connectrpc.com/connect"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id" "git.omukk.dev/wrenn/sandbox/internal/service"
"git.omukk.dev/wrenn/sandbox/internal/validate"
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
"git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect"
) )
type sandboxHandler struct { type sandboxHandler struct {
db *db.Queries svc *service.SandboxService
agent hostagentv1connect.HostAgentServiceClient
} }
func newSandboxHandler(db *db.Queries, agent hostagentv1connect.HostAgentServiceClient) *sandboxHandler { func newSandboxHandler(svc *service.SandboxService) *sandboxHandler {
return &sandboxHandler{db: db, agent: agent} return &sandboxHandler{svc: svc}
} }
type createSandboxRequest struct { type createSandboxRequest struct {
@ -86,95 +78,28 @@ func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) {
return return
} }
if req.Template == "" { ac := auth.MustFromContext(r.Context())
req.Template = "minimal"
}
if err := validate.SafeName(req.Template); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", fmt.Sprintf("invalid template name: %s", err))
return
}
if req.VCPUs <= 0 {
req.VCPUs = 1
}
if req.MemoryMB <= 0 {
req.MemoryMB = 512
}
// timeout_sec = 0 means no auto-pause; only set if explicitly requested.
ctx := r.Context() sb, err := h.svc.Create(r.Context(), service.SandboxCreateParams{
ac := auth.MustFromContext(ctx)
// If the template is a snapshot, use its baked-in vcpus/memory
// (they cannot be changed since the VM state is frozen).
if tmpl, err := h.db.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: req.Template, TeamID: ac.TeamID}); err == nil && tmpl.Type == "snapshot" {
if tmpl.Vcpus.Valid {
req.VCPUs = tmpl.Vcpus.Int32
}
if tmpl.MemoryMb.Valid {
req.MemoryMB = tmpl.MemoryMb.Int32
}
}
sandboxID := id.NewSandboxID()
// Insert pending record.
_, err := h.db.InsertSandbox(ctx, db.InsertSandboxParams{
ID: sandboxID,
TeamID: ac.TeamID, TeamID: ac.TeamID,
HostID: "default",
Template: req.Template, Template: req.Template,
Status: "pending", VCPUs: req.VCPUs,
Vcpus: req.VCPUs, MemoryMB: req.MemoryMB,
MemoryMb: req.MemoryMB,
TimeoutSec: req.TimeoutSec, TimeoutSec: req.TimeoutSec,
}) })
if err != nil { if err != nil {
slog.Error("failed to insert sandbox", "error", err) status, code, msg := serviceErrToHTTP(err)
writeError(w, http.StatusInternalServerError, "db_error", "failed to create sandbox record")
return
}
// Call host agent to create the sandbox.
resp, err := h.agent.CreateSandbox(ctx, connect.NewRequest(&pb.CreateSandboxRequest{
SandboxId: sandboxID,
Template: req.Template,
Vcpus: req.VCPUs,
MemoryMb: req.MemoryMB,
TimeoutSec: req.TimeoutSec,
}))
if err != nil {
if _, dbErr := h.db.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{
ID: sandboxID, Status: "error",
}); dbErr != nil {
slog.Warn("failed to update sandbox status to error", "id", sandboxID, "error", dbErr)
}
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg) writeError(w, status, code, msg)
return return
} }
// Update to running.
now := time.Now()
sb, err := h.db.UpdateSandboxRunning(ctx, db.UpdateSandboxRunningParams{
ID: sandboxID,
HostIp: resp.Msg.HostIp,
GuestIp: "",
StartedAt: pgtype.Timestamptz{
Time: now,
Valid: true,
},
})
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to update sandbox status")
return
}
writeJSON(w, http.StatusCreated, sandboxToResponse(sb)) writeJSON(w, http.StatusCreated, sandboxToResponse(sb))
} }
// List handles GET /v1/sandboxes. // List handles GET /v1/sandboxes.
func (h *sandboxHandler) List(w http.ResponseWriter, r *http.Request) { func (h *sandboxHandler) List(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
sandboxes, err := h.db.ListSandboxesByTeam(r.Context(), ac.TeamID) sandboxes, err := h.svc.List(r.Context(), ac.TeamID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list sandboxes") writeError(w, http.StatusInternalServerError, "db_error", "failed to list sandboxes")
return return
@ -193,7 +118,7 @@ func (h *sandboxHandler) Get(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxID := chi.URLParam(r, "id")
ac := auth.MustFromContext(r.Context()) ac := auth.MustFromContext(r.Context())
sb, err := h.db.GetSandboxByTeam(r.Context(), db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) sb, err := h.svc.Get(r.Context(), sandboxID, ac.TeamID)
if err != nil { if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found") writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return return
@ -203,149 +128,59 @@ func (h *sandboxHandler) Get(w http.ResponseWriter, r *http.Request) {
} }
// Pause handles POST /v1/sandboxes/{id}/pause. // Pause handles POST /v1/sandboxes/{id}/pause.
// Pause = snapshot + destroy. The sandbox is frozen to disk and all running
// resources are released. It can be resumed later.
func (h *sandboxHandler) Pause(w http.ResponseWriter, r *http.Request) { func (h *sandboxHandler) Pause(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxID := chi.URLParam(r, "id")
ctx := r.Context() ac := auth.MustFromContext(r.Context())
ac := auth.MustFromContext(ctx)
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) sb, err := h.svc.Pause(r.Context(), sandboxID, ac.TeamID)
if err != nil { if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found") status, code, msg := serviceErrToHTTP(err)
return
}
if sb.Status != "running" {
writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running")
return
}
if _, err := h.agent.PauseSandbox(ctx, connect.NewRequest(&pb.PauseSandboxRequest{
SandboxId: sandboxID,
})); err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg) writeError(w, status, code, msg)
return return
} }
sb, err = h.db.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{
ID: sandboxID, Status: "paused",
})
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to update status")
return
}
writeJSON(w, http.StatusOK, sandboxToResponse(sb)) writeJSON(w, http.StatusOK, sandboxToResponse(sb))
} }
// Resume handles POST /v1/sandboxes/{id}/resume. // Resume handles POST /v1/sandboxes/{id}/resume.
// Resume restores a paused sandbox from snapshot using UFFD lazy memory loading.
func (h *sandboxHandler) Resume(w http.ResponseWriter, r *http.Request) { func (h *sandboxHandler) Resume(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxID := chi.URLParam(r, "id")
ctx := r.Context() ac := auth.MustFromContext(r.Context())
ac := auth.MustFromContext(ctx)
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) sb, err := h.svc.Resume(r.Context(), sandboxID, ac.TeamID)
if err != nil { if err != nil {
writeError(w, http.StatusNotFound, "not_found", "sandbox not found") status, code, msg := serviceErrToHTTP(err)
return
}
if sb.Status != "paused" {
writeError(w, http.StatusConflict, "invalid_state", "sandbox is not paused")
return
}
resp, err := h.agent.ResumeSandbox(ctx, connect.NewRequest(&pb.ResumeSandboxRequest{
SandboxId: sandboxID,
TimeoutSec: sb.TimeoutSec,
}))
if err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg) writeError(w, status, code, msg)
return return
} }
now := time.Now()
sb, err = h.db.UpdateSandboxRunning(ctx, db.UpdateSandboxRunningParams{
ID: sandboxID,
HostIp: resp.Msg.HostIp,
GuestIp: "",
StartedAt: pgtype.Timestamptz{
Time: now,
Valid: true,
},
})
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to update status")
return
}
writeJSON(w, http.StatusOK, sandboxToResponse(sb)) writeJSON(w, http.StatusOK, sandboxToResponse(sb))
} }
// Ping handles POST /v1/sandboxes/{id}/ping. // Ping handles POST /v1/sandboxes/{id}/ping.
// Resets the inactivity timer for a running sandbox.
func (h *sandboxHandler) Ping(w http.ResponseWriter, r *http.Request) { func (h *sandboxHandler) Ping(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxID := chi.URLParam(r, "id")
ctx := r.Context() ac := auth.MustFromContext(r.Context())
ac := auth.MustFromContext(ctx)
sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) if err := h.svc.Ping(r.Context(), sandboxID, ac.TeamID); err != nil {
if err != nil { status, code, msg := serviceErrToHTTP(err)
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
return
}
if sb.Status != "running" {
writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running")
return
}
if _, err := h.agent.PingSandbox(ctx, connect.NewRequest(&pb.PingSandboxRequest{
SandboxId: sandboxID,
})); err != nil {
status, code, msg := agentErrToHTTP(err)
writeError(w, status, code, msg) writeError(w, status, code, msg)
return return
} }
if err := h.db.UpdateLastActive(ctx, db.UpdateLastActiveParams{
ID: sandboxID,
LastActiveAt: pgtype.Timestamptz{
Time: time.Now(),
Valid: true,
},
}); err != nil {
slog.Warn("ping: failed to update last_active_at in DB", "sandbox_id", sandboxID, "error", err)
}
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// Destroy handles DELETE /v1/sandboxes/{id}. // Destroy handles DELETE /v1/sandboxes/{id}.
func (h *sandboxHandler) Destroy(w http.ResponseWriter, r *http.Request) { func (h *sandboxHandler) Destroy(w http.ResponseWriter, r *http.Request) {
sandboxID := chi.URLParam(r, "id") sandboxID := chi.URLParam(r, "id")
ctx := r.Context() ac := auth.MustFromContext(r.Context())
ac := auth.MustFromContext(ctx)
_, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) if err := h.svc.Destroy(r.Context(), sandboxID, ac.TeamID); err != nil {
if err != nil { status, code, msg := serviceErrToHTTP(err)
writeError(w, http.StatusNotFound, "not_found", "sandbox not found") writeError(w, status, code, msg)
return return
} }
// Best-effort destroy on host agent — sandbox may already be gone (TTL reap).
if _, err := h.agent.DestroySandbox(ctx, connect.NewRequest(&pb.DestroySandboxRequest{
SandboxId: sandboxID,
})); err != nil {
slog.Warn("destroy: agent RPC failed (sandbox may already be gone)", "sandbox_id", sandboxID, "error", err)
}
if _, err := h.db.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{
ID: sandboxID, Status: "stopped",
}); err != nil {
slog.Error("destroy: failed to update sandbox status in DB", "sandbox_id", sandboxID, "error", err)
}
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }

View File

@ -14,18 +14,20 @@ import (
"git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id" "git.omukk.dev/wrenn/sandbox/internal/id"
"git.omukk.dev/wrenn/sandbox/internal/service"
"git.omukk.dev/wrenn/sandbox/internal/validate" "git.omukk.dev/wrenn/sandbox/internal/validate"
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
"git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect"
) )
type snapshotHandler struct { type snapshotHandler struct {
svc *service.TemplateService
db *db.Queries db *db.Queries
agent hostagentv1connect.HostAgentServiceClient agent hostagentv1connect.HostAgentServiceClient
} }
func newSnapshotHandler(db *db.Queries, agent hostagentv1connect.HostAgentServiceClient) *snapshotHandler { func newSnapshotHandler(svc *service.TemplateService, db *db.Queries, agent hostagentv1connect.HostAgentServiceClient) *snapshotHandler {
return &snapshotHandler{db: db, agent: agent} return &snapshotHandler{svc: svc, db: db, agent: agent}
} }
type createSnapshotRequest struct { type createSnapshotRequest struct {
@ -91,7 +93,6 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusConflict, "already_exists", "snapshot name already exists; use ?overwrite=true to replace") writeError(w, http.StatusConflict, "already_exists", "snapshot name already exists; use ?overwrite=true to replace")
return return
} }
// Delete existing template record and files.
if err := h.db.DeleteTemplateByTeam(ctx, db.DeleteTemplateByTeamParams{Name: req.Name, TeamID: ac.TeamID}); err != nil { if err := h.db.DeleteTemplateByTeam(ctx, db.DeleteTemplateByTeamParams{Name: req.Name, TeamID: ac.TeamID}); err != nil {
slog.Warn("failed to delete existing template", "name", req.Name, "error", err) slog.Warn("failed to delete existing template", "name", req.Name, "error", err)
} }
@ -108,8 +109,6 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
return return
} }
// Call host agent to create snapshot. If running, the agent pauses it first.
// The sandbox remains paused after this call.
resp, err := h.agent.CreateSnapshot(ctx, connect.NewRequest(&pb.CreateSnapshotRequest{ resp, err := h.agent.CreateSnapshot(ctx, connect.NewRequest(&pb.CreateSnapshotRequest{
SandboxId: req.SandboxID, SandboxId: req.SandboxID,
Name: req.Name, Name: req.Name,
@ -129,7 +128,6 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
} }
} }
// Insert template record.
tmpl, err := h.db.InsertTemplate(ctx, db.InsertTemplateParams{ tmpl, err := h.db.InsertTemplate(ctx, db.InsertTemplateParams{
Name: req.Name, Name: req.Name,
Type: "snapshot", Type: "snapshot",
@ -149,17 +147,10 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) {
// List handles GET /v1/snapshots. // List handles GET /v1/snapshots.
func (h *snapshotHandler) List(w http.ResponseWriter, r *http.Request) { func (h *snapshotHandler) List(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ac := auth.MustFromContext(r.Context())
ac := auth.MustFromContext(ctx)
typeFilter := r.URL.Query().Get("type") typeFilter := r.URL.Query().Get("type")
var templates []db.Template templates, err := h.svc.List(r.Context(), ac.TeamID, typeFilter)
var err error
if typeFilter != "" {
templates, err = h.db.ListTemplatesByTeamAndType(ctx, db.ListTemplatesByTeamAndTypeParams{TeamID: ac.TeamID, Type: typeFilter})
} else {
templates, err = h.db.ListTemplatesByTeam(ctx, ac.TeamID)
}
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list templates") writeError(w, http.StatusInternalServerError, "db_error", "failed to list templates")
return return
@ -188,7 +179,6 @@ func (h *snapshotHandler) Delete(w http.ResponseWriter, r *http.Request) {
return return
} }
// Delete files on host agent.
if _, err := h.agent.DeleteSnapshot(ctx, connect.NewRequest(&pb.DeleteSnapshotRequest{ if _, err := h.agent.DeleteSnapshot(ctx, connect.NewRequest(&pb.DeleteSnapshotRequest{
Name: name, Name: name,
})); err != nil { })); err != nil {

View File

@ -3,10 +3,12 @@ package api
import ( import (
"bufio" "bufio"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
"strings"
"time" "time"
"connectrpc.com/connect" "connectrpc.com/connect"
@ -68,6 +70,30 @@ func decodeJSON(r *http.Request, v any) error {
return json.NewDecoder(r.Body).Decode(v) return json.NewDecoder(r.Body).Decode(v)
} }
// serviceErrToHTTP maps a service-layer error to an HTTP status, code, and message.
// It inspects the underlying Connect RPC error if present, otherwise returns 500.
func serviceErrToHTTP(err error) (int, string, string) {
msg := err.Error()
// Check for Connect RPC errors wrapped by the service layer.
var connectErr *connect.Error
if errors.As(err, &connectErr) {
return agentErrToHTTP(connectErr)
}
// Map well-known service error patterns.
switch {
case strings.Contains(msg, "not found"):
return http.StatusNotFound, "not_found", msg
case strings.Contains(msg, "not running"), strings.Contains(msg, "not paused"):
return http.StatusConflict, "invalid_state", msg
case strings.Contains(msg, "invalid"):
return http.StatusBadRequest, "invalid_request", msg
default:
return http.StatusInternalServerError, "internal_error", msg
}
}
type statusWriter struct { type statusWriter struct {
http.ResponseWriter http.ResponseWriter
status int status int

View File

@ -10,6 +10,7 @@ import (
"git.omukk.dev/wrenn/sandbox/internal/auth/oauth" "git.omukk.dev/wrenn/sandbox/internal/auth/oauth"
"git.omukk.dev/wrenn/sandbox/internal/db" "git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/service"
"git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect"
) )
@ -26,15 +27,20 @@ func New(queries *db.Queries, agent hostagentv1connect.HostAgentServiceClient, p
r := chi.NewRouter() r := chi.NewRouter()
r.Use(requestLogger()) r.Use(requestLogger())
sandbox := newSandboxHandler(queries, agent) // Shared service layer.
sandboxSvc := &service.SandboxService{DB: queries, Agent: agent}
apiKeySvc := &service.APIKeyService{DB: queries}
templateSvc := &service.TemplateService{DB: queries}
sandbox := newSandboxHandler(sandboxSvc)
exec := newExecHandler(queries, agent) exec := newExecHandler(queries, agent)
execStream := newExecStreamHandler(queries, agent) execStream := newExecStreamHandler(queries, agent)
files := newFilesHandler(queries, agent) files := newFilesHandler(queries, agent)
filesStream := newFilesStreamHandler(queries, agent) filesStream := newFilesStreamHandler(queries, agent)
snapshots := newSnapshotHandler(queries, agent) snapshots := newSnapshotHandler(templateSvc, queries, agent)
authH := newAuthHandler(queries, pool, jwtSecret) authH := newAuthHandler(queries, pool, jwtSecret)
oauthH := newOAuthHandler(queries, pool, jwtSecret, oauthRegistry, oauthRedirectURL) oauthH := newOAuthHandler(queries, pool, jwtSecret, oauthRegistry, oauthRedirectURL)
apiKeys := newAPIKeyHandler(queries) apiKeys := newAPIKeyHandler(apiKeySvc)
// OpenAPI spec and docs. // OpenAPI spec and docs.
r.Get("/openapi.yaml", serveOpenAPI) r.Get("/openapi.yaml", serveOpenAPI)
@ -94,6 +100,12 @@ func (s *Server) Handler() http.Handler {
return s.router return s.router
} }
// Router returns the underlying chi router so additional routes (e.g. dashboard)
// can be mounted on it.
func (s *Server) Router() chi.Router {
return s.router
}
func serveOpenAPI(w http.ResponseWriter, r *http.Request) { func serveOpenAPI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/yaml") w.Header().Set("Content-Type", "application/yaml")
_, _ = w.Write(openapiYAML) _, _ = w.Write(openapiYAML)

View File

@ -0,0 +1,58 @@
package service
import (
"context"
"fmt"
"git.omukk.dev/wrenn/sandbox/internal/auth"
"git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
)
// APIKeyService provides API key operations shared between the REST API and the dashboard.
type APIKeyService struct {
DB *db.Queries
}
// APIKeyCreateResult holds the result of creating an API key, including the
// plaintext key which is only available at creation time.
type APIKeyCreateResult struct {
Row db.TeamApiKey
Plaintext string
}
// Create generates a new API key for the given team.
func (s *APIKeyService) Create(ctx context.Context, teamID, userID, name string) (APIKeyCreateResult, error) {
if name == "" {
name = "Unnamed API Key"
}
plaintext, hash, err := auth.GenerateAPIKey()
if err != nil {
return APIKeyCreateResult{}, fmt.Errorf("generate key: %w", err)
}
row, err := s.DB.InsertAPIKey(ctx, db.InsertAPIKeyParams{
ID: id.NewAPIKeyID(),
TeamID: teamID,
Name: name,
KeyHash: hash,
KeyPrefix: auth.APIKeyPrefix(plaintext),
CreatedBy: userID,
})
if err != nil {
return APIKeyCreateResult{}, fmt.Errorf("insert key: %w", err)
}
return APIKeyCreateResult{Row: row, Plaintext: plaintext}, nil
}
// List returns all API keys belonging to the given team.
func (s *APIKeyService) List(ctx context.Context, teamID string) ([]db.TeamApiKey, error) {
return s.DB.ListAPIKeysByTeam(ctx, teamID)
}
// Delete removes an API key by ID, scoped to the given team.
func (s *APIKeyService) Delete(ctx context.Context, keyID, teamID string) error {
return s.DB.DeleteAPIKey(ctx, db.DeleteAPIKeyParams{ID: keyID, TeamID: teamID})
}

225
internal/service/sandbox.go Normal file
View File

@ -0,0 +1,225 @@
package service
import (
"context"
"fmt"
"log/slog"
"time"
"connectrpc.com/connect"
"github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/sandbox/internal/db"
"git.omukk.dev/wrenn/sandbox/internal/id"
"git.omukk.dev/wrenn/sandbox/internal/validate"
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
"git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect"
)
// SandboxService provides sandbox lifecycle operations shared between the
// REST API and the dashboard.
type SandboxService struct {
DB *db.Queries
Agent hostagentv1connect.HostAgentServiceClient
}
// SandboxCreateParams holds the parameters for creating a sandbox.
type SandboxCreateParams struct {
TeamID string
Template string
VCPUs int32
MemoryMB int32
TimeoutSec int32
}
// Create creates a new sandbox: inserts a pending DB record, calls the host agent,
// and updates the record to running. Returns the sandbox DB row.
func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.Sandbox, error) {
if p.Template == "" {
p.Template = "minimal"
}
if err := validate.SafeName(p.Template); err != nil {
return db.Sandbox{}, fmt.Errorf("invalid template name: %w", err)
}
if p.VCPUs <= 0 {
p.VCPUs = 1
}
if p.MemoryMB <= 0 {
p.MemoryMB = 512
}
// If the template is a snapshot, use its baked-in vcpus/memory.
if tmpl, err := s.DB.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: p.Template, TeamID: p.TeamID}); err == nil && tmpl.Type == "snapshot" {
if tmpl.Vcpus.Valid {
p.VCPUs = tmpl.Vcpus.Int32
}
if tmpl.MemoryMb.Valid {
p.MemoryMB = tmpl.MemoryMb.Int32
}
}
sandboxID := id.NewSandboxID()
if _, err := s.DB.InsertSandbox(ctx, db.InsertSandboxParams{
ID: sandboxID,
TeamID: p.TeamID,
HostID: "default",
Template: p.Template,
Status: "pending",
Vcpus: p.VCPUs,
MemoryMb: p.MemoryMB,
TimeoutSec: p.TimeoutSec,
}); err != nil {
return db.Sandbox{}, fmt.Errorf("insert sandbox: %w", err)
}
resp, err := s.Agent.CreateSandbox(ctx, connect.NewRequest(&pb.CreateSandboxRequest{
SandboxId: sandboxID,
Template: p.Template,
Vcpus: p.VCPUs,
MemoryMb: p.MemoryMB,
TimeoutSec: p.TimeoutSec,
}))
if err != nil {
if _, dbErr := s.DB.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{
ID: sandboxID, Status: "error",
}); dbErr != nil {
slog.Warn("failed to update sandbox status to error", "id", sandboxID, "error", dbErr)
}
return db.Sandbox{}, fmt.Errorf("agent create: %w", err)
}
now := time.Now()
sb, err := s.DB.UpdateSandboxRunning(ctx, db.UpdateSandboxRunningParams{
ID: sandboxID,
HostIp: resp.Msg.HostIp,
GuestIp: "",
StartedAt: pgtype.Timestamptz{
Time: now,
Valid: true,
},
})
if err != nil {
return db.Sandbox{}, fmt.Errorf("update sandbox running: %w", err)
}
return sb, nil
}
// List returns all sandboxes belonging to the given team.
func (s *SandboxService) List(ctx context.Context, teamID string) ([]db.Sandbox, error) {
return s.DB.ListSandboxesByTeam(ctx, teamID)
}
// Get returns a single sandbox by ID, scoped to the given team.
func (s *SandboxService) Get(ctx context.Context, sandboxID, teamID string) (db.Sandbox, error) {
return s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
}
// Pause snapshots and freezes a running sandbox to disk.
func (s *SandboxService) Pause(ctx context.Context, sandboxID, teamID string) (db.Sandbox, error) {
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
if err != nil {
return db.Sandbox{}, fmt.Errorf("sandbox not found: %w", err)
}
if sb.Status != "running" {
return db.Sandbox{}, fmt.Errorf("sandbox is not running (status: %s)", sb.Status)
}
if _, err := s.Agent.PauseSandbox(ctx, connect.NewRequest(&pb.PauseSandboxRequest{
SandboxId: sandboxID,
})); err != nil {
return db.Sandbox{}, fmt.Errorf("agent pause: %w", err)
}
sb, err = s.DB.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{
ID: sandboxID, Status: "paused",
})
if err != nil {
return db.Sandbox{}, fmt.Errorf("update status: %w", err)
}
return sb, nil
}
// Resume restores a paused sandbox from snapshot.
func (s *SandboxService) Resume(ctx context.Context, sandboxID, teamID string) (db.Sandbox, error) {
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
if err != nil {
return db.Sandbox{}, fmt.Errorf("sandbox not found: %w", err)
}
if sb.Status != "paused" {
return db.Sandbox{}, fmt.Errorf("sandbox is not paused (status: %s)", sb.Status)
}
resp, err := s.Agent.ResumeSandbox(ctx, connect.NewRequest(&pb.ResumeSandboxRequest{
SandboxId: sandboxID,
TimeoutSec: sb.TimeoutSec,
}))
if err != nil {
return db.Sandbox{}, fmt.Errorf("agent resume: %w", err)
}
now := time.Now()
sb, err = s.DB.UpdateSandboxRunning(ctx, db.UpdateSandboxRunningParams{
ID: sandboxID,
HostIp: resp.Msg.HostIp,
GuestIp: "",
StartedAt: pgtype.Timestamptz{
Time: now,
Valid: true,
},
})
if err != nil {
return db.Sandbox{}, fmt.Errorf("update status: %w", err)
}
return sb, nil
}
// Destroy stops a sandbox and marks it as stopped.
func (s *SandboxService) Destroy(ctx context.Context, sandboxID, teamID string) error {
if _, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID}); err != nil {
return fmt.Errorf("sandbox not found: %w", err)
}
// Best-effort destroy on host agent — sandbox may already be gone.
if _, err := s.Agent.DestroySandbox(ctx, connect.NewRequest(&pb.DestroySandboxRequest{
SandboxId: sandboxID,
})); err != nil {
slog.Warn("destroy: agent RPC failed (sandbox may already be gone)", "sandbox_id", sandboxID, "error", err)
}
if _, err := s.DB.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{
ID: sandboxID, Status: "stopped",
}); err != nil {
return fmt.Errorf("update status: %w", err)
}
return nil
}
// Ping resets the inactivity timer for a running sandbox.
func (s *SandboxService) Ping(ctx context.Context, sandboxID, teamID string) error {
sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID})
if err != nil {
return fmt.Errorf("sandbox not found: %w", err)
}
if sb.Status != "running" {
return fmt.Errorf("sandbox is not running (status: %s)", sb.Status)
}
if _, err := s.Agent.PingSandbox(ctx, connect.NewRequest(&pb.PingSandboxRequest{
SandboxId: sandboxID,
})); err != nil {
return fmt.Errorf("agent ping: %w", err)
}
if err := s.DB.UpdateLastActive(ctx, db.UpdateLastActiveParams{
ID: sandboxID,
LastActiveAt: pgtype.Timestamptz{
Time: time.Now(),
Valid: true,
},
}); err != nil {
slog.Warn("ping: failed to update last_active_at", "sandbox_id", sandboxID, "error", err)
}
return nil
}

View File

@ -0,0 +1,25 @@
package service
import (
"context"
"git.omukk.dev/wrenn/sandbox/internal/db"
)
// TemplateService provides template/snapshot operations shared between the
// REST API and the dashboard.
type TemplateService struct {
DB *db.Queries
}
// List returns all templates belonging to the given team. If typeFilter is
// non-empty, only templates of that type ("base" or "snapshot") are returned.
func (s *TemplateService) List(ctx context.Context, teamID, typeFilter string) ([]db.Template, error) {
if typeFilter != "" {
return s.DB.ListTemplatesByTeamAndType(ctx, db.ListTemplatesByTeamAndTypeParams{
TeamID: teamID,
Type: typeFilter,
})
}
return s.DB.ListTemplatesByTeam(ctx, teamID)
}