feat(sessions): sandbox resource — per-session k8s Pod+PVC+Service (§4.15.4)
CI / CI (pull_request) Successful in 2m8s
CI / CI (pull_request) Successful in 2m8s
New X-Resource: sandbox on X-Service: sessions
POST ?session_id= → CreateOrResume
1. Ensure PVC workspace-session-<id> (Longhorn 1Gi)
2. If pod already Running → return DNS (resume path, idempotent)
3. Ensure pi-session-sandbox NetworkPolicy (shared, created once)
4. Create Pod pi-session-<id> + Service pi-session-<id>:8787
5. Wait up to 30s for Pod Ready
6. Fire-and-forget: merge pod_name/pvc_name/svc_dns/last_active_at
into session memory_entity summary
DELETE ?session_id= → delete Pod + Service + PVC
Idle reaper goroutine (5min interval, configurable via
SANDBOX_REAPER_INTERVAL_MINUTES): lists pi-session-* pods,
deletes Succeeded/Failed ones, preserves PVCs for resume.
Security: runAsNonRoot/1000, readOnlyRootFilesystem, drop ALL
caps, RuntimeDefault seccomp, automountServiceAccountToken:false.
Resources: 500m/512Mi req, 1/1Gi limit.
NetworkPolicy: egress → DNS + api:8080 only; ingress ← poimen-frontend.
RBAC: Role session-sandbox-manager + RoleBinding in poimen ns.
7 unit tests — all pass (mock k8s server).
Image: forgejo.riotpiao.com/riotpiao-poimen/poimen-pi-session-runtime:latest
This commit is contained in:
@@ -12,9 +12,11 @@ package sessions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
@@ -33,6 +35,7 @@ const (
|
||||
type Handler struct {
|
||||
graphqlURL string
|
||||
client *http.Client
|
||||
sandbox *SandboxManager
|
||||
}
|
||||
|
||||
// NewHandler creates a Handler reading upstream URL from env var.
|
||||
@@ -42,10 +45,33 @@ func NewHandler() *Handler {
|
||||
if gql == "" {
|
||||
gql = defaultGraphQLURL
|
||||
}
|
||||
return &Handler{
|
||||
h := &Handler{
|
||||
graphqlURL: gql,
|
||||
client: &http.Client{Timeout: 30 * time.Second, Transport: tracing.NewTransport(nil)},
|
||||
sandbox: NewSandboxManager(),
|
||||
}
|
||||
h.startIdleReaper()
|
||||
return h
|
||||
}
|
||||
|
||||
// startIdleReaper launches a background goroutine that deletes exited pi-session
|
||||
// pods (bridge.js idle-timeout triggered) while preserving their PVCs.
|
||||
func (h *Handler) startIdleReaper() {
|
||||
intervalStr := os.Getenv("SANDBOX_REAPER_INTERVAL_MINUTES")
|
||||
interval := defaultReaperInterval
|
||||
if intervalStr != "" {
|
||||
var min int
|
||||
if n, _ := fmt.Sscanf(intervalStr, "%d", &min); n == 1 && min > 0 {
|
||||
interval = time.Duration(min) * time.Minute
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
h.sandbox.ReapIdlePods(context.Background())
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ServeHTTP routes by X-Upstream-Path set by the dispatcher.
|
||||
@@ -66,6 +92,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleListEpisodes(w, r)
|
||||
case "/v1/sessions/episodes/get":
|
||||
h.handleGetEpisode(w, r)
|
||||
case "/v1/sessions/sandbox":
|
||||
h.handleSandbox(w, r)
|
||||
default:
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "sessions endpoint not found"})
|
||||
}
|
||||
@@ -498,6 +526,61 @@ func (h *Handler) gql(r *http.Request, query string, variables map[string]any) (
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// handleSandbox dispatches POST (create/resume) and DELETE (teardown).
|
||||
// Both take ?session_id= as a query param.
|
||||
func (h *Handler) handleSandbox(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := r.URL.Query().Get("session_id")
|
||||
if sessionID == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "session_id query param required"})
|
||||
return
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
h.handleSandboxCreate(w, r, sessionID)
|
||||
case http.MethodDelete:
|
||||
h.handleSandboxDelete(w, r, sessionID)
|
||||
default:
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
}
|
||||
}
|
||||
|
||||
// POST ?session_id= — create or resume pi-session sandbox.
|
||||
func (h *Handler) handleSandboxCreate(w http.ResponseWriter, r *http.Request, sessionID string) {
|
||||
result, err := h.sandbox.CreateOrResume(r.Context(), sessionID)
|
||||
if err != nil {
|
||||
log.Printf("sandbox create/resume session=%s: %v", sessionID, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "sandbox error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Update session memory_entity with pod/pvc metadata (fire-and-forget — don't fail the response)
|
||||
go func() {
|
||||
current, _ := h.fetchEntitySummary(r, "session-"+sessionID, sessionID, "SESSION")
|
||||
current["pod_name"] = result.PodName
|
||||
current["pvc_name"] = result.PVCName
|
||||
current["svc_dns"] = result.SvcDNS
|
||||
current["last_active_at"] = time.Now().UTC().Format(time.RFC3339)
|
||||
if b, err := json.Marshal(current); err == nil {
|
||||
h.upsertEntity(r, "session-"+sessionID, sessionID, "SESSION", string(b)) //nolint:errcheck
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("sandbox: session=%s pod=%s resumed=%v dns=%s", sessionID, result.PodName, result.Resumed, result.SvcDNS)
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// DELETE ?session_id= — delete Pod + Service + PVC.
|
||||
func (h *Handler) handleSandboxDelete(w http.ResponseWriter, r *http.Request, sessionID string) {
|
||||
if err := h.sandbox.Delete(r.Context(), sessionID); err != nil {
|
||||
log.Printf("sandbox delete session=%s: %v", sessionID, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "sandbox delete error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
log.Printf("sandbox: deleted pod+svc+pvc for session=%s", sessionID)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"session_id": sessionID, "status": "deleted"})
|
||||
}
|
||||
|
||||
// EpisodeID computes the deterministic episode ID per §4.14 Q6:
|
||||
// sha256(session_id + milestone_id + attempt).
|
||||
// Exported so callers can verify the value the server would produce.
|
||||
|
||||
Reference in New Issue
Block a user