From f38d5812d15b66a24fe8a7fc9ddd3d43010b4bcc Mon Sep 17 00:00:00 2001 From: pptx704 Date: Mon, 16 Mar 2026 04:13:11 +0600 Subject: [PATCH] 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). --- internal/api/handlers_apikeys.go | 41 ++---- internal/api/handlers_sandbox.go | 213 +++------------------------ internal/api/handlers_snapshots.go | 22 +-- internal/api/middleware.go | 26 ++++ internal/api/server.go | 18 ++- internal/service/apikey.go | 58 ++++++++ internal/service/sandbox.go | 225 +++++++++++++++++++++++++++++ internal/service/template.go | 25 ++++ 8 files changed, 389 insertions(+), 239 deletions(-) create mode 100644 internal/service/apikey.go create mode 100644 internal/service/sandbox.go create mode 100644 internal/service/template.go diff --git a/internal/api/handlers_apikeys.go b/internal/api/handlers_apikeys.go index b8a5ead..d0e9074 100644 --- a/internal/api/handlers_apikeys.go +++ b/internal/api/handlers_apikeys.go @@ -8,15 +8,15 @@ import ( "git.omukk.dev/wrenn/sandbox/internal/auth" "git.omukk.dev/wrenn/sandbox/internal/db" - "git.omukk.dev/wrenn/sandbox/internal/id" + "git.omukk.dev/wrenn/sandbox/internal/service" ) type apiKeyHandler struct { - db *db.Queries + svc *service.APIKeyService } -func newAPIKeyHandler(db *db.Queries) *apiKeyHandler { - return &apiKeyHandler{db: db} +func newAPIKeyHandler(svc *service.APIKeyService) *apiKeyHandler { + return &apiKeyHandler{svc: svc} } type createAPIKeyRequest struct { @@ -60,32 +60,14 @@ func (h *apiKeyHandler) Create(w http.ResponseWriter, r *http.Request) { return } - if req.Name == "" { - req.Name = "Unnamed API Key" - } - - plaintext, hash, err := auth.GenerateAPIKey() + result, err := h.svc.Create(r.Context(), ac.TeamID, ac.UserID, req.Name) 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 } - keyID := id.NewAPIKeyID() - row, err := h.db.InsertAPIKey(r.Context(), db.InsertAPIKeyParams{ - 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 + resp := apiKeyToResponse(result.Row) + resp.Key = &result.Plaintext 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) { 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 { writeError(w, http.StatusInternalServerError, "db_error", "failed to list API keys") return @@ -113,10 +95,7 @@ func (h *apiKeyHandler) Delete(w http.ResponseWriter, r *http.Request) { ac := auth.MustFromContext(r.Context()) keyID := chi.URLParam(r, "id") - if err := h.db.DeleteAPIKey(r.Context(), db.DeleteAPIKeyParams{ - ID: keyID, - TeamID: ac.TeamID, - }); err != nil { + if err := h.svc.Delete(r.Context(), keyID, ac.TeamID); err != nil { writeError(w, http.StatusInternalServerError, "db_error", "failed to delete API key") return } diff --git a/internal/api/handlers_sandbox.go b/internal/api/handlers_sandbox.go index 50a629b..a312e5f 100644 --- a/internal/api/handlers_sandbox.go +++ b/internal/api/handlers_sandbox.go @@ -2,30 +2,22 @@ package api import ( "encoding/json" - "fmt" - "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/auth" "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" + "git.omukk.dev/wrenn/sandbox/internal/service" ) type sandboxHandler struct { - db *db.Queries - agent hostagentv1connect.HostAgentServiceClient + svc *service.SandboxService } -func newSandboxHandler(db *db.Queries, agent hostagentv1connect.HostAgentServiceClient) *sandboxHandler { - return &sandboxHandler{db: db, agent: agent} +func newSandboxHandler(svc *service.SandboxService) *sandboxHandler { + return &sandboxHandler{svc: svc} } type createSandboxRequest struct { @@ -86,95 +78,28 @@ func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) { return } - if req.Template == "" { - 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. + ac := auth.MustFromContext(r.Context()) - ctx := r.Context() - 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, + sb, err := h.svc.Create(r.Context(), service.SandboxCreateParams{ TeamID: ac.TeamID, - HostID: "default", Template: req.Template, - Status: "pending", - Vcpus: req.VCPUs, - MemoryMb: req.MemoryMB, + 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 { - 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) + status, code, msg := serviceErrToHTTP(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) { 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 { writeError(w, http.StatusInternalServerError, "db_error", "failed to list sandboxes") return @@ -193,7 +118,7 @@ func (h *sandboxHandler) Get(w http.ResponseWriter, r *http.Request) { sandboxID := chi.URLParam(r, "id") 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 { writeError(w, http.StatusNotFound, "not_found", "sandbox not found") return @@ -203,149 +128,59 @@ func (h *sandboxHandler) Get(w http.ResponseWriter, r *http.Request) { } // 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) { sandboxID := chi.URLParam(r, "id") - ctx := r.Context() - ac := auth.MustFromContext(ctx) + ac := auth.MustFromContext(r.Context()) - 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 { - 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) + status, code, msg := serviceErrToHTTP(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. -// Resume restores a paused sandbox from snapshot using UFFD lazy memory loading. func (h *sandboxHandler) Resume(w http.ResponseWriter, r *http.Request) { sandboxID := chi.URLParam(r, "id") - ctx := r.Context() - ac := auth.MustFromContext(ctx) + ac := auth.MustFromContext(r.Context()) - 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 { - 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 - } - - resp, err := h.agent.ResumeSandbox(ctx, connect.NewRequest(&pb.ResumeSandboxRequest{ - SandboxId: sandboxID, - TimeoutSec: sb.TimeoutSec, - })) - if err != nil { - status, code, msg := agentErrToHTTP(err) + status, code, msg := serviceErrToHTTP(err) writeError(w, status, code, msg) 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)) } // 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) { sandboxID := chi.URLParam(r, "id") - ctx := r.Context() - ac := auth.MustFromContext(ctx) + ac := auth.MustFromContext(r.Context()) - sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) - 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.PingSandbox(ctx, connect.NewRequest(&pb.PingSandboxRequest{ - SandboxId: sandboxID, - })); err != nil { - status, code, msg := agentErrToHTTP(err) + if err := h.svc.Ping(r.Context(), sandboxID, ac.TeamID); err != nil { + status, code, msg := serviceErrToHTTP(err) writeError(w, status, code, msg) 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) } // Destroy handles DELETE /v1/sandboxes/{id}. func (h *sandboxHandler) Destroy(w http.ResponseWriter, r *http.Request) { sandboxID := chi.URLParam(r, "id") - ctx := r.Context() - ac := auth.MustFromContext(ctx) + ac := auth.MustFromContext(r.Context()) - _, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) - if err != nil { - writeError(w, http.StatusNotFound, "not_found", "sandbox not found") + if err := h.svc.Destroy(r.Context(), sandboxID, ac.TeamID); err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) 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) } diff --git a/internal/api/handlers_snapshots.go b/internal/api/handlers_snapshots.go index 20cd99f..8667edb 100644 --- a/internal/api/handlers_snapshots.go +++ b/internal/api/handlers_snapshots.go @@ -14,18 +14,20 @@ import ( "git.omukk.dev/wrenn/sandbox/internal/auth" "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 snapshotHandler struct { + svc *service.TemplateService db *db.Queries agent hostagentv1connect.HostAgentServiceClient } -func newSnapshotHandler(db *db.Queries, agent hostagentv1connect.HostAgentServiceClient) *snapshotHandler { - return &snapshotHandler{db: db, agent: agent} +func newSnapshotHandler(svc *service.TemplateService, db *db.Queries, agent hostagentv1connect.HostAgentServiceClient) *snapshotHandler { + return &snapshotHandler{svc: svc, db: db, agent: agent} } 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") return } - // Delete existing template record and files. 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) } @@ -108,8 +109,6 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) { 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{ SandboxId: req.SandboxID, 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{ Name: req.Name, Type: "snapshot", @@ -149,17 +147,10 @@ func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) { // List handles GET /v1/snapshots. func (h *snapshotHandler) List(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - ac := auth.MustFromContext(ctx) + ac := auth.MustFromContext(r.Context()) typeFilter := r.URL.Query().Get("type") - var templates []db.Template - 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) - } + templates, err := h.svc.List(r.Context(), ac.TeamID, typeFilter) if err != nil { writeError(w, http.StatusInternalServerError, "db_error", "failed to list templates") return @@ -188,7 +179,6 @@ func (h *snapshotHandler) Delete(w http.ResponseWriter, r *http.Request) { return } - // Delete files on host agent. if _, err := h.agent.DeleteSnapshot(ctx, connect.NewRequest(&pb.DeleteSnapshotRequest{ Name: name, })); err != nil { diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 00cf1c9..63ad16f 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -3,10 +3,12 @@ package api import ( "bufio" "encoding/json" + "errors" "fmt" "log/slog" "net" "net/http" + "strings" "time" "connectrpc.com/connect" @@ -68,6 +70,30 @@ func decodeJSON(r *http.Request, v any) error { 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 { http.ResponseWriter status int diff --git a/internal/api/server.go b/internal/api/server.go index bc0b4a2..b1859af 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -10,6 +10,7 @@ import ( "git.omukk.dev/wrenn/sandbox/internal/auth/oauth" "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/service" "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.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) execStream := newExecStreamHandler(queries, agent) files := newFilesHandler(queries, agent) filesStream := newFilesStreamHandler(queries, agent) - snapshots := newSnapshotHandler(queries, agent) + snapshots := newSnapshotHandler(templateSvc, queries, agent) authH := newAuthHandler(queries, pool, jwtSecret) oauthH := newOAuthHandler(queries, pool, jwtSecret, oauthRegistry, oauthRedirectURL) - apiKeys := newAPIKeyHandler(queries) + apiKeys := newAPIKeyHandler(apiKeySvc) // OpenAPI spec and docs. r.Get("/openapi.yaml", serveOpenAPI) @@ -94,6 +100,12 @@ func (s *Server) Handler() http.Handler { 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) { w.Header().Set("Content-Type", "application/yaml") _, _ = w.Write(openapiYAML) diff --git a/internal/service/apikey.go b/internal/service/apikey.go new file mode 100644 index 0000000..5dfc5c1 --- /dev/null +++ b/internal/service/apikey.go @@ -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}) +} diff --git a/internal/service/sandbox.go b/internal/service/sandbox.go new file mode 100644 index 0000000..d0b1ee5 --- /dev/null +++ b/internal/service/sandbox.go @@ -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 +} diff --git a/internal/service/template.go b/internal/service/template.go new file mode 100644 index 0000000..d669e45 --- /dev/null +++ b/internal/service/template.go @@ -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) +}