Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,6 @@ test/e2e/python/.pytest_cache/
# TypeScript E2E test artifacts
test/e2e/ts/node_modules/
test/e2e/ts/package-lock.json

# Vendored dependencies
vendor/
20 changes: 11 additions & 9 deletions cmd/edvabe/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,13 +164,15 @@ func serveCmd(args []string) {
domain = fmt.Sprintf("localhost:%d", *port)
}
mgr, err := sandbox.NewManager(sandbox.Options{
Runtime: rt,
Agent: ap,
Domain: domain,
Resolver: template.NewSandboxResolver(templateStore),
FreezeDuration: *freezeDuration,
MaxFrozen: *maxFrozen,
StoppedGCAfter: *stoppedGCAfter,
Runtime: rt,
Agent: ap,
Domain: domain,
Resolver: template.NewSandboxResolver(templateStore),
FreezeDuration: *freezeDuration,
MaxFrozen: *maxFrozen,
StoppedGCAfter: *stoppedGCAfter,
KeepaliveEnabled: os.Getenv("EDVABE_KEEPALIVE_ENABLED") != "0",
KeepaliveCoalesce: envDurationOr("EDVABE_KEEPALIVE_COALESCE", time.Second),
})
if err != nil {
fmt.Fprintf(os.Stderr, "serve: init manager: %v\n", err)
Expand Down Expand Up @@ -224,13 +226,13 @@ func serveCmd(args []string) {
FileSigner: fileSigner,
PublicBase: os.Getenv("EDVABE_PUBLIC_BASE"),
})
proxyHandler := api.NewProxy(mgr, rt)
proxyHandler := api.NewProxy(mgr, rt, mgr)
dashboardHandler := dashboard.NewHandler(dashboard.HandlerOptions{
Manager: mgr,
Runtime: rt,
Templates: templateStore,
})
handler := api.NewRouter(controlHandler, proxyHandler, dashboardHandler)
handler := api.NewRouter(controlHandler, proxyHandler, dashboardHandler, mgr.MarkActivity)

if *dnsListen != "" {
answerStr := *dnsAnswer
Expand Down
14 changes: 9 additions & 5 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,14 @@ type InitConfig struct {
HyperloopIP string
}

// VolumeMount describes a filesystem path inside the sandbox backed by
// an external volume. Phase 1 passes an empty list; Phase 4 will wire
// real volumes through the control plane.
// VolumeMount describes an NFS-backed filesystem path inside the
// sandbox, as expected by upstream envd's /init endpoint. edvabe
// does NOT use this — local Docker volumes are mounted at container
// creation time by the runtime, not by envd. This struct exists
// solely for wire-parity with envd's init body and must never be
// populated with local Docker volume names. See
// docs/08-volume-mounts-plan.md § Envd boundary.
type VolumeMount struct {
Name string
MountPath string
NFSTarget string `json:"nfs_target,omitempty"`
Path string `json:"path,omitempty"`
}
4 changes: 2 additions & 2 deletions internal/agent/upstream/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ func TestInitAgentPostsExpectedPayload(t *testing.T) {
EnvVars: map[string]string{"FOO": "bar"},
DefaultUser: "user",
DefaultWorkdir: "/home/user",
VolumeMounts: []agent.VolumeMount{{Name: "vol", MountPath: "/volumes/vol"}},
VolumeMounts: []agent.VolumeMount{{NFSTarget: "server:/export", Path: "/volumes/vol"}},
})
if err != nil {
t.Fatalf("InitAgent: %v", err)
Expand All @@ -87,7 +87,7 @@ func TestInitAgentPostsExpectedPayload(t *testing.T) {
if got.DefaultWorkdir != "/home/user" {
t.Errorf("DefaultWorkdir = %q", got.DefaultWorkdir)
}
if len(got.VolumeMounts) != 1 || got.VolumeMounts[0].MountPath != "/volumes/vol" {
if len(got.VolumeMounts) != 1 || got.VolumeMounts[0].Path != "/volumes/vol" {
t.Errorf("VolumeMounts = %#v", got.VolumeMounts)
}
if got.Timestamp == "" {
Expand Down
5 changes: 3 additions & 2 deletions internal/api/control/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,13 @@ func NewRouter(opts RouterOptions) http.Handler {
// lifetime — no persistence across restarts.
apiKeys := newAPIKeyStore()
accessTokens := newAccessTokenStore()
volumes := newVolumeStore()
volumes := newVolumeStore(opts.Runtime)
_ = volumes.loadFromRuntime(context.Background())

sandboxHandler := api.RequireAPIKey(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/sandboxes":
createSandbox(opts.Manager, opts.Provider, w, r)
createSandbox(opts.Manager, opts.Provider, volumes, w, r)
case r.Method == http.MethodGet && r.URL.Path == "/v2/sandboxes":
listSandboxes(opts.Manager, opts.Provider, w, r)

Expand Down
154 changes: 118 additions & 36 deletions internal/api/control/sandboxes.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ package control
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"path"
"sort"
"strconv"
"strings"
Expand All @@ -28,12 +30,17 @@ type newSandboxRequest struct {
CPUCount int `json:"cpuCount,omitempty"`
MemoryMB int `json:"memoryMB,omitempty"`
// Accepted for wire compat but not enforced in edvabe.
Secure bool `json:"secure,omitempty"`
AllowInternetAccess *bool `json:"allow_internet_access,omitempty"`
Network *json.RawMessage `json:"network,omitempty"`
VolumeMounts []json.RawMessage `json:"volumeMounts,omitempty"`
MCP *json.RawMessage `json:"mcp,omitempty"`
AutoResume *json.RawMessage `json:"autoResume,omitempty"`
Secure bool `json:"secure,omitempty"`
AllowInternetAccess *bool `json:"allow_internet_access,omitempty"`
Network *json.RawMessage `json:"network,omitempty"`
VolumeMounts []sandboxVolumeMount `json:"volumeMounts,omitempty"`
MCP *json.RawMessage `json:"mcp,omitempty"`
AutoResume *json.RawMessage `json:"autoResume,omitempty"`
}

type sandboxVolumeMount struct {
Name string `json:"name"`
Path string `json:"path"`
}

// newSandboxLifecycleInput mirrors the SDK's NewSandbox.lifecycle field.
Expand All @@ -56,18 +63,26 @@ type sandboxResponse struct {
Metadata map[string]string `json:"metadata,omitempty"`
StartedAt time.Time `json:"startedAt"`
EndAt time.Time `json:"endAt"`
LastActiveAt time.Time `json:"lastActiveAt,omitempty"`
}

type sandboxDetailResponse struct {
sandboxResponse
State sandbox.State `json:"state"`
CPUCount int `json:"cpuCount"`
MemoryMB int64 `json:"memoryMB"`
DiskSizeMB int64 `json:"diskSizeMB"`
Lifecycle sandboxLifecycle `json:"lifecycle"`
Network sandboxNetworkConfig `json:"network"`
AllowInternetAccess bool `json:"allowInternetAccess"`
EnvVars map[string]string `json:"envVars,omitempty"`
State sandbox.State `json:"state"`
CPUCount int `json:"cpuCount"`
MemoryMB int64 `json:"memoryMB"`
DiskSizeMB int64 `json:"diskSizeMB"`
Lifecycle sandboxLifecycle `json:"lifecycle"`
Network sandboxNetworkConfig `json:"network"`
AllowInternetAccess bool `json:"allowInternetAccess"`
EnvVars map[string]string `json:"envVars,omitempty"`
LastActiveAt time.Time `json:"lastActiveAt"`
VolumeMounts []volumeMountResponse `json:"volumeMounts"`
}

type volumeMountResponse struct {
Name string `json:"name"`
Path string `json:"path"`
}

type sandboxLifecycle struct {
Expand All @@ -89,22 +104,33 @@ type timeoutRequest struct {
Timeout int `json:"timeout"`
}

func createSandbox(manager sandboxManager, provider versionProvider, w http.ResponseWriter, r *http.Request) {
func createSandbox(manager sandboxManager, provider versionProvider, volumes *volumeStore, w http.ResponseWriter, r *http.Request) {
var req newSandboxRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.WriteError(w, http.StatusBadRequest, "invalid JSON body")
return
}

var volumeMounts []sandbox.VolumeMount
if len(req.VolumeMounts) > 0 {
resolved, err := resolveVolumeMounts(req.VolumeMounts, volumes)
if err != nil {
api.WriteError(w, http.StatusBadRequest, err.Error())
return
}
volumeMounts = resolved
}

sbx, err := manager.Create(r.Context(), sandbox.CreateOptions{
TemplateID: req.TemplateID,
Alias: req.Alias,
Metadata: req.Metadata,
EnvVars: req.EnvVars,
Timeout: time.Duration(req.Timeout) * time.Second,
OnTimeout: resolveOnTimeout(req),
CPUCount: req.CPUCount,
MemoryMB: req.MemoryMB,
TemplateID: req.TemplateID,
Alias: req.Alias,
Metadata: req.Metadata,
EnvVars: req.EnvVars,
Timeout: time.Duration(req.Timeout) * time.Second,
OnTimeout: resolveOnTimeout(req),
CPUCount: req.CPUCount,
MemoryMB: req.MemoryMB,
VolumeMounts: volumeMounts,
})
if err != nil {
api.WriteError(w, http.StatusInternalServerError, err.Error())
Expand Down Expand Up @@ -439,13 +465,13 @@ func getSandboxLogs(manager sandboxManager, w http.ResponseWriter, r *http.Reque
// ──────────────────────────────────────────────────────────────────────

type metricsEntry struct {
Timestamp time.Time `json:"timestamp"`
CPUCount int `json:"cpuCount"`
CPUUsedPct float64 `json:"cpuUsedPct"`
MemTotalMiB int64 `json:"memTotalMiB"`
MemUsedMiB int64 `json:"memUsedMiB"`
DiskTotalMiB int64 `json:"diskTotalMiB"`
DiskUsedMiB int64 `json:"diskUsedMiB"`
Timestamp time.Time `json:"timestamp"`
CPUCount int `json:"cpuCount"`
CPUUsedPct float64 `json:"cpuUsedPct"`
MemTotalMiB int64 `json:"memTotalMiB"`
MemUsedMiB int64 `json:"memUsedMiB"`
DiskTotalMiB int64 `json:"diskTotalMiB"`
DiskUsedMiB int64 `json:"diskUsedMiB"`
}

func getSandboxMetrics(manager sandboxManager, rt runtime.Runtime, w http.ResponseWriter, r *http.Request) {
Expand All @@ -465,11 +491,11 @@ func getSandboxMetrics(manager sandboxManager, rt runtime.Runtime, w http.Respon
return
}
writeJSON(w, http.StatusOK, []metricsEntry{{
Timestamp: time.Now(),
CPUUsedPct: stats.CPUUsedPercent,
MemTotalMiB: stats.MemoryLimitMB,
MemUsedMiB: stats.MemoryUsedMB,
DiskUsedMiB: stats.DiskUsedMB,
Timestamp: time.Now(),
CPUUsedPct: stats.CPUUsedPercent,
MemTotalMiB: stats.MemoryLimitMB,
MemUsedMiB: stats.MemoryUsedMB,
DiskUsedMiB: stats.DiskUsedMB,
}})
}

Expand Down Expand Up @@ -549,7 +575,8 @@ func toSandboxResponse(manager sandboxManager, provider versionProvider, sbx *sa
Domain: manager.Domain(),
Metadata: sbx.Metadata,
StartedAt: sbx.CreatedAt,
EndAt: sbx.ExpiresAt,
EndAt: sbx.ExpiresAt(),
LastActiveAt: sbx.LastActiveAt,
}
}

Expand All @@ -567,6 +594,8 @@ func toSandboxDetailResponse(manager sandboxManager, provider versionProvider, s
Network: sandboxNetworkConfig{AllowPublicTraffic: true, AllowOut: []string{}, DenyOut: []string{}},
AllowInternetAccess: true,
EnvVars: sbx.EnvVars,
LastActiveAt: sbx.LastActiveAt,
VolumeMounts: toVolumeMountResponse(sbx.VolumeMounts),
}
// Fall back to runtime stats for sandboxes created before this
// field existed (CPUCount/MemoryMB == 0 means "unlimited" on the
Expand All @@ -580,6 +609,17 @@ func toSandboxDetailResponse(manager sandboxManager, provider versionProvider, s
return resp
}

func toVolumeMountResponse(mounts []sandbox.VolumeMount) []volumeMountResponse {
if len(mounts) == 0 {
return []volumeMountResponse{}
}
out := make([]volumeMountResponse, len(mounts))
for i, m := range mounts {
out[i] = volumeMountResponse{Name: m.Name, Path: m.Path}
}
return out
}

// resolveOnTimeout picks the sandbox lifecycle mode from the incoming
// request. Both autoPause (legacy shorthand) and lifecycle.onTimeout
// are accepted; lifecycle.onTimeout wins when both are set, mirroring
Expand All @@ -599,3 +639,45 @@ func resolveOnTimeout(req newSandboxRequest) sandbox.OnTimeoutMode {
}
return sandbox.OnTimeoutKill
}

const maxVolumeMounts = 16

// resolveVolumeMounts validates each volumeMount entry and resolves
// the logical volume name to a Docker volume source. Returns an error
// if any mount is invalid or references an unknown volume.
func resolveVolumeMounts(mounts []sandboxVolumeMount, volumes *volumeStore) ([]sandbox.VolumeMount, error) {
if len(mounts) > maxVolumeMounts {
return nil, fmt.Errorf("too many volume mounts (max %d)", maxVolumeMounts)
}
seen := make(map[string]bool, len(mounts))
out := make([]sandbox.VolumeMount, 0, len(mounts))
for _, m := range mounts {
if m.Name == "" {
return nil, errors.New("volumeMount.name is required")
}
if m.Path == "" {
return nil, errors.New("volumeMount.path is required")
}
cleaned := path.Clean(m.Path)
if !path.IsAbs(cleaned) {
return nil, fmt.Errorf("volumeMount.path %q is not absolute", m.Path)
}
if cleaned == "/" {
return nil, errors.New("volumeMount.path cannot be /")
}
if seen[cleaned] {
return nil, fmt.Errorf("duplicate volumeMount.path %q", cleaned)
}
rec, ok := volumes.resolveByName(m.Name)
if !ok {
return nil, fmt.Errorf("volume %q not found", m.Name)
}
seen[cleaned] = true
out = append(out, sandbox.VolumeMount{
Name: m.Name,
Path: cleaned,
DockerName: dockerNameForVolumeID(rec.VolumeID),
})
}
return out, nil
}
Loading