feat(sessions): sandbox resource — per-session k8s Pod+PVC+Service (§4.15.4)

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:
Admin Bot
2026-09-29 11:12:27 +08:00
parent fadc658ac0
commit 5ad3dc9d08
5 changed files with 897 additions and 1 deletions
+84 -1
View File
@@ -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.