Skip to content

Latest commit

 

History

History
594 lines (485 loc) · 27 KB

sdk-python.md

File metadata and controls

594 lines (485 loc) · 27 KB

Python SDK

from cocoonsandbox import Client

client = Client("10.0.0.5:7777", api_token="...")
with client.new("ghcr.io/cocoonstack/sandbox/rt:24.04") as sb:
    print(sb.exec("echo", "hello"))          # "hello\n"

pip install cocoonstack-sandbox — stdlib-only, no dependencies, and synchronous by design (agent frameworks that need async wrap calls in asyncio.to_thread, exactly like the OpenAI adapter does). It matches the Go SDK guest and data-plane surface, with these exceptions: the operator pool and tenant verbs (SetPools, PutTenant and their siblings), the claim env (WithEnv, Env/SetEnv/PatchEnv) and setting claim metadata or on_expire, and reading a claim's net_route remain Go-only, Template.new takes no claim_ref, and file and tar payloads are whole bytes values rather than streams (read_file/pull return them, write_file/push take them; the wire is chunked, the caller's copy is not). Two behaviors differ as well: Session.close() and Lsp.stop() swallow not_found where Go returns it, and a failed session command puts its merged output in ExitError.stdout with stderr empty, where Go's ExitError.Stderr holds it. Wire fidelity is pinned by the shared protocol fixture corpus: Go round-trips it in CI, the Rust guest parses its requests and round-trips its responses, and Python round-trips its requests and decodes each response.

A complete example

Claim a node:24.04 sandbox (it carries make; rt:24.04 does not), push a project in, run a build, then freeze the built state and fan out two independent workers from that exact moment:

import io
import os
import tarfile

from cocoonsandbox import Client, ExitError


def project_tar() -> bytes:
    buf = io.BytesIO()
    with tarfile.open(fileobj=buf, mode="w") as tar:
        body = b"build:\n\techo built > out.txt\n"
        info = tarfile.TarInfo("Makefile")
        info.size = len(body)
        tar.addfile(info, io.BytesIO(body))
    return buf.getvalue()


def main() -> None:
    client = Client(os.environ["SANDBOXD_ADDR"], api_token=os.environ["SANDBOXD_TOKEN"])
    with client.new("node:24.04", size="medium", ttl_seconds=600) as sb:
        print(f"claimed {sb.id} on {sb.owner}")

        sb.push("/work", project_tar())  # needs no egress policy
        try:
            print(sb.exec("sh", "-c", "cd /work && make build 2>&1"), end="")
        except ExitError as e:
            raise SystemExit(f"build failed (rc={e.code}): {e.stderr}")

        ckpt = sb.checkpoint("built")  # freeze the built state
        for i in range(2):
            with ckpt.new() as worker:  # each branch is fully independent
                print(worker.exec("sh", "-c", f"echo worker {i} && ls /work"), end="")


if __name__ == "__main__":
    main()

The rest of this guide is the per-method reference.

Connecting

client = Client("10.0.0.5:7777", api_token="...", timeout=120.0)

api_token is the node token — a root api_token (full access), the operator_token (the claim lifecycle, sandboxes, info and lookup), or a tenant token (resource-creating verbs only; operator surfaces answer it 403). Every node of a cell shares the root token and the same tenants set. timeout bounds every control-plane request and the data-plane dial and upgrade; a guest stream then lives until the guest ends it. Unlike the Go SDK, where a canceled context closes any call, only run/exec take a timeout here: every other data-plane call blocks until the guest answers or the connection drops. A caller with a wall clock of its own passes deadline to a claim (below) so the redirect walk cannot outlive it.

For HTTPS (endpoints) with a private CA, pass a standard ssl.SSLContext:

import os
import ssl
from cocoonsandbox import Client

client = Client(
    "https://node-a.sandbox.example.com",
    api_token=os.environ["SANDBOXD_TOKEN"],
    ssl_context=ssl.create_default_context(cafile="edge-ca.pem"),
)
with client.new("rt:24.04") as sb:
    assert sb.run(["true"]) == 0

Omit ssl_context for system trust. The SDK configures that context for HTTP/1.1; use a dedicated context if another caller requires a different ALPN.

Clusters need nothing extra: dial any node. On a warm miss the entry node answers with a redirect and new follows it transparently; the returned handle is bound to the owning node and all further calls go there directly. To recover a handle when only id + token survived (say, across a process restart):

sb = client.lookup(id, token)   # lists the peers, then probes the entry node and every peer concurrently

Claiming

sb = client.new("ghcr.io/cocoonstack/sandbox/rt:24.04",
                net="egress", size="medium", ttl_seconds=600,
                volumes=["imagenet", {"name": "weights", "mount": "/models"},
                         {"name": "scratch-db", "mode": "rw"}])
parameter values default meaning
net "none", "egress" "none" Cloud Hypervisor network shape: none disables the NIC and uses vsock-only I/O; egress attaches a bridge/CNI NIC
size "small", "medium", "medium-4g", "large", "xlarge", "2xlarge" "small" resource tier: 1cpu/512M, 2cpu/1G, 2cpu/4G, 4cpu/4G, 4cpu/8G, 8cpu/16G
volumes bare names or {name, mount?, mode?, attach_only?} mappings None attach and mount up to eight unique catalog dataset disks; an omitted mount defaults to /volumes/<name>; mode is "ro" (default) or "rw" — "rw" requires the catalog entry's writable: true; accepted by Client.new and Template.new
ttl_seconds int server default 5m sandbox TTL, server-capped at 24h. The node reaps the sandbox after the TTL even if the client vanishes
deadline time.monotonic() value None keyword-only wall clock over the whole claim, redirect targets included: every request is bounded by whatever is left and a spent deadline raises TimeoutError. Without it each redirect candidate gets a fresh timeout, so a cluster with unreachable peers can cost a multiple of it. Also on Template.new and Checkpoint.new

new returns when the sandbox's silkd answers: a warm hit is milliseconds, a cold key can take the full boot. A volume claim may consume an ordinary warm VM and returns only after every requested disk is mounted; sb.volumes contains dictionaries with the finalized name, effective mount, and (for rw entries) mode. Custom mounts must be absolute and clean, stay outside the guest OS tree, and cannot duplicate or nest. The handle exposes sb.id, sb.token, sb.owner, sb.deadline, and sb.from_checkpoint (the lineage edge when branched). sb.template_digest is the exact content identity when the claim cloned a promoted template; it is empty for other sources. Sandbox is a context manager; sb.close() releases it (it follows a sandbox that moved to another node with a lookup, also when that node is gone; releasing one already gone is not an error — double-release and reap races stay silent). Every other call follows a move too: a 404 or a refused connection from the owner runs client.lookup once, and the call is sent again to a different owner, since every argument (stdin, file data, tar streams) is bytes. find_iter also retries once before its first frame; it never replays matches already yielded. An exception from the caller's own on_stdout/on_stderr is raised as it is and never retried, and a released sandbox still raises a 404 APIError. Volume sandboxes cannot hibernate, fork, checkpoint, or promote. Egress-lane sandboxes cannot either, and a sandbox whose tun is turned off cannot promote; each answers 409. Checkpoint branches do not accept volumes of either mode in this version.

{"attach_only": True} on a volume entry leaves the mount to you: that entry in sb.volumes carries no mount key; see attach-only volumes for finding the device and the consistency contract. For a root client managing a tenant claim, see the external volume controller example. It keeps the controller reservation and access gate across detach, hibernate, and wake.

client.volumes() returns the fleet entries this token may use:

for volume in client.volumes():
    print(volume["name"], volume["default_mount"],
          volume["size_bytes"], volume["available"], volume["nodes"],
          volume.get("writable", False))

writable is present (and true) only for a writable entry; a read-only entry omits the key, so read it with .get, not [...].

Entry semantics and placement: GET /v1/volumes, cluster.

Changing live volumes

Use sb.get_volumes(), sb.set_volumes([...]), and sb.patch_volumes(add=[...], remove=[...]) on the same running sandbox. These methods use the client's API token on the owning node; a sandbox token cannot manage volumes. set_volumes([]) detaches all entries; None is rejected. PATCH retains omitted names. Parameters take lists of names or Volume mappings. The SDK sends volume policy to sandboxd and preserves attach_only directly.

from __future__ import annotations

from cocoonsandbox import Volume

volumes: list[str | Volume] = [
    {"name": "dataset", "mount": "/datasets"},
    {"name": "work", "mode": "rw", "attach_only": True},
]
view = sb.set_volumes(volumes)

The caller mounts, flushes, and unmounts attach-only devices. See the live volume contract for writer exclusion and dirty-marker limits. Each method takes timeout= (default: the client timeout, 120 s); a large replacement can need longer, and a call that times out raises APIError with status 0 and cancels the server run, so retry the same target.

Responses include volumes_want, volumes, states, and converging. VolumePhase and VOLUME_ATTACHED, VOLUME_PENDING, and VOLUME_DETACHING name the three states. A pending state with converging=False needs an explicit retry. VolumeError, exported from cocoonsandbox and cocoonsandbox.errors, is a subclass of APIError and retains a complete failure view in .response. Complete replies update both public sets (sb.volumes_want, sb.volumes); incomplete errors preserve the last view. Serialize calls on one handle. See the replacement tutorial and API recovery contract.

Renewing

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

sb.hibernate()                        # snapshot + stop atomically; memory freed
sb.exec("cat", "/tmp/state")          # the next call that reaches the guest wakes it transparently

hibernate snapshots the VM and stops it in one atomic step. The handle stays valid: the first call that reaches the guest restores the VM (roughly a restore's latency, tens of milliseconds on bare metal). On a node at max_running that call raises a 429 APIError, which the SDK does not retry, and the sandbox stays hibernated. 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), 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 the sandbox's pool sets reader, stat, read_file, list_dir and pull on a hibernated or archived handle are served without waking it, as of the guest sync just before the last hibernate, and the handle's next call that is anything else wakes the sandbox on the same connection.

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 window; 0 dials per call) and the next call on that handle sends its request on it, so a busy handle pays the dial, upgrade and TLS handshake once. A handle parks at most 8 idle connections. A kept connection counts as live for idle_hibernate_seconds until it closes, so keep the window below that setting; close and hibernate drop it at once. Streams (watch, open_pty, dial_port, an LSP session) take a connection of their own.

The handle's sb.deadline remains the value returned when it was created or last renewed, so after an archive/wake transition read the current one from client.sandboxes() (wake).

Forking

children = sb.fork(2, ttl_seconds=600)   # list[Sandbox], own leases

Clones the sandbox into fresh, fully independent claims: memory, disk, and guest state (sessions, processes, tmpfs) duplicate at the fork point, and each child gets a distinct machine identity. ttl_seconds bounds every child's lifetime (0 = server default) — children never inherit the parent's remaining lease. A running parent pauses briefly for the snapshot; a hibernated parent forks from its memory image without waking. All-or-nothing: on error no child survived. Count is capped at the node's max_fork_count (default 16).

Promoting to a template

tpl = sb.promote("myproj:v1")     # publish current state
child = tpl.new()                 # clones the promoted state
assert tpl.content_digest and tpl.content_digest == child.template_digest
tpl.delete()                      # caller owns the lifecycle

Templates are keyed by (name, the sandbox's network lane, its size); on the default local-disk backend they live on the owning node (a shared store makes every node resolve them); the returned Template handle is bound there. Its delete and volume-less new reach that node; new(volumes=...) may follow one volume-placement redirect. The name-based calls (client.new("myproj:v1"), client.delete_template(...)) route cluster-wide via template gossip and lag a promote/delete by about a gossip tick — prefer the handle right after promoting (see Templates on a cluster). tpl.content_digest identifies the published export bytes. A caller pinning the mutable name can compare a claim's template_digest with its expected value and close/refuse a mismatch.

Checkpoints — branching and time travel

sb.write_file("/root/state.txt", b"v1")
ckpt = sb.checkpoint("after-setup")       # source keeps running
sb.write_file("/root/state.txt", b"v2")

branch = ckpt.new()                        # a fresh sandbox at the captured moment
branch.read_file("/root/state.txt")        # b"v1"
sb.read_file("/root/state.txt")            # b"v2" — source unaffected
ckpt.delete()
client.checkpoints()                       # node's checkpoints, newest first
known = client.checkpoint("ck_…")          # known id; no listing round-trip

A checkpoint captures memory, disk, and running processes without stopping the sandbox (the same brief pause a fork takes); ckpt.new(ttl_seconds=0) branches any number of independent sandboxes from that exact moment, and successive checkpoints of sources and branches form a tree. Checkpoints live in the node's checkpoint store — a shared FUSE mount or a checkpoint_store of kind s3 lets any node branch them. client.checkpoints() lists the connected node's records, while client.checkpoint(id) creates an entry-node-bound handle for an already known id without listing. new() follows an owner redirect and may heal a missing record locally; delete() acts on the handle's bound node.

delete() also asks the peers that node sees to drop any healed replica, as best-effort cleanup rather than a fleet-wide revocation; see delete is eventual.

Language servers (LSP)

lsp = sb.start_lsp("python", "/work")   # flavor image provides the server
stream = lsp.request()                  # JSON-RPC byte stream (frame it yourself)
# ... speak Content-Length-framed JSON-RPC over stream.send()/recv() ...
stream.close()                          # ends the session and reaps the server
lsp.close()                             # same as lsp.stop(); both are context-managers

start_lsp spawns the language server the flavor image ships for the language (the python flavor bakes pylsp); the base image has none, so it raises the typed not_found. silkd is a broker — it pipes JSON-RPC bytes without parsing LSP semantics, so the caller frames (Content-Length) and correlates by request id. A server serves one request() stream for its lifetime: closing the stream reaps it (start a new one to keep working); lsp.stop() kills it early.

Reaching guest ports

conn = sb.dial_port(8080)                        # byte stream to 127.0.0.1:8080 in the guest
listener = sb.proxy_port("127.0.0.1:0", 8080)    # local listener piping to it
url = sb.preview_url(8080, ttl_seconds=1800)     # signed, shareable browser URL

dial_port returns a PortConn (send/recv/close_write/close, context-manager) relayed over the silkd protocol — it works on the no-network lane, where the vsock relay is the only way in. A dead port raises silkd's not_found. proxy_port serves the port on a local listener for unmodified local tools (browsers, curl); close the returned socket to stop, and a guest port that closes ends the local connection too. preview_url mints a signed URL served by the node's preview listener, clamped to the claim's remaining lease — the URL dies with the sandbox, and a node without preview_listen answers 501. Minting is resource-creating and takes the api token, like fork and checkpoint.

Node info

client.info()   # pools, claims, drain, at_capacity/reason, and mesh peers

at_capacity: true means refill is parked because the node cannot start another VM, not that it is still filling its warm target; at_capacity_reason carries the reason. Both keys are absent while refill is not capacity-blocked.

Running commands

out = sb.exec("python3", "script.py")            # stdout; ExitError on rc != 0

code = sb.run(["bash", "-c", "make test"],
              cwd="/work", env={"CI": "1"}, user="ubuntu",
              stdin=input_bytes,
              on_stdout=lambda b: sys.stdout.buffer.write(b),
              on_stderr=lambda b: sys.stderr.buffer.write(b),
              timeout=600)                          # seconds; TimeoutError past it

stdin takes bytes, bytearray or memoryview; a non-empty str raises TypeError.

timeout on exec and run is a wall clock over the dial and the run: at its end the SDK cuts the connection and kills the reported pid, and TimeoutError is raised.

exec returns stdout and raises ExitError on a non-zero exit — carrying code, stderr, and the stdout produced before it failed. run streams raw bytes through the callbacks (chunk boundaries may split multi-byte sequences) and returns the exit code. user de-escalates inside the guest; session= routes the command into a persistent session, where only argv applies — the session owns the cwd, environment and user, and stderr arrives merged into stdout. A command's environment is PATH, TERM, the lane's proxy variables and the claim env, plus env, not the image's ENV. The guest kills a foreground command whose connection dropped only once it next writes output, so a run(timeout=) cut does not leave that to the drop: once the guest has reported the command's pid, the cut sends that pid a kill over a connection of its own (best effort, bounded to 5 s) before SandboxTimeout is raised. A client that only drops the connection leaves a silent command running in the guest; ps then kill stops it. spawn outlives the connection by design. A command run through a session is the session's: it reports no pid, so no cut kills it and it is not in ps; it runs to completion, and the session's close() is what ends the session.

Background processes

pid = sb.spawn("sh", "-c", "make build")     # returns immediately
sb.ps()                                       # [{pid, argv, detached, state, exit_code?, ...}]
code = sb.logs(pid, on_stdout=out.append)     # replay the bounded ring; None while running
code = sb.attach(pid, on_stdout=out.append)   # replay, then follow live until exit
sb.kill(pid)                                  # default SIGKILL

spawn starts the command detached with a bounded output ring; logs replays it, attach follows live output until exit (replay and live stream hand off atomically). kill signals the process group the pid leads, so a pipeline or a forked child goes with it. Killing an already-exited process is a no-op success. An exited process leaves the table after 5 minutes; from then on its pid raises SilkdError not_found.

Sessions

A session is a real persistent shell: cwd, env and shell state survive across calls.

sess = sb.session(cwd="/work", env={"PATH": "..."})   # context-manager
sess.exec("export", "MARK=1")            # persists
sess.exec("sh", "-c", "echo $MARK")      # "1\n"
sess.close()

sb.sessions()                            # live session ids

Idle sessions are reaped guest-side after 30 minutes.

Files

sb.write_file("/work/a.txt", b"data")     # atomic; mode=0o755 optional
data = sb.read_file("/work/a.txt")
ents = sb.list_dir("/work")               # [{"name", "kind", "size"}]
info = sb.stat("/work/a.txt")             # {"kind", "size", "mode", "mtime_epoch_secs"}
sb.mkdir("/work/sub", parents=True)
sb.remove("/work/sub", recursive=True)
sb.rename("/a", "/b")

Writes stream any size and commit via temp-file rename: a mid-stream failure never leaves a truncated destination, and overwriting an executable keeps its exec bit.

Project trees

sb.push("/work", tar_bytes)    # extract a tar stream under /work (atomic)
tar = sb.pull("/work")         # /work back as tar bytes

push is atomic against a truncated stream and needs no egress policy, so it brings a project into a no-network sandbox whose pool allows no git host.

Search

matches = sb.find("/work", r"TODO|FIXME", glob="*.py")
from contextlib import closing

with closing(sb.find_iter("/work", r"TODO")) as stream:   # streamed; closing ends the walk in the guest
    for m in stream:
        if m["line"] > 100:
            break
# [{"type", "file", "line", "content"}]; glob is anchored *? wildcards on the file name

results = sb.replace(["/work/main.py"], r"foo", "bar")
# [{"type", "file", "replacements"}]; per-file atomic

Patterns are regular expressions evaluated in the guest — no shell quoting. replace is atomic per file, not per list: it raises at the first missing, unreadable or non-UTF-8 path and the files before it stay rewritten, so pass it paths a find just returned. A find fails as a whole when one match's frame would pass the 8 MiB cap.

Watching

w = sb.watch("/work", recursive=True)
for ev in w:                       # {"type", "kind", "path"}
    print(ev["kind"], ev["path"])  # created|modified|deleted|renamed
w.close()

watch returns once the guest acknowledges the watch is armed — events caused after it returns are guaranteed captured. A bad path fails synchronously; if the consumer falls too far behind, iteration raises the terminal overflow instead of silently dropping events. Iteration also ends when the relay drops, which w.error tells apart from a clean close (None).

Git

sb.git_clone(url, "/work/repo", branch="main", depth=1, auth=token)
st = sb.git_status("/work/repo")          # {"type", "branch", "ahead", "behind", "files", "truncated"?}
sb.git_add("/work/repo", ["a.txt"])
sha = sb.git_commit("/work/repo", "message", "Dev <dev@example.com>")
sb.git_push("/work/repo", auth=token)
sb.git_pull("/work/repo", auth=token)
br = sb.git_branches("/work/repo")        # {"type", "current", "branches"}
sb.git_create_branch("/work/repo", "feature")
sb.git_checkout("/work/repo", "feature")
sb.git_delete_branch("/work/repo", "feature")

Results are structured (porcelain v2 under the hood), never scraped stdout. Auth tokens travel as an in-memory header, never touching guest disk. Clone, push and pull reach the remote the way an exec does: directly on the egress lane, through the egress proxy elsewhere, so a pool whose policy does not allow the git host fails them with git's own error.

Terminals

pty = sb.open_pty(cols=120, rows=40)      # context-manager; pty.pid is the guest process
pty.write(b"make test\n")
data = pty.read()                         # b"" when the shell exits
pty.exit_code                             # the shell's status, once read() returned b""
pty.resize(200, 50)
pty.close()

Node operations

sb = client.new("rt:24.04", claim_ref="ns/workload")
client.sandboxes()                     # one summary dict per live claim — never tokens
client.drain()                         # refuse new claims, run leases out
client.undrain()
client.cordon()                        # refuse new claims across restarts; moves in still land
client.uncordon()
client.attach(owner_addr, id, token)   # bind a known handle, no lookup round-trip

sandboxes() is scoped to the calling token, so a tenant sees only its own claims; the fields are those of GET /v1/sandboxes. drain() leaves live claims alone — poll info() until claimed is zero. undrain() lifts only a drain and uncordon() only a cordon; the admission table compares the two.

Errors

SandboxError is the base; catch the narrowest type you handle:

  • APIError(verb, status, message) — control plane (HTTP status)
  • SilkdError(kind, message) — typed guest failure; kind is bad_request (including a spawn the guest cannot start: missing binary, a cwd that is not a directory, no exec bit, a user the guest cannot resolve) / not_found / unimplemented / internal
  • ExitError(code, stderr, stdout) — non-zero exit from exec
  • SandboxTimeout — a caller's timeout or deadline expired before the call finished; also a TimeoutError
  • ProtocolError — a failed relay dial or TLS handshake, or a broken stream (EOF, oversized or undecodable frame)
try:
    sb.read_file("/work/missing.txt")
except SilkdError as e:
    if e.kind == "not_found":
        ...