Skip to content

Commit addc489

Browse files
feat(sdk): inject create-time env_vars into the guest via envd /init
Sandbox.create(env_vars=...) values were recorded as sandbox metadata but never loaded into the guest runtime, so commands.run / run_code could not see them. After the sandbox is created, the Python and Go SDKs push them into the guest through envd's native POST /init, reusing the same CubeProxy data-plane channel commands.run already uses. Precedence: template env < create env < per-command env. No guest rootfs writes or extra deployment config. /init uses bounded request timeouts, jittered retries, and prompt response cleanup on both SDKs. Signed-off-by: xiaojunxiang <xiaojunxiang@kingsoft.com>
1 parent 75b4765 commit addc489

5 files changed

Lines changed: 231 additions & 3 deletions

File tree

sdk/go/client.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ func (c *Client) Create(ctx context.Context, opts CreateOptions) (*Sandbox, erro
7474
return nil, err
7575
}
7676
c.attachSandbox(&sandbox)
77+
if len(opts.EnvVars) > 0 {
78+
if err := sandbox.initEnvVars(ctx, opts.EnvVars); err != nil {
79+
return nil, err
80+
}
81+
}
7782
return &sandbox, nil
7883
}
7984

sdk/go/envd.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"encoding/json"
1212
"fmt"
1313
"io"
14+
"math/rand"
1415
"net/http"
1516
"net/url"
1617
"strconv"
@@ -24,8 +25,18 @@ const (
2425
connectEndStreamFlag = byte(0x02)
2526
connectCompressedFlag = byte(0x01)
2627
maxConnectEnvelopeSize = 64 * 1024 * 1024
28+
29+
envdInitMaxAttempts = 5
30+
envdInitRetryDelay = 800 * time.Millisecond
31+
envdInitRetryJitter = 400 * time.Millisecond
32+
envdInitReqTimeout = 10 * time.Second
2733
)
2834

35+
type envdInitRequest struct {
36+
EnvVars map[string]string `json:"envVars"`
37+
Timestamp string `json:"timestamp"`
38+
}
39+
2940
type processStartRequest struct {
3041
Process processConfig `json:"process"`
3142
Stdin *bool `json:"stdin,omitempty"`
@@ -83,6 +94,62 @@ type connectError struct {
8394
Message string `json:"message,omitempty"`
8495
}
8596

97+
func (s *Sandbox) initEnvVars(ctx context.Context, envVars map[string]string) error {
98+
if err := s.ensureClient(); err != nil {
99+
return err
100+
}
101+
if len(envVars) == 0 {
102+
return nil
103+
}
104+
105+
raw, err := json.Marshal(envdInitRequest{
106+
EnvVars: envVars,
107+
Timestamp: time.Now().UTC().Format(time.RFC3339),
108+
})
109+
if err != nil {
110+
return err
111+
}
112+
113+
var lastErr error
114+
for attempt := 0; attempt < envdInitMaxAttempts; attempt++ {
115+
if attempt > 0 {
116+
delay := envdInitRetryDelay + time.Duration(rand.Int63n(int64(envdInitRetryJitter)))
117+
timer := time.NewTimer(delay)
118+
select {
119+
case <-ctx.Done():
120+
timer.Stop()
121+
return ctx.Err()
122+
case <-timer.C:
123+
}
124+
}
125+
126+
reqCtx, cancel := context.WithTimeout(ctx, envdInitReqTimeout)
127+
req, err := s.newEnvdRequest(reqCtx, http.MethodPost, "/init", nil, bytes.NewReader(raw))
128+
if err != nil {
129+
cancel()
130+
return err
131+
}
132+
req.Header.Set("Content-Type", "application/json")
133+
134+
resp, err := s.client.dataHTTP.Do(req)
135+
cancel()
136+
if err != nil {
137+
lastErr = err
138+
continue
139+
}
140+
141+
statusOK := resp.StatusCode < http.StatusBadRequest
142+
_, _ = io.Copy(io.Discard, resp.Body)
143+
resp.Body.Close()
144+
if statusOK {
145+
return nil
146+
}
147+
lastErr = fmt.Errorf("envd /init returned HTTP %d", resp.StatusCode)
148+
}
149+
150+
return fmt.Errorf("failed to inject create env_vars into sandbox: %w", lastErr)
151+
}
152+
86153
func (s *Sandbox) startProcess(ctx context.Context, payload processStartRequest, opts CommandOptions) (*processStartResult, error) {
87154
if err := s.ensureClient(); err != nil {
88155
return nil, err

sdk/go/sdk_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,25 @@ func TestNewConfigFromEnv(t *testing.T) {
7676

7777
func TestCreateSendsPythonCompatiblePayload(t *testing.T) {
7878
var got map[string]any
79+
var initPath string
80+
var initHost string
81+
var initBody map[string]any
82+
83+
dataServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
84+
if r.Method != http.MethodPost || r.URL.Path != "/init" {
85+
t.Fatalf("data request = %s %s", r.Method, r.URL.Path)
86+
}
87+
initPath = r.URL.Path
88+
initHost = r.Host
89+
if err := json.NewDecoder(r.Body).Decode(&initBody); err != nil {
90+
t.Fatalf("decode init body: %v", err)
91+
}
92+
w.WriteHeader(http.StatusOK)
93+
}))
94+
defer dataServer.Close()
95+
96+
dataHost, dataPort := serverHostPort(t, dataServer.URL)
97+
7998
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
8099
if r.Method != http.MethodPost || r.URL.Path != "/sandboxes" {
81100
t.Fatalf("request = %s %s", r.Method, r.URL.Path)
@@ -100,6 +119,8 @@ func TestCreateSendsPythonCompatiblePayload(t *testing.T) {
100119
Timeout: 300 * time.Second,
101120
RequestTimeout: time.Second,
102121
SandboxDomain: "cube.app",
122+
ProxyNodeIP: dataHost,
123+
ProxyPortHTTP: dataPort,
103124
})
104125

105126
sb, err := client.Create(context.Background(), CreateOptions{
@@ -136,6 +157,49 @@ func TestCreateSendsPythonCompatiblePayload(t *testing.T) {
136157
if _, ok := got["mcp"].(map[string]any); !ok {
137158
t.Fatalf("extra field not preserved: %#v", got["mcp"])
138159
}
160+
161+
if initPath != "/init" {
162+
t.Fatalf("init path=%q", initPath)
163+
}
164+
if initHost != fmt.Sprintf("%d-%s.cube.app", JupyterPort, testSandboxID) {
165+
t.Fatalf("init Host=%q", initHost)
166+
}
167+
assertMapString(t, initBody["envVars"], "FOO", "bar")
168+
if _, ok := initBody["timestamp"].(string); !ok {
169+
t.Fatalf("init timestamp missing: %#v", initBody["timestamp"])
170+
}
171+
}
172+
173+
func TestCreateWithoutEnvVarsSkipsInit(t *testing.T) {
174+
dataCalled := false
175+
dataServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
176+
dataCalled = true
177+
w.WriteHeader(http.StatusOK)
178+
}))
179+
defer dataServer.Close()
180+
181+
dataHost, dataPort := serverHostPort(t, dataServer.URL)
182+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
183+
w.Header().Set("Content-Type", "application/json")
184+
w.WriteHeader(http.StatusCreated)
185+
fmt.Fprint(w, sandboxJSON(testSandboxID, "tpl-no-init"))
186+
}))
187+
defer server.Close()
188+
189+
client := NewClient(Config{
190+
APIURL: server.URL,
191+
TemplateID: "tpl-no-init",
192+
Timeout: 300 * time.Second,
193+
RequestTimeout: time.Second,
194+
ProxyNodeIP: dataHost,
195+
ProxyPortHTTP: dataPort,
196+
})
197+
if _, err := client.Create(context.Background(), CreateOptions{}); err != nil {
198+
t.Fatalf("Create returned error: %v", err)
199+
}
200+
if dataCalled {
201+
t.Fatal("data plane was called without env_vars")
202+
}
139203
}
140204

141205
func TestCreateOmitsOptionalFieldsAndRequiresTemplate(t *testing.T) {

sdk/python/cubesandbox/sandbox.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33

44
from __future__ import annotations
55

6+
import random
7+
import time
8+
from datetime import datetime, timezone
69
from typing import Any, Callable, Dict
710

811
import httpx
912
import requests
1013

11-
from ._commands import CommandResult, Commands
14+
from ._commands import CommandResult, Commands, ENVD_PORT
1215
from ._config import Config
1316
from ._exceptions import ApiError, AuthenticationError, CubeSandboxError, SandboxNotFoundError, TemplateNotFoundError
1417
from ._filesystem import Filesystem
@@ -18,6 +21,10 @@
1821
from ._transport import build_client
1922

2023
JUPYTER_PORT = 49999
24+
ENVD_INIT_MAX_ATTEMPTS = 5
25+
ENVD_INIT_RETRY_BASE_SECS = 0.8
26+
ENVD_INIT_RETRY_JITTER_SECS = 0.4
27+
ENVD_INIT_REQ_TIMEOUT_SECS = 10.0
2128

2229

2330
def _check_response(resp: requests.Response) -> None:
@@ -156,7 +163,10 @@ def create(
156163
resp = s.post(f"{cfg.api_url}/sandboxes", json=payload,
157164
headers={"Content-Type": "application/json"})
158165
_check_response(resp)
159-
return cls(resp.json(), config=cfg)
166+
sandbox = cls(resp.json(), config=cfg)
167+
if env_vars:
168+
sandbox._init_env_vars(env_vars)
169+
return sandbox
160170

161171
@classmethod
162172
def connect(cls, sandbox_id: str, *, config: Config | None = None) -> "Sandbox":
@@ -667,3 +677,64 @@ def _build_session(self) -> requests.Session:
667677
def _build_data_client(self) -> httpx.Client:
668678
"""Build an HTTP client for CubeProxy-routed sandbox data-plane APIs."""
669679
return build_client(self._config)
680+
681+
def _init_env_vars(self, env_vars: Dict[str, str]) -> None:
682+
"""Make create-time env_vars visible to later commands.run / run_code.
683+
684+
The control plane only records env_vars as sandbox metadata; it never
685+
loads them into the guest runtime. So once the sandbox is up we push
686+
them into the guest via envd's native POST /init, reusing the exact
687+
CubeProxy data-plane channel commands.run already uses. envd stores them
688+
as global defaults and merges them into every later process execution,
689+
giving the precedence ``template env < create env < per-command env``.
690+
691+
Routing through the configured data-plane client means no extra
692+
deployment configuration is required: whatever address commands.run
693+
reaches envd on, /init reaches it on too.
694+
"""
695+
if not env_vars:
696+
return
697+
if self._client is None:
698+
self._client = self._build_data_client()
699+
700+
headers = {}
701+
access_token = self._data.get("envdAccessToken")
702+
if access_token:
703+
headers["X-Access-Token"] = access_token
704+
705+
url = f"http://{self.get_host(ENVD_PORT)}/init"
706+
body = {
707+
"envVars": env_vars,
708+
"timestamp": datetime.now(timezone.utc).isoformat(),
709+
}
710+
711+
# The proxy route to a freshly created sandbox may settle a moment after
712+
# create returns, so retry briefly before surfacing a hard failure.
713+
last_error: Exception | None = None
714+
for attempt in range(ENVD_INIT_MAX_ATTEMPTS):
715+
if attempt:
716+
delay = ENVD_INIT_RETRY_BASE_SECS + random.uniform(
717+
0, ENVD_INIT_RETRY_JITTER_SECS
718+
)
719+
time.sleep(delay)
720+
try:
721+
resp = self._client.post(
722+
url,
723+
json=body,
724+
headers=headers,
725+
timeout=ENVD_INIT_REQ_TIMEOUT_SECS,
726+
)
727+
try:
728+
if resp.status_code < 400:
729+
return
730+
last_error = RuntimeError(
731+
f"envd /init returned HTTP {resp.status_code}"
732+
)
733+
finally:
734+
resp.close()
735+
except BaseException as exc:
736+
if isinstance(exc, (KeyboardInterrupt, SystemExit)):
737+
raise
738+
last_error = exc
739+
740+
raise CubeSandboxError(f"failed to inject create env_vars into sandbox: {last_error}")

sdk/python/tests/test_sandbox.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,31 @@ def test_create_sends_template_and_timeout(self):
105105
assert body["timeout"] == 600
106106

107107
def test_create_sends_env_vars(self):
108-
with patch("requests.Session.post", return_value=mock_response(SANDBOX_DATA, status=201)) as m:
108+
captured: dict = {}
109+
110+
def handler(request: httpx.Request) -> httpx.Response:
111+
captured["url"] = str(request.url)
112+
captured["body"] = json.loads(request.content)
113+
return httpx.Response(200)
114+
115+
client = httpx.Client(transport=httpx.MockTransport(handler))
116+
with patch("requests.Session.post", return_value=mock_response(SANDBOX_DATA, status=201)) as m, \
117+
patch.object(Sandbox, "_build_data_client", return_value=client):
109118
Sandbox.create(env_vars={"FOO": "bar"}, config=make_config())
119+
110120
body = m.call_args.kwargs["json"]
111121
assert body["envVars"] == {"FOO": "bar"}
122+
# SDK transparently pushes create-time env_vars into the guest via
123+
# envd's native /init so later commands.run can read them.
124+
assert captured["url"].endswith("/init")
125+
assert captured["body"]["envVars"] == {"FOO": "bar"}
126+
127+
def test_create_without_env_vars_skips_init(self):
128+
build = MagicMock()
129+
with patch("requests.Session.post", return_value=mock_response(SANDBOX_DATA, status=201)), \
130+
patch.object(Sandbox, "_build_data_client", new=build):
131+
Sandbox.create(config=make_config())
132+
build.assert_not_called()
112133

113134
def test_create_sends_metadata(self):
114135
meta = {"network-policy": "deny-all"}

0 commit comments

Comments
 (0)