diff --git a/apps/web/src/i18n/locales/en-US/admin.json b/apps/web/src/i18n/locales/en-US/admin.json index 1d09f30..f111cd1 100644 --- a/apps/web/src/i18n/locales/en-US/admin.json +++ b/apps/web/src/i18n/locales/en-US/admin.json @@ -1425,8 +1425,8 @@ }, "sandboxSize": { "hint": "Changes take effect after the current sandbox is released.", - "standard": "Standard (4 vCPU / 8 GiB)", - "xl": "XL (4 vCPU / 50 GiB)" + "standard": "Standard (2 vCPU / 4 GiB)", + "xl": "Large (4 vCPU / 8 GiB)" }, "emptyModel": { "title": "No usable Model yet", diff --git a/apps/web/src/i18n/locales/zh-CN/admin.json b/apps/web/src/i18n/locales/zh-CN/admin.json index 3ac849b..00ad8eb 100644 --- a/apps/web/src/i18n/locales/zh-CN/admin.json +++ b/apps/web/src/i18n/locales/zh-CN/admin.json @@ -1425,8 +1425,8 @@ }, "sandboxSize": { "hint": "改完规格后,等当前沙盒被释放才会切换到新规格。", - "standard": "标准 (4 vCPU / 8 GiB)", - "xl": "XL (4 vCPU / 50 GiB)" + "standard": "标准 (2 vCPU / 4 GiB)", + "xl": "大规格 (4 vCPU / 8 GiB)" }, "emptyModel": { "title": "还没有可用 Model", diff --git a/server/cmd/server/sandbox_docker.go b/server/cmd/server/sandbox_docker.go index 4af0675..2938685 100644 --- a/server/cmd/server/sandbox_docker.go +++ b/server/cmd/server/sandbox_docker.go @@ -90,9 +90,11 @@ func agentDaemonWSURLFromBase(base string) string { // - AGENT_DAEMON_SANDBOX_DOCKER_IMAGE — local image tag to run. // - AGENT_DAEMON_SANDBOX_DOCKER_NETWORK — optional docker network to join // (use the compose network when the server runs as a compose service). -// - AGENT_DAEMON_SANDBOX_DOCKER_MEMORY / _CPUS — optional `docker run` -// resource caps; unset = built-in default (4g / 2 CPU). Set to -// 0/unlimited/none to remove the cap. +// - AGENT_DAEMON_SANDBOX_DOCKER_MEMORY / _CPUS — optional global +// `docker run` resource caps for every sandbox size. Unset = +// size-specific defaults below. Set to 0/unlimited/none to remove the cap. +// - AGENT_DAEMON_SANDBOX_DOCKER_STANDARD_MEMORY / _STANDARD_CPUS and +// _XL_MEMORY / _XL_CPUS — optional per-size overrides. // - AGENT_DAEMON_SANDBOX_DOCKER_PIDS_LIMIT — optional pids cap; unset = no // cap (docker default). func buildDockerAgentDaemonSandboxProvider( @@ -140,7 +142,7 @@ func buildDockerAgentDaemonSandboxProvider( Binder: binder, Bindings: dbStore, Template: image, - Templates: map[string]string{"standard": image}, + Templates: map[string]string{"standard": image, "xl": image}, DefaultSize: "standard", ServerURL: serverURL, OwnerChecker: dbStore, @@ -162,13 +164,13 @@ func buildDockerAgentDaemonSandboxProvider( return provider } -// Built-in sandbox caps used when the operator sets no override: a -// conservative default stops one runaway sandbox starving the host (raise it -// with the env var, or disable it with 0/unlimited). PidsLimit has no default -// — a low pids cap breaks parallel builds (`make -j`, `go test ./...`). +// Built-in sandbox caps used when the operator sets no override. PidsLimit has +// no default — a low pids cap breaks parallel builds (`make -j`, `go test ./...`). const ( - defaultDockerMemory = "4g" - defaultDockerCPUs = "2" + defaultDockerStandardMemory = "4g" + defaultDockerStandardCPUs = "2" + defaultDockerXLMemory = "8g" + defaultDockerXLCPUs = "4" ) // dockerClientFromEnv builds the docker sandbox client, resolving the @@ -176,16 +178,51 @@ const ( // fall back to the built-in default when unset, pids stays off; see // resolveDockerLimit for the 0/unlimited escape hatch. func dockerClientFromEnv(env func(string) string, image, network string, hostGateway bool) *dockersandbox.Client { + standardLimits, xlLimits := dockerLimitsFromEnv(env) return &dockersandbox.Client{ - Image: image, - Network: network, - HostGateway: hostGateway, - Memory: resolveDockerLimit(env("AGENT_DAEMON_SANDBOX_DOCKER_MEMORY"), defaultDockerMemory), - CPUs: resolveDockerLimit(env("AGENT_DAEMON_SANDBOX_DOCKER_CPUS"), defaultDockerCPUs), - PidsLimit: resolveDockerLimit(env("AGENT_DAEMON_SANDBOX_DOCKER_PIDS_LIMIT"), ""), + Image: image, + Network: network, + HostGateway: hostGateway, + Memory: standardLimits.Memory, + CPUs: standardLimits.CPUs, + PidsLimit: standardLimits.PidsLimit, + LimitsBySize: map[string]dockersandbox.ResourceLimits{"standard": standardLimits, "xl": xlLimits}, } } +func dockerLimitsFromEnv(env func(string) string) (standard dockersandbox.ResourceLimits, xl dockersandbox.ResourceLimits) { + globalMemory := strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_DOCKER_MEMORY")) + globalCPUs := strings.TrimSpace(env("AGENT_DAEMON_SANDBOX_DOCKER_CPUS")) + pidsLimit := resolveDockerLimit(env("AGENT_DAEMON_SANDBOX_DOCKER_PIDS_LIMIT"), "") + + standardMemoryDefault := defaultDockerStandardMemory + xlMemoryDefault := defaultDockerXLMemory + if globalMemory != "" { + resolved := resolveDockerLimit(globalMemory, "") + standardMemoryDefault = resolved + xlMemoryDefault = resolved + } + standardCPUsDefault := defaultDockerStandardCPUs + xlCPUsDefault := defaultDockerXLCPUs + if globalCPUs != "" { + resolved := resolveDockerLimit(globalCPUs, "") + standardCPUsDefault = resolved + xlCPUsDefault = resolved + } + + standard = dockersandbox.ResourceLimits{ + Memory: resolveDockerLimit(env("AGENT_DAEMON_SANDBOX_DOCKER_STANDARD_MEMORY"), standardMemoryDefault), + CPUs: resolveDockerLimit(env("AGENT_DAEMON_SANDBOX_DOCKER_STANDARD_CPUS"), standardCPUsDefault), + PidsLimit: pidsLimit, + } + xl = dockersandbox.ResourceLimits{ + Memory: resolveDockerLimit(env("AGENT_DAEMON_SANDBOX_DOCKER_XL_MEMORY"), xlMemoryDefault), + CPUs: resolveDockerLimit(env("AGENT_DAEMON_SANDBOX_DOCKER_XL_CPUS"), xlCPUsDefault), + PidsLimit: pidsLimit, + } + return standard, xl +} + // resolveDockerLimit resolves one resource cap: empty env → built-in default; // "0"/"unlimited"/"none" (case-insensitive) → "" so Create omits the flag // (docker's unbounded default); otherwise the literal value, passed through diff --git a/server/cmd/server/sandbox_docker_test.go b/server/cmd/server/sandbox_docker_test.go index 4a5f8d1..5c5a186 100644 --- a/server/cmd/server/sandbox_docker_test.go +++ b/server/cmd/server/sandbox_docker_test.go @@ -41,21 +41,45 @@ func TestDockerClientFromEnvReadsResourceLimits(t *testing.T) { if c.Memory != "2g" || c.CPUs != "1.5" || c.PidsLimit != "512" { t.Fatalf("resource limits not wired: %+v", c) } + if c.LimitsBySize["standard"].Memory != "2g" || c.LimitsBySize["xl"].Memory != "2g" { + t.Fatalf("global memory override should apply to every size: %+v", c.LimitsBySize) + } } func TestDockerClientFromEnvAppliesBuiltInDefaults(t *testing.T) { - // With the env unset the operator still gets a safe built-in cap (2 CPU / - // 4GB) so one runaway sandbox can't starve the host. PidsLimit stays unset: - // a low pids cap is a classic build-breaker (make -j, go test ./...). + // With the env unset the operator gets the smaller advertised standard size. + // PidsLimit stays unset: a low pids cap is a classic build-breaker + // (make -j, go test ./...). c := dockerClientFromEnv(func(string) string { return "" }, "img", "", false) if c.CPUs != "2" || c.Memory != "4g" { t.Fatalf("expected default 2 CPU / 4g, got cpus=%q memory=%q", c.CPUs, c.Memory) } + if xl := c.LimitsBySize["xl"]; xl.CPUs != "4" || xl.Memory != "8g" { + t.Fatalf("expected xl default 4 CPU / 8g, got %+v", xl) + } if c.PidsLimit != "" { t.Fatalf("expected pids-limit unset by default, got %q", c.PidsLimit) } } +func TestDockerClientFromEnvReadsPerSizeOverrides(t *testing.T) { + env := func(k string) string { + return map[string]string{ + "AGENT_DAEMON_SANDBOX_DOCKER_STANDARD_MEMORY": "6g", + "AGENT_DAEMON_SANDBOX_DOCKER_STANDARD_CPUS": "3", + "AGENT_DAEMON_SANDBOX_DOCKER_XL_MEMORY": "64g", + "AGENT_DAEMON_SANDBOX_DOCKER_XL_CPUS": "6", + }[k] + } + c := dockerClientFromEnv(env, "img", "", false) + if got := c.LimitsBySize["standard"]; got.Memory != "6g" || got.CPUs != "3" { + t.Fatalf("standard override not wired: %+v", got) + } + if got := c.LimitsBySize["xl"]; got.Memory != "64g" || got.CPUs != "6" { + t.Fatalf("xl override not wired: %+v", got) + } +} + func TestDockerClientFromEnvEscapeHatchDisablesDefault(t *testing.T) { // An operator who wants docker's unbounded default back sets the env to // 0/unlimited/none (case-insensitive, trimmed); the flag is then omitted diff --git a/server/internal/connector/agentdaemon/sandbox_provider.go b/server/internal/connector/agentdaemon/sandbox_provider.go index b1964a0..fd43e66 100644 --- a/server/internal/connector/agentdaemon/sandbox_provider.go +++ b/server/internal/connector/agentdaemon/sandbox_provider.go @@ -630,6 +630,7 @@ func (p *E2BSandboxProvider) coldStart(ctx context.Context, in connector.PromptI "parsar.agent_id": in.AgentID, "parsar.device_id": deviceID, "parsar.sandbox_kind": "agent_daemon_claude_code", + "parsar.sandbox_size": resolvedSize, }, }) if err != nil { diff --git a/server/internal/dev/routes_agents.go b/server/internal/dev/routes_agents.go index fde97e6..16c3acf 100644 --- a/server/internal/dev/routes_agents.go +++ b/server/internal/dev/routes_agents.go @@ -398,6 +398,7 @@ func createAgent(runtimeStore RuntimeStore, agentDaemonSandbox AgentDaemonSandbo deviceID, err := agentDaemonSandbox.Acquire(ctx, connector.PromptInput{ AgentID: paID, WorkspaceID: workspaceID, + AgentConfig: result.Agent.Config, }) if err != nil { log.Bg().Warn("eager sandbox acquire failed", diff --git a/server/internal/dev/sandbox_admin.go b/server/internal/dev/sandbox_admin.go index f1298d8..994d16f 100644 --- a/server/internal/dev/sandbox_admin.go +++ b/server/internal/dev/sandbox_admin.go @@ -16,9 +16,9 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/obs/log" - "github.com/go-chi/chi/v5" "github.com/MiniMax-AI-Dev/parsar/server/internal/connector" "github.com/MiniMax-AI-Dev/parsar/server/internal/store" + "github.com/go-chi/chi/v5" ) // SandboxBindingStore is the data-layer dependency for admin sandbox @@ -45,17 +45,17 @@ type sandboxAdminDeps struct { // sandboxStatusResponse is the GET response shape. type sandboxStatusResponse struct { - BindingID string `json:"binding_id"` - WorkspaceID string `json:"workspace_id"` - AgentID *string `json:"agent_id"` - Name *string `json:"name,omitempty"` - SandboxID string `json:"sandbox_id"` - TemplateID string `json:"template_id"` - Status string `json:"status"` - StatusKind string `json:"status_kind"` - CreatedAt time.Time `json:"created_at"` - LastActiveAt time.Time `json:"last_active_at"` - KilledAt *time.Time `json:"killed_at,omitempty"` + BindingID string `json:"binding_id"` + WorkspaceID string `json:"workspace_id"` + AgentID *string `json:"agent_id"` + Name *string `json:"name,omitempty"` + SandboxID string `json:"sandbox_id"` + TemplateID string `json:"template_id"` + Status string `json:"status"` + StatusKind string `json:"status_kind"` + CreatedAt time.Time `json:"created_at"` + LastActiveAt time.Time `json:"last_active_at"` + KilledAt *time.Time `json:"killed_at,omitempty"` // ExpiresAt is fetched live from the e2b control plane. Nil when // the binding isn't in this pod's cache or the lookup failed. ExpiresAt *time.Time `json:"expires_at,omitempty"` @@ -243,9 +243,9 @@ func rebuildSandbox(deps sandboxAdminDeps, runtimeStore RuntimeStore, daemonMgr } if !found { writeJSON(w, http.StatusNotFound, map[string]string{ - "error": "no active sandbox binding to rebuild", - "workspace_id": workspaceID, - "agent_id": agentID, + "error": "no active sandbox binding to rebuild", + "workspace_id": workspaceID, + "agent_id": agentID, }) return } @@ -295,9 +295,9 @@ func rebuildSandbox(deps sandboxAdminDeps, runtimeStore RuntimeStore, daemonMgr } if runtimeStore != nil { if _, bindErr := runtimeStore.SetAgentRuntime(ctx, store.SetAgentRuntimeInput{ - WorkspaceID: workspaceID, - AgentID: agentID, - RuntimeID: deviceID, + WorkspaceID: workspaceID, + AgentID: agentID, + RuntimeID: deviceID, }); bindErr != nil { log.Bg().Error("sandbox rebuild: re-acquired but runtime_id persist failed", "agent_id", agentID, @@ -362,9 +362,9 @@ func renewSandbox(deps sandboxAdminDeps, daemonMgr AgentDaemonSandboxManager) ht } if !found { writeJSON(w, http.StatusNotFound, map[string]string{ - "error": "no active sandbox binding to renew", - "workspace_id": workspaceID, - "agent_id": agentID, + "error": "no active sandbox binding to renew", + "workspace_id": workspaceID, + "agent_id": agentID, }) return } @@ -381,9 +381,9 @@ func renewSandbox(deps sandboxAdminDeps, daemonMgr AgentDaemonSandboxManager) ht // This pod's cache doesn't own the binding; UI poll will // pick up the new owner within ~15s. writeJSON(w, http.StatusConflict, map[string]string{ - "error": "sandbox not owned by this pod; refresh and retry", - "workspace_id": workspaceID, - "agent_id": agentID, + "error": "sandbox not owned by this pod; refresh and retry", + "workspace_id": workspaceID, + "agent_id": agentID, }) return } @@ -437,12 +437,19 @@ func acquireSandbox(deps sandboxAdminDeps, runtimeStore RuntimeStore, provider A if _, found, _ := deps.store.GetActiveSandboxBindingForAgent( r.Context(), workspaceID, agentID); found { writeJSON(w, http.StatusOK, map[string]string{ - "status": "already_bound", + "status": "already_bound", "agent_id": agentID, }) return } } + var agentConfig map[string]any + if detail, detailErr := deps.store.GetAgentDetail(r.Context(), agentID); detailErr == nil { + agentConfig = detail.Config + } else { + log.Bg().Warn("sandbox acquire (manual): load agent config failed; acquire will use default sandbox size", + "agent_id", agentID, "err", detailErr) + } // Fire-and-forget: return 202 immediately. On success, persist // the new deviceID to agents.runtime_id. go func() { @@ -451,6 +458,7 @@ func acquireSandbox(deps sandboxAdminDeps, runtimeStore RuntimeStore, provider A deviceID, err := provider.Acquire(ctx, connector.PromptInput{ AgentID: agentID, WorkspaceID: workspaceID, + AgentConfig: agentConfig, }) if err != nil { log.Bg().Warn("sandbox acquire (manual) failed", @@ -459,9 +467,9 @@ func acquireSandbox(deps sandboxAdminDeps, runtimeStore RuntimeStore, provider A } if runtimeStore != nil { if _, bindErr := runtimeStore.SetAgentRuntime(ctx, store.SetAgentRuntimeInput{ - WorkspaceID: workspaceID, - AgentID: agentID, - RuntimeID: deviceID, + WorkspaceID: workspaceID, + AgentID: agentID, + RuntimeID: deviceID, }); bindErr != nil { log.Bg().Error("sandbox acquire (manual) succeeded but runtime_id persist failed", "agent_id", agentID, @@ -474,7 +482,7 @@ func acquireSandbox(deps sandboxAdminDeps, runtimeStore RuntimeStore, provider A "agent_id", agentID, "device_id", deviceID) }() writeJSON(w, http.StatusAccepted, map[string]string{ - "status": "provisioning", + "status": "provisioning", "agent_id": agentID, }) } @@ -501,9 +509,9 @@ func performLifecycleAction(w http.ResponseWriter, r *http.Request, deps sandbox } if !found { writeJSON(w, http.StatusNotFound, map[string]string{ - "error": "no active sandbox binding to act on", - "workspace_id": workspaceID, - "agent_id": agentID, + "error": "no active sandbox binding to act on", + "workspace_id": workspaceID, + "agent_id": agentID, }) return } @@ -531,9 +539,9 @@ func performLifecycleAction(w http.ResponseWriter, r *http.Request, deps sandbox // next dispatch experience — never block the kill response on it. if runtimeStore != nil { if _, clearErr := runtimeStore.SetAgentRuntime(r.Context(), store.SetAgentRuntimeInput{ - WorkspaceID: workspaceID, - AgentID: agentID, - RuntimeID: "", + WorkspaceID: workspaceID, + AgentID: agentID, + RuntimeID: "", }); clearErr != nil { log.Bg().Warn("sandbox kill: clear agent runtime_id failed (next dispatch may target dead device)", "agent_id", agentID, "err", clearErr) @@ -559,18 +567,18 @@ func toSandboxStatusResponse(b store.SandboxBindingRead) sandboxStatusResponse { kind = "terminal" } return sandboxStatusResponse{ - BindingID: b.ID, - WorkspaceID: b.WorkspaceID, - AgentID: b.AgentID, - Name: b.Name, - SandboxID: b.SandboxID, - TemplateID: b.TemplateID, - Status: b.Status, - StatusKind: kind, - CreatedAt: b.CreatedAt, - LastActiveAt: b.LastActiveAt, - KilledAt: b.KilledAt, - Metadata: b.Metadata, + BindingID: b.ID, + WorkspaceID: b.WorkspaceID, + AgentID: b.AgentID, + Name: b.Name, + SandboxID: b.SandboxID, + TemplateID: b.TemplateID, + Status: b.Status, + StatusKind: kind, + CreatedAt: b.CreatedAt, + LastActiveAt: b.LastActiveAt, + KilledAt: b.KilledAt, + Metadata: b.Metadata, } } diff --git a/server/internal/dev/sandbox_admin_test.go b/server/internal/dev/sandbox_admin_test.go index 9d1c8b1..b8a49c8 100644 --- a/server/internal/dev/sandbox_admin_test.go +++ b/server/internal/dev/sandbox_admin_test.go @@ -127,6 +127,7 @@ type fakeDaemonManager struct { // when unset. acquireDeviceID string acquireCalls []string + acquireInputs []connector.PromptInput } func (f *fakeDaemonManager) Acquire(_ context.Context, in connector.PromptInput) (string, error) { @@ -136,10 +137,19 @@ func (f *fakeDaemonManager) Acquire(_ context.Context, in connector.PromptInput) device = "fake-device" } f.acquireCalls = append(f.acquireCalls, in.AgentID) + f.acquireInputs = append(f.acquireInputs, in) f.mu.Unlock() return device, nil } +func (f *fakeDaemonManager) inputs() []connector.PromptInput { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]connector.PromptInput, len(f.acquireInputs)) + copy(out, f.acquireInputs) + return out +} + func (f *fakeDaemonManager) SandboxStatus(_ context.Context, _ string) (connector.SandboxInfo, bool, error) { return connector.SandboxInfo{}, false, nil } @@ -333,7 +343,15 @@ func TestSandboxAdminRebuildKillsAndReProvisions(t *testing.T) { SandboxID: "sbx_abc", Status: store.SandboxBindingStatusActive, } - storeFake := &fakeSandboxBindingStore{binding: &binding} + storeFake := &fakeSandboxBindingStore{ + binding: &binding, + agentDetails: map[string]store.AgentStatusRead{ + "00000000-0000-0000-0000-000000000009": { + AgentID: "00000000-0000-0000-0000-000000000009", + Config: map[string]any{"sandbox_size": "xl"}, + }, + }, + } daemonFake := &fakeDaemonManager{acquireDeviceID: "device-new"} runtimeFake := &recordingRuntimeStore{} deps := sandboxAdminDeps{store: storeFake, daemonMgr: daemonFake} @@ -370,6 +388,10 @@ func TestSandboxAdminRebuildKillsAndReProvisions(t *testing.T) { if calls[0].RuntimeID != "device-new" { t.Errorf("rebuild should write the new device id; got %q", calls[0].RuntimeID) } + inputs := daemonFake.inputs() + if len(inputs) != 1 || inputs[0].AgentConfig["sandbox_size"] != "xl" { + t.Fatalf("rebuild should pass agent config to Acquire; got %+v", inputs) + } } func TestSandboxAdminListReturnsActiveBindings(t *testing.T) { @@ -531,7 +553,14 @@ func TestSandboxAdminAcquireWritesRuntimeIDOnSuccess(t *testing.T) { // No active binding → handler should kick off Acquire in a goroutine // and, on success, persist the new device id to // agents.runtime_id. - storeFake := &fakeSandboxBindingStore{} // no binding + storeFake := &fakeSandboxBindingStore{ + agentDetails: map[string]store.AgentStatusRead{ + "00000000-0000-0000-0000-000000000009": { + AgentID: "00000000-0000-0000-0000-000000000009", + Config: map[string]any{"sandbox_size": "xl"}, + }, + }, + } // no binding daemonFake := &fakeDaemonManager{acquireDeviceID: "device-fresh"} runtimeFake := &recordingRuntimeStore{} deps := sandboxAdminDeps{store: storeFake, daemonMgr: daemonFake} @@ -561,4 +590,8 @@ func TestSandboxAdminAcquireWritesRuntimeIDOnSuccess(t *testing.T) { if calls[0].AgentID != "00000000-0000-0000-0000-000000000009" { t.Errorf("SetAgentRuntime should target the agent from URL; got %q", calls[0].AgentID) } + inputs := daemonFake.inputs() + if len(inputs) != 1 || inputs[0].AgentConfig["sandbox_size"] != "xl" { + t.Fatalf("manual acquire should pass agent config to Acquire; got %+v", inputs) + } } diff --git a/server/internal/sandbox/docker/client.go b/server/internal/sandbox/docker/client.go index a071243..caa414e 100644 --- a/server/internal/sandbox/docker/client.go +++ b/server/internal/sandbox/docker/client.go @@ -37,10 +37,17 @@ type Client struct { // when non-empty (empty = flag omitted, docker's default). A malformed // value makes `docker run` exit non-zero, which Create surfaces as an // error, so no pre-validation is needed here. - Memory string // --memory, e.g. "2g" - CPUs string // --cpus, e.g. "1.5" - PidsLimit string // --pids-limit, e.g. "512" - runner runnerFunc + Memory string // --memory, e.g. "2g" + CPUs string // --cpus, e.g. "1.5" + PidsLimit string // --pids-limit, e.g. "512" + LimitsBySize map[string]ResourceLimits + runner runnerFunc +} + +type ResourceLimits struct { + Memory string + CPUs string + PidsLimit string } func (c *Client) Create(ctx context.Context, input e2b.CreateInput) (e2b.Sandbox, error) { @@ -51,13 +58,14 @@ func (c *Client) Create(ctx context.Context, input e2b.CreateInput) (e2b.Sandbox if c.HostGateway { args = append(args, "--add-host", "host.docker.internal:host-gateway") } - if m := strings.TrimSpace(c.Memory); m != "" { + limits := c.limitsFor(input) + if m := strings.TrimSpace(limits.Memory); m != "" { args = append(args, "--memory", m) } - if cpus := strings.TrimSpace(c.CPUs); cpus != "" { + if cpus := strings.TrimSpace(limits.CPUs); cpus != "" { args = append(args, "--cpus", cpus) } - if pids := strings.TrimSpace(c.PidsLimit); pids != "" { + if pids := strings.TrimSpace(limits.PidsLimit); pids != "" { args = append(args, "--pids-limit", pids) } for k, v := range input.Metadata { @@ -85,6 +93,20 @@ func (c *Client) Create(ctx context.Context, input e2b.CreateInput) (e2b.Sandbox return e2b.Sandbox{SandboxID: id}, nil } +func (c *Client) limitsFor(input e2b.CreateInput) ResourceLimits { + size := strings.TrimSpace(input.Metadata["parsar.sandbox_size"]) + if size != "" && c.LimitsBySize != nil { + if limits, ok := c.LimitsBySize[size]; ok { + return limits + } + } + return ResourceLimits{ + Memory: c.Memory, + CPUs: c.CPUs, + PidsLimit: c.PidsLimit, + } +} + // RunCommand execs into the container and returns the command's exit code as // Status (Create/Kill treat non-zero as failure; here it's the payload). Note // a docker-level launch failure — e.g. the container is gone — surfaces as diff --git a/server/internal/sandbox/docker/client_test.go b/server/internal/sandbox/docker/client_test.go index 645c3c8..7fb1c23 100644 --- a/server/internal/sandbox/docker/client_test.go +++ b/server/internal/sandbox/docker/client_test.go @@ -128,6 +128,33 @@ func TestCreateAppliesNetworkHostGatewayEnvAndLabels(t *testing.T) { } } +func TestCreateAppliesSizeSpecificResourceLimits(t *testing.T) { + var got recordedCall + fake := &fakeRunner{handler: func(call recordedCall) (execResult, error) { + got = call + return execResult{Stdout: "cid\n"}, nil + }} + client := &Client{ + Image: "img", + Memory: "4g", + CPUs: "2", + runner: fake.run, + LimitsBySize: map[string]ResourceLimits{ + "xl": {Memory: "8g", CPUs: "4"}, + }, + } + _, err := client.Create(context.Background(), e2b.CreateInput{ + Metadata: map[string]string{"parsar.sandbox_size": "xl"}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + joined := strings.Join(got.Args, " ") + if !strings.Contains(joined, "--memory 8g") || !strings.Contains(joined, "--cpus 4") { + t.Fatalf("expected xl resource limits, got %v", got.Args) + } +} + func TestKillRemovesContainer(t *testing.T) { fake := &fakeRunner{} client := &Client{Image: "img", runner: fake.run}