From 0f84bdc88a715e1a3cb31946414a77c393510458 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 23 Sep 2026 11:42:45 +0800 Subject: [PATCH 1/2] sdk: add Renew to the Go and Python SDKs POST /v1/sandboxes/{id}/renew had no SDK wrapper, so a client holding a sandbox past its claim TTL had to hand-roll the request. Renew sends the sandbox's own token, returns the granted deadline and stores it on the handle; the e2e test pins the wire mirror against the real server. --- README.md | 2 +- docs/sdk-python.md | 23 ++++++++++-- docs/sdk.md | 23 ++++++++++-- e2e/e2e_test.go | 33 ++++++++++++++++ sdk/go/README.md | 2 +- sdk/go/client.go | 8 ++++ sdk/go/sandbox.go | 14 +++++++ sdk/go/sandbox_test.go | 58 +++++++++++++++++++++++++++++ sdk/python/README.md | 3 +- sdk/python/cocoonsandbox/sandbox.py | 9 +++++ sdk/python/tests/conftest.py | 2 + sdk/python/tests/test_client.py | 28 +++++++++++++- 12 files changed, 193 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ed5340ec..56debd9d 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ performance) — source in reconcile, memberlist mesh with redirect placement - `sdk/go/` — Go SDK: `Connect/New/Lookup`, `Exec/Run`, files, `Push/Pull`, sessions, `Find/Replace`, `Watch`, git verbs, `OpenPty`, - `Fork/Hibernate/Promote/Checkpoint`, `DialPort/ProxyPort/PreviewURL`, + `Renew/Fork/Hibernate/Promote/Checkpoint`, `DialPort/ProxyPort/PreviewURL`, `StartLsp`, `Spawn/Ps/Kill/Logs/Attach`; `protocol/wire` carries the frame vocabulary, `sdk/go/silkd` the conn layer, `silkdtest` a test fake - `sdk/python/` — Python SDK (stdlib-only, sync), matching the Go guest and diff --git a/docs/sdk-python.md b/docs/sdk-python.md index 121cdf0b..9b3a0eb5 100644 --- a/docs/sdk-python.md +++ b/docs/sdk-python.md @@ -159,6 +159,20 @@ peer advertising both the template and every volume; when that intersection is empty, one volume holder may self-verify access to a shared template store before provisioning. +## Renewing + +```python +sb.renew(3600) # the lease now ends an hour from now +``` + +`renew` resets the lease to `ttl_seconds` from now and returns the granted +deadline, which it also stores in `sb.deadline`. 0 asks for the node default +of 5m, and the node caps the TTL at 24h. The grant is authoritative, and a +renew can shorten a lease as well as extend it. `renew` authenticates with the +sandbox's own token, so a handle from `client.lookup` can renew. An archived +sandbox is refused with an `APIError` of status 409: any call that reaches the +guest wakes it, and then it can renew. + ## Hibernating ```python @@ -170,8 +184,8 @@ sb.exec("cat", "/tmp/state") # the next call that reaches the guest wak stays valid: the first call that reaches the guest restores the VM (roughly a restore's latency, tens of milliseconds on bare metal). The TTL keeps running — a hibernated sandbox is still reaped at its deadline, so claim -with a `ttl_seconds` that covers the idle period. When to hibernate is your -policy, unless the deployment opts into `idle_hibernate_seconds` +with a `ttl_seconds` that covers the idle period or `renew` before it ends. +When to hibernate is your policy, unless the deployment opts into `idle_hibernate_seconds` ([deploy](deploy.md#configuration)), which hibernates idle claims automatically with the same transparent wake. A claim with a connection live when the sweep checks it (a relay stream, a buffered exec, a preview dial, an @@ -193,8 +207,9 @@ the original claim deadline with the archive-retention deadline (or no deadline when archives are kept forever). Waking an archive starts a fresh lease of the length the claim was granted (the server default when it asked for none). The existing handle's `sb.deadline` remains the value -returned when that handle was created; call `client.sandboxes()` to read the -current server deadline after an archive/wake transition. +returned when that handle was created or last renewed; call +`client.sandboxes()` to read the current server deadline after an +archive/wake transition. ## Forking diff --git a/docs/sdk.md b/docs/sdk.md index 95cb8e44..e2054f34 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -263,6 +263,20 @@ peer advertising both the template and every volume; when that intersection is empty, one volume holder may self-verify access to a shared template store before provisioning. +## Renewing + +```go +deadline, err := sb.Renew(ctx, time.Hour) // the lease now ends an hour from now +``` + +`Renew` resets the lease to the given TTL from now and returns the granted +deadline, which it also stores in `Sandbox.Deadline`. Zero asks for the server +default of 5m; the TTL is rounded up to seconds and capped at 24h, as with +`WithTimeout`. The grant is authoritative, and a renew can shorten a lease as +well as extend it. `Renew` authenticates with the sandbox's own token, so a +handle from `Lookup` can renew. An archived sandbox is refused with a 409 +`*APIError`: any call that reaches the guest wakes it, and then it can renew. + ## Hibernating ```go @@ -276,8 +290,8 @@ guest does can fall between the snapshot point and the stop. The handle stays valid: the first call that reaches the guest restores the VM (adding roughly a restore's latency, tens of milliseconds on bare metal). The TTL keeps running — a hibernated sandbox is still reaped at its deadline, so -claim with a `WithTimeout` that covers the idle period. When to hibernate -is your policy; the node only provides the transition — unless the +claim with a `WithTimeout` that covers the idle period or `Renew` before it +ends. When to hibernate is your policy; the node only provides the transition — unless the deployment opts into `idle_hibernate_seconds` (see [deploy](deploy.md#configuration)), which hibernates idle claims automatically with the same transparent wake. A claim with a connection live @@ -289,8 +303,9 @@ the original claim deadline with the archive-retention deadline (or no deadline when archives are kept forever). Waking an archive starts a fresh lease of the length the claim was granted (the server default when it asked for none). The existing handle's `Sandbox.Deadline` is the value -returned when that handle was created; call `Client.Sandboxes` to read the -current server deadline after an archive/wake transition. +returned when that handle was created or last renewed; call +`Client.Sandboxes` to read the current server deadline after an +archive/wake transition. ## Forking diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index fc49d0c2..bf9b346c 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -158,6 +158,39 @@ func TestPromoteEndToEnd(t *testing.T) { } } +func TestRenewEndToEnd(t *testing.T) { + stack := startStack(t, "node-token") + sb, err := stack.client.New(t.Context(), "rt:24.04") + if err != nil { + t.Fatalf("New: %v", err) + } + defer sb.Close() + + longer, err := sb.Renew(t.Context(), time.Hour) + if err != nil { + t.Fatalf("Renew: %v", err) + } + if left := time.Until(longer); left < 59*time.Minute || left > time.Hour { + t.Errorf("an hour's renew left %v on the lease", left) + } + if !sb.Deadline.Equal(longer) { + t.Errorf("handle deadline %v, want the grant %v", sb.Deadline, longer) + } + code, raw := rawJSON(t, stack, http.MethodGet, "/v1/sandboxes/"+sb.ID, "") + var held types.Sandbox + if code != http.StatusOK || json.Unmarshal(raw, &held) != nil || !held.Deadline.Equal(longer) { + t.Errorf("node holds %s (HTTP %d), want deadline %v", raw, code, longer) + } + + shorter, err := sb.Renew(t.Context(), 2*time.Minute) + if err != nil { + t.Fatalf("shortening Renew: %v", err) + } + if !shorter.Before(longer) { + t.Errorf("a two-minute renew granted %v, not before the hour's %v", shorter, longer) + } +} + func TestCheckpointEndToEnd(t *testing.T) { stack := startStack(t, "node-token") src, err := stack.client.New(t.Context(), "rt:24.04") diff --git a/sdk/go/README.md b/sdk/go/README.md index c31b7287..b658ce13 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -11,7 +11,7 @@ out, _ := sb.Exec(ctx, "echo", "hello") ``` - `client.go` — claim/redirect follow, `Lookup` scatter, template delete -- `sandbox.go` — exec/run, fork, hibernate (transparent wake) +- `sandbox.go` — exec/run, renew, fork, hibernate (transparent wake) - `session.go` — persistent shell sessions - `files.go` / `tree.go` — streaming file verbs, tar push/pull - `port.go` — `DialPort`/`ProxyPort` (net.Conn over the relay), `PreviewURL` diff --git a/sdk/go/client.go b/sdk/go/client.go index 67303c22..7755b121 100644 --- a/sdk/go/client.go +++ b/sdk/go/client.go @@ -61,6 +61,14 @@ type promoteResponse struct { ContentDigest string `json:"content_digest"` } +type renewRequest struct { + TTLSeconds int `json:"ttl_seconds,omitzero"` +} + +type renewResponse struct { + Deadline time.Time `json:"deadline"` +} + type errorResponse struct { Error string `json:"error"` } diff --git a/sdk/go/sandbox.go b/sdk/go/sandbox.go index b3f72d93..9fb2c335 100644 --- a/sdk/go/sandbox.go +++ b/sdk/go/sandbox.go @@ -199,6 +199,20 @@ func (s *Sandbox) Hibernate(ctx context.Context) error { return doNoContent(ctx, s.c, http.MethodPost, s.owner, "/v1/sandboxes/"+s.ID+"/hibernate", nil, s.token, "hibernate") } +// Renew resets the lease to ttl from now (zero means the server default) and records the granted deadline in Deadline. +func (s *Sandbox) Renew(ctx context.Context, ttl time.Duration) (time.Time, error) { + body, err := encodeBody("renew", renewRequest{TTLSeconds: ttlSeconds(ttl)}) + if err != nil { + return time.Time{}, err + } + rr, err := doJSON[renewResponse](ctx, s.c, http.MethodPost, s.owner, "/v1/sandboxes/"+s.ID+"/renew", bytes.NewReader(body), s.token, "renew") + if err != nil { + return time.Time{}, err + } + s.Deadline = rr.Deadline + return rr.Deadline, nil +} + // Close releases the sandbox on its node; releasing one already gone is not // an error. It takes no ctx so it stays defer-friendly — bounded internally. func (s *Sandbox) Close() error { diff --git a/sdk/go/sandbox_test.go b/sdk/go/sandbox_test.go index 6aa5d8c6..338b611f 100644 --- a/sdk/go/sandbox_test.go +++ b/sdk/go/sandbox_test.go @@ -181,6 +181,64 @@ func TestDialAgentRejectsControlChars(t *testing.T) { } } +func TestRenewSendsTheSandboxTokenAndRecordsTheGrant(t *testing.T) { + granted := time.Date(2026, 9, 23, 12, 0, 0, 0, time.UTC) + tests := []struct { + name string + ttl time.Duration + body string + }{ + {"explicit ttl", 90 * time.Second, `{"ttl_seconds":90}`}, + {"server default", 0, `{}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1/sandboxes/sb_1/renew" { + t.Errorf("got %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer tok" { + t.Errorf("authorization %q, want the sandbox's own token", got) + } + if body, _ := io.ReadAll(r.Body); strings.TrimSpace(string(body)) != tt.body { + t.Errorf("body %s, want %s", body, tt.body) + } + _, _ = io.WriteString(w, `{"deadline":"2026-09-23T12:00:00Z"}`) + })) + t.Cleanup(ts.Close) + c := testClient(t, ts, WithAPIToken("api")) + sb := &Sandbox{ID: "sb_1", token: "tok", owner: c.addr, c: c} + + got, err := sb.Renew(t.Context(), tt.ttl) + if err != nil { + t.Fatalf("Renew: %v", err) + } + if !got.Equal(granted) || !sb.Deadline.Equal(granted) { + t.Errorf("returned %v, handle %v, want %v", got, sb.Deadline, granted) + } + }) + } +} + +func TestRenewRefusalLeavesTheDeadline(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = io.WriteString(w, `{"error":"sandbox archived"}`) + })) + t.Cleanup(ts.Close) + c := testClient(t, ts) + before := time.Date(2026, 9, 23, 11, 0, 0, 0, time.UTC) + sb := &Sandbox{ID: "sb_1", Deadline: before, token: "tok", owner: c.addr, c: c} + + _, err := sb.Renew(t.Context(), time.Minute) + if apiErr, ok := errors.AsType[*APIError](err); !ok || apiErr.Status != http.StatusConflict { + t.Fatalf("Renew error %v, want a 409 APIError", err) + } + if !sb.Deadline.Equal(before) { + t.Errorf("a refused renew moved the handle to %v", sb.Deadline) + } +} + func newAgentServer(t *testing.T, serve func(net.Conn)) *httptest.Server { t.Helper() mux := http.NewServeMux() diff --git a/sdk/python/README.md b/sdk/python/README.md index e2461c25..1363a017 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -19,7 +19,8 @@ dependencies. The surface mirrors the Go SDK (files and tar streams as whole `bytes` values): exec/run, file read/write, tar push/pull, find/replace, watch, persistent sessions, git verbs, pty, port dial/proxy/preview URLs, background processes (spawn/ps/kill/ -logs/attach), fork, hibernate, promote, checkpoints, and the LSP broker. +logs/attach), renew, fork, hibernate, promote, checkpoints, and the LSP +broker. Wire fidelity is pinned by the shared protocol fixture corpus (Rust + Go + Python all round-trip it in CI). diff --git a/sdk/python/cocoonsandbox/sandbox.py b/sdk/python/cocoonsandbox/sandbox.py index 29ea2434..6f1f1716 100644 --- a/sdk/python/cocoonsandbox/sandbox.py +++ b/sdk/python/cocoonsandbox/sandbox.py @@ -307,6 +307,15 @@ def hibernate(self) -> None: self.owner, "POST", f"/v1/sandboxes/{self.id}/hibernate", None, "hibernate", bearer=self.token ) + def renew(self, ttl_seconds: int = 0) -> str: + """Resets the lease to ttl_seconds from now (0 means the node default) and records the granted deadline.""" + body = {"ttl_seconds": ttl_seconds} if ttl_seconds else None + reply = self._client._request( + self.owner, "POST", f"/v1/sandboxes/{self.id}/renew", body, "renew", bearer=self.token + ) + self.deadline = cast(str, require_field(reply, "deadline", "renew")) + return self.deadline + def checkpoint(self, name: str = "") -> Checkpoint: """Captures a branchable snapshot without stopping the sandbox.""" body = {"token": self.token} diff --git a/sdk/python/tests/conftest.py b/sdk/python/tests/conftest.py index 4e6cbd22..3fcc0fd5 100644 --- a/sdk/python/tests/conftest.py +++ b/sdk/python/tests/conftest.py @@ -15,6 +15,7 @@ class FakeNode(BaseHTTPRequestHandler): routes = {} + last_headers = {} def do_POST(self): self._dispatch("POST") @@ -29,6 +30,7 @@ def log_message(self, *args): pass def _dispatch(self, method): + FakeNode.last_headers = dict(self.headers) length = int(self.headers.get("Content-Length") or 0) body = json.loads(self.rfile.read(length)) if length else {} handler = self.routes.get((method, self.path.split("?")[0])) diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index f43db9f5..2630740a 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -5,7 +5,7 @@ from http.server import HTTPServer import pytest -from conftest import FakeNode +from conftest import FakeNode, sandbox_at from cocoonsandbox import APIError, Client, Template @@ -226,6 +226,32 @@ def test_promote_returns_content_digest(node): assert tpl.content_digest == "sha256:promoted" +@pytest.mark.parametrize(("ttl", "sent"), [(90, {"ttl_seconds": 90}), (0, {})]) +def test_renew_sends_the_sandbox_token_and_records_the_grant(node, ttl, sent): + seen = [] + + def renew(body, path): + seen.append(body) + return 200, {"deadline": "2026-09-23T12:00:00Z"} + + FakeNode.routes[("POST", "/v1/sandboxes/sb_1/renew")] = renew + sb = sandbox_at(node, api_token="api") + assert sb.renew(ttl) == "2026-09-23T12:00:00Z" + assert sb.deadline == "2026-09-23T12:00:00Z" + assert seen == [sent] + assert FakeNode.last_headers["Authorization"] == "Bearer tok" + + +def test_renew_refusal_leaves_the_deadline(node): + FakeNode.routes[("POST", "/v1/sandboxes/sb_1/renew")] = lambda body, path: (409, {"error": "sandbox archived"}) + sb = sandbox_at(node) + sb.deadline = "2026-09-23T11:00:00Z" + with pytest.raises(APIError) as refused: + sb.renew(60) + assert refused.value.status == 409 + assert sb.deadline == "2026-09-23T11:00:00Z" + + def test_claim_follows_redirect_with_no_redirect(node): seen = [] From 791a9bc0381eadaec23efcc565fbf9b1a339e548 Mon Sep 17 00:00:00 2001 From: CMGS Date: Wed, 23 Sep 2026 11:55:41 +0800 Subject: [PATCH 2/2] review: drop a no-op TrimSpace, rewrap two doc lines, name the client suite's verbs --- docs/sdk-python.md | 11 ++++++----- docs/sdk.md | 4 ++-- sdk/go/sandbox_test.go | 2 +- sdk/python/tests/test_client.py | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/sdk-python.md b/docs/sdk-python.md index 9b3a0eb5..0b01d87a 100644 --- a/docs/sdk-python.md +++ b/docs/sdk-python.md @@ -185,11 +185,12 @@ stays valid: the first call that reaches the guest restores the VM (roughly a restore's latency, tens of milliseconds on bare metal). The TTL keeps running — a hibernated sandbox is still reaped at its deadline, so claim with a `ttl_seconds` that covers the idle period or `renew` before it ends. -When to hibernate is your policy, unless the deployment opts into `idle_hibernate_seconds` -([deploy](deploy.md#configuration)), which hibernates idle claims -automatically with the same transparent wake. A claim with a connection live -when the sweep checks it (a relay stream, a buffered exec, a preview dial, an -egress request) is not swept; the idle clock restarts when that connection ends. +When to hibernate is your policy, unless the deployment opts into +`idle_hibernate_seconds` ([deploy](deploy.md#configuration)), which +hibernates idle claims automatically with the same transparent wake. A claim +with a connection live when the sweep checks it (a relay stream, a buffered +exec, a preview dial, an egress request) is not swept; the idle clock +restarts when that connection ends. Data-plane calls share a handle's relay connection: after a call the SDK keeps the connection for 30 seconds (`Client(..., keep_alive=...)` tunes the diff --git a/docs/sdk.md b/docs/sdk.md index e2054f34..4d94d930 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -291,8 +291,8 @@ stays valid: the first call that reaches the guest restores the VM (adding roughly a restore's latency, tens of milliseconds on bare metal). The TTL keeps running — a hibernated sandbox is still reaped at its deadline, so claim with a `WithTimeout` that covers the idle period or `Renew` before it -ends. When to hibernate is your policy; the node only provides the transition — unless the -deployment opts into `idle_hibernate_seconds` (see +ends. When to hibernate is your policy; the node only provides the +transition — unless the deployment opts into `idle_hibernate_seconds` (see [deploy](deploy.md#configuration)), which hibernates idle claims automatically with the same transparent wake. A claim with a connection live when the sweep checks it (a relay stream, a buffered exec, a preview dial, an diff --git a/sdk/go/sandbox_test.go b/sdk/go/sandbox_test.go index 338b611f..2e457ef9 100644 --- a/sdk/go/sandbox_test.go +++ b/sdk/go/sandbox_test.go @@ -200,7 +200,7 @@ func TestRenewSendsTheSandboxTokenAndRecordsTheGrant(t *testing.T) { if got := r.Header.Get("Authorization"); got != "Bearer tok" { t.Errorf("authorization %q, want the sandbox's own token", got) } - if body, _ := io.ReadAll(r.Body); strings.TrimSpace(string(body)) != tt.body { + if body, _ := io.ReadAll(r.Body); string(body) != tt.body { t.Errorf("body %s, want %s", body, tt.body) } _, _ = io.WriteString(w, `{"deadline":"2026-09-23T12:00:00Z"}`) diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index 2630740a..a3a7eac8 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -1,4 +1,4 @@ -"""Control-plane claims, redirects, volume discovery, errors, and checkpoints.""" +"""Control-plane claims, redirects, volume discovery, errors, promote, renew, checkpoints, and deadlines.""" import threading import time