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:
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
58
internal/service/apikey.go
Normal file
58
internal/service/apikey.go
Normal 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
225
internal/service/sandbox.go
Normal 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
|
||||||
|
}
|
||||||
25
internal/service/template.go
Normal file
25
internal/service/template.go
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user