- REST API (chi router): sandbox CRUD, exec, pause/resume, file write/read - PostgreSQL persistence via pgx/v5 + sqlc (sandboxes table with goose migration) - Connect RPC client to host agent for all VM operations - Reconciler syncs host agent state with DB every 30s (detects TTL-reaped sandboxes) - OpenAPI 3.1 spec served at /openapi.yaml, Swagger UI at /docs - Added WriteFile/ReadFile RPCs to hostagent proto and implementations - File upload via multipart form, download via JSON body POST - sandbox_id propagated from control plane to host agent on create
278 lines
7.4 KiB
Go
278 lines
7.4 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"connectrpc.com/connect"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.omukk.dev/wrenn/sandbox/internal/db"
|
|
"git.omukk.dev/wrenn/sandbox/internal/id"
|
|
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
|
|
"git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect"
|
|
)
|
|
|
|
type sandboxHandler struct {
|
|
db *db.Queries
|
|
agent hostagentv1connect.HostAgentServiceClient
|
|
}
|
|
|
|
func newSandboxHandler(db *db.Queries, agent hostagentv1connect.HostAgentServiceClient) *sandboxHandler {
|
|
return &sandboxHandler{db: db, agent: agent}
|
|
}
|
|
|
|
type createSandboxRequest struct {
|
|
Template string `json:"template"`
|
|
VCPUs int32 `json:"vcpus"`
|
|
MemoryMB int32 `json:"memory_mb"`
|
|
TimeoutSec int32 `json:"timeout_sec"`
|
|
}
|
|
|
|
type sandboxResponse struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
Template string `json:"template"`
|
|
VCPUs int32 `json:"vcpus"`
|
|
MemoryMB int32 `json:"memory_mb"`
|
|
TimeoutSec int32 `json:"timeout_sec"`
|
|
GuestIP string `json:"guest_ip,omitempty"`
|
|
HostIP string `json:"host_ip,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
StartedAt *string `json:"started_at,omitempty"`
|
|
LastActiveAt *string `json:"last_active_at,omitempty"`
|
|
LastUpdated string `json:"last_updated"`
|
|
}
|
|
|
|
func sandboxToResponse(sb db.Sandbox) sandboxResponse {
|
|
resp := sandboxResponse{
|
|
ID: sb.ID,
|
|
Status: sb.Status,
|
|
Template: sb.Template,
|
|
VCPUs: sb.Vcpus,
|
|
MemoryMB: sb.MemoryMb,
|
|
TimeoutSec: sb.TimeoutSec,
|
|
GuestIP: sb.GuestIp,
|
|
HostIP: sb.HostIp,
|
|
}
|
|
if sb.CreatedAt.Valid {
|
|
resp.CreatedAt = sb.CreatedAt.Time.Format(time.RFC3339)
|
|
}
|
|
if sb.StartedAt.Valid {
|
|
s := sb.StartedAt.Time.Format(time.RFC3339)
|
|
resp.StartedAt = &s
|
|
}
|
|
if sb.LastActiveAt.Valid {
|
|
s := sb.LastActiveAt.Time.Format(time.RFC3339)
|
|
resp.LastActiveAt = &s
|
|
}
|
|
if sb.LastUpdated.Valid {
|
|
resp.LastUpdated = sb.LastUpdated.Time.Format(time.RFC3339)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// Create handles POST /v1/sandboxes.
|
|
func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|
var req createSandboxRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
|
return
|
|
}
|
|
|
|
if req.Template == "" {
|
|
req.Template = "minimal"
|
|
}
|
|
if req.VCPUs <= 0 {
|
|
req.VCPUs = 1
|
|
}
|
|
if req.MemoryMB <= 0 {
|
|
req.MemoryMB = 512
|
|
}
|
|
if req.TimeoutSec <= 0 {
|
|
req.TimeoutSec = 300
|
|
}
|
|
|
|
ctx := r.Context()
|
|
sandboxID := id.NewSandboxID()
|
|
|
|
// Insert pending record.
|
|
sb, err := h.db.InsertSandbox(ctx, db.InsertSandboxParams{
|
|
ID: sandboxID,
|
|
OwnerID: "",
|
|
HostID: "default",
|
|
Template: req.Template,
|
|
Status: "pending",
|
|
Vcpus: req.VCPUs,
|
|
MemoryMb: req.MemoryMB,
|
|
TimeoutSec: req.TimeoutSec,
|
|
})
|
|
if err != nil {
|
|
slog.Error("failed to insert sandbox", "error", 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 {
|
|
h.db.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{
|
|
ID: sandboxID, Status: "error",
|
|
})
|
|
status, code, msg := agentErrToHTTP(err)
|
|
writeError(w, status, code, msg)
|
|
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))
|
|
}
|
|
|
|
// List handles GET /v1/sandboxes.
|
|
func (h *sandboxHandler) List(w http.ResponseWriter, r *http.Request) {
|
|
sandboxes, err := h.db.ListSandboxes(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "db_error", "failed to list sandboxes")
|
|
return
|
|
}
|
|
|
|
resp := make([]sandboxResponse, len(sandboxes))
|
|
for i, sb := range sandboxes {
|
|
resp[i] = sandboxToResponse(sb)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// Get handles GET /v1/sandboxes/{id}.
|
|
func (h *sandboxHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|
sandboxID := chi.URLParam(r, "id")
|
|
|
|
sb, err := h.db.GetSandbox(r.Context(), sandboxID)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, sandboxToResponse(sb))
|
|
}
|
|
|
|
// Pause handles POST /v1/sandboxes/{id}/pause.
|
|
func (h *sandboxHandler) Pause(w http.ResponseWriter, r *http.Request) {
|
|
sandboxID := chi.URLParam(r, "id")
|
|
ctx := r.Context()
|
|
|
|
sb, err := h.db.GetSandbox(ctx, sandboxID)
|
|
if err != nil {
|
|
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.PauseSandbox(ctx, connect.NewRequest(&pb.PauseSandboxRequest{
|
|
SandboxId: sandboxID,
|
|
})); err != nil {
|
|
status, code, msg := agentErrToHTTP(err)
|
|
writeError(w, status, code, msg)
|
|
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))
|
|
}
|
|
|
|
// Resume handles POST /v1/sandboxes/{id}/resume.
|
|
func (h *sandboxHandler) Resume(w http.ResponseWriter, r *http.Request) {
|
|
sandboxID := chi.URLParam(r, "id")
|
|
ctx := r.Context()
|
|
|
|
sb, err := h.db.GetSandbox(ctx, sandboxID)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
|
|
return
|
|
}
|
|
if sb.Status != "paused" {
|
|
writeError(w, http.StatusConflict, "invalid_state", "sandbox is not paused")
|
|
return
|
|
}
|
|
|
|
if _, err := h.agent.ResumeSandbox(ctx, connect.NewRequest(&pb.ResumeSandboxRequest{
|
|
SandboxId: sandboxID,
|
|
})); err != nil {
|
|
status, code, msg := agentErrToHTTP(err)
|
|
writeError(w, status, code, msg)
|
|
return
|
|
}
|
|
|
|
sb, err = h.db.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{
|
|
ID: sandboxID, Status: "running",
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "db_error", "failed to update status")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, sandboxToResponse(sb))
|
|
}
|
|
|
|
// Destroy handles DELETE /v1/sandboxes/{id}.
|
|
func (h *sandboxHandler) Destroy(w http.ResponseWriter, r *http.Request) {
|
|
sandboxID := chi.URLParam(r, "id")
|
|
ctx := r.Context()
|
|
|
|
_, err := h.db.GetSandbox(ctx, sandboxID)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "sandbox not found")
|
|
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)
|
|
}
|