Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 24 additions & 8 deletions docs/sdk-python.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -170,12 +184,13 @@ 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`
([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.
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.

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
Expand All @@ -193,8 +208,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

Expand Down
25 changes: 20 additions & 5 deletions docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -276,9 +290,9 @@ 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
deployment opts into `idle_hibernate_seconds` (see
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
when the sweep checks it (a relay stream, a buffered exec, a preview dial, an
Expand All @@ -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

Expand Down
33 changes: 33 additions & 0 deletions e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion sdk/go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
8 changes: 8 additions & 0 deletions sdk/go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down
14 changes: 14 additions & 0 deletions sdk/go/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
58 changes: 58 additions & 0 deletions sdk/go/sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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); 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()
Expand Down
3 changes: 2 additions & 1 deletion sdk/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
9 changes: 9 additions & 0 deletions sdk/python/cocoonsandbox/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
2 changes: 2 additions & 0 deletions sdk/python/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

class FakeNode(BaseHTTPRequestHandler):
routes = {}
last_headers = {}

def do_POST(self):
self._dispatch("POST")
Expand All @@ -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]))
Expand Down
30 changes: 28 additions & 2 deletions sdk/python/tests/test_client.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""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
from http.server import HTTPServer

import pytest
from conftest import FakeNode
from conftest import FakeNode, sandbox_at

from cocoonsandbox import APIError, Client, Template

Expand Down Expand Up @@ -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 = []

Expand Down
Loading