cocoonstack-sandbox 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: cocoonstack-sandbox
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the cocoon sandbox control plane
5
+ License: Apache-2.0
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+
9
+ # cocoonstack-sandbox
10
+
11
+ Python SDK for the cocoon sandbox control plane: microVM sandboxes with
12
+ warm-pool claims, checkpoint branching, transparent hibernate/wake, and a
13
+ relay-only data plane that works on no-network guests.
14
+
15
+ ```python
16
+ from cocoonsandbox import Client
17
+
18
+ client = Client("10.0.0.5:7777", api_token="...")
19
+ with client.new("ghcr.io/cocoonstack/sandbox/rt:24.04") as sb:
20
+ print(sb.exec("echo", "hello"))
21
+ ckpt = sb.checkpoint("after-setup")
22
+ branch = ckpt.new() # a fresh sandbox at the captured moment
23
+ ```
24
+
25
+ stdlib-only and synchronous — `pip install cocoonstack-sandbox` brings no
26
+ dependencies. The surface mirrors the Go SDK: exec/run, streaming files,
27
+ tar push/pull, find/replace, watch, persistent sessions, git verbs, pty,
28
+ port dial/proxy/preview URLs, fork, hibernate, promote, checkpoints, and
29
+ the LSP broker. Wire fidelity is pinned by the shared protocol fixture
30
+ corpus (Rust + Go + Python all round-trip it in CI).
31
+
32
+ Full reference: https://cocoonstack.github.io/sandbox/sdk-python
@@ -0,0 +1,24 @@
1
+ # cocoonstack-sandbox
2
+
3
+ Python SDK for the cocoon sandbox control plane: microVM sandboxes with
4
+ warm-pool claims, checkpoint branching, transparent hibernate/wake, and a
5
+ relay-only data plane that works on no-network guests.
6
+
7
+ ```python
8
+ from cocoonsandbox import Client
9
+
10
+ client = Client("10.0.0.5:7777", api_token="...")
11
+ with client.new("ghcr.io/cocoonstack/sandbox/rt:24.04") as sb:
12
+ print(sb.exec("echo", "hello"))
13
+ ckpt = sb.checkpoint("after-setup")
14
+ branch = ckpt.new() # a fresh sandbox at the captured moment
15
+ ```
16
+
17
+ stdlib-only and synchronous — `pip install cocoonstack-sandbox` brings no
18
+ dependencies. The surface mirrors the Go SDK: exec/run, streaming files,
19
+ tar push/pull, find/replace, watch, persistent sessions, git verbs, pty,
20
+ port dial/proxy/preview URLs, fork, hibernate, promote, checkpoints, and
21
+ the LSP broker. Wire fidelity is pinned by the shared protocol fixture
22
+ corpus (Rust + Go + Python all round-trip it in CI).
23
+
24
+ Full reference: https://cocoonstack.github.io/sandbox/sdk-python
@@ -0,0 +1,33 @@
1
+ """Python SDK for the cocoon sandbox control plane: claim a microVM from a
2
+ sandboxd node, run commands over the relayed silkd protocol, branch it with
3
+ checkpoints, release it. stdlib-only.
4
+
5
+ from cocoonsandbox import Client
6
+
7
+ client = Client("node:7777", api_token="...")
8
+ with client.new("ghcr.io/cocoonstack/sandbox/rt:24.04") as sb:
9
+ print(sb.exec("echo", "hello"))
10
+ """
11
+
12
+ from .checkpoint import Checkpoint
13
+ from .client import Client
14
+ from .errors import APIError, ExitError, ProtocolError, SandboxError, SilkdError
15
+ from .sandbox import Lsp, PortConn, Pty, Sandbox, Session, Watcher
16
+ from .template import Template
17
+
18
+ __all__ = [
19
+ "APIError",
20
+ "Checkpoint",
21
+ "Client",
22
+ "ExitError",
23
+ "Lsp",
24
+ "PortConn",
25
+ "ProtocolError",
26
+ "Pty",
27
+ "Sandbox",
28
+ "SandboxError",
29
+ "Session",
30
+ "SilkdError",
31
+ "Template",
32
+ "Watcher",
33
+ ]
@@ -0,0 +1,28 @@
1
+ """Checkpoint: a captured sandbox state bound to the node that holds it.
2
+ Branch any number of fresh sandboxes from the captured moment; the source
3
+ keeps running and can be checkpointed again, so captures form a tree."""
4
+
5
+ from __future__ import annotations
6
+
7
+
8
+ class Checkpoint:
9
+ """A captured sandbox state on its owner node."""
10
+
11
+ def __init__(self, client, addr: str, rec: dict):
12
+ self._client = client
13
+ self._addr = addr
14
+ self.id = rec["id"]
15
+ self.name = rec.get("name", "")
16
+ self.sandbox_id = rec.get("sandbox_id", "")
17
+ self.created_at = rec.get("created_at", "")
18
+
19
+ def new(self, ttl_seconds: int = 0):
20
+ """Claims a fresh sandbox branched from the checkpoint."""
21
+ body = {"ttl_seconds": ttl_seconds} if ttl_seconds else {}
22
+ reply = self._client._post_json(self._addr, f"/v1/checkpoints/{self.id}/claim", body, "claim checkpoint")
23
+ return self._client._handle_from(self._addr, reply)
24
+
25
+ def delete(self) -> None:
26
+ """Removes the checkpoint from its node."""
27
+ self._client._request(self._addr, "DELETE", f"/v1/checkpoints/{self.id}", None, "delete checkpoint")
28
+
@@ -0,0 +1,141 @@
1
+ """Client: the control-plane entry point. Dial any node of a cluster; a
2
+ claim either lands locally or follows one MOVED-style redirect to the node
3
+ that has capacity, and the returned Sandbox is bound to its owner."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+
12
+ from .checkpoint import Checkpoint
13
+ from .errors import APIError
14
+ from .sandbox import Sandbox
15
+
16
+
17
+ class Client:
18
+ """Talks to one sandboxd node (and, transparently, its cluster)."""
19
+
20
+ def __init__(self, addr: str, api_token: str = "", timeout: float = 120.0):
21
+ self.addr = addr.split(",")[0].strip()
22
+ self.api_token = api_token
23
+ self.timeout = timeout
24
+
25
+ def new(self, template: str, net: str = "", size: str = "", ttl_seconds: int = 0) -> Sandbox:
26
+ """Claims a sandbox; a warm hit is milliseconds. On a cluster a warm
27
+ miss may redirect to a peer, followed transparently."""
28
+ claim = _claim_body(template, net, size, ttl_seconds)
29
+ reply = self._post_json(self.addr, "/v1/claim", claim, "claim")
30
+ redirect = reply.get("redirect") or []
31
+ if redirect:
32
+ claim["no_redirect"] = True
33
+ last_error = None
34
+ for peer in redirect:
35
+ try:
36
+ return self._handle_from(peer, self._post_json(peer, "/v1/claim", claim, "claim"))
37
+ except APIError as exc:
38
+ last_error = exc
39
+ raise last_error
40
+ return self._handle_from(self.addr, reply)
41
+
42
+ def delete_template(self, template: str, net: str = "", size: str = "") -> None:
43
+ """Removes a promoted template by name; on a cluster the delete
44
+ follows gossip to the owner node (one hop)."""
45
+ query = {"template": template}
46
+ if net:
47
+ query["net"] = net
48
+ if size:
49
+ query["size"] = size
50
+ path = "/v1/templates?" + urllib.parse.urlencode(query)
51
+ reply = self._request(self.addr, "DELETE", path, None, "delete template")
52
+ candidates = (reply or {}).get("redirect") or []
53
+ query["no_redirect"] = "1"
54
+ path = "/v1/templates?" + urllib.parse.urlencode(query)
55
+ # Each candidate once, like the Go SDK: a peer that lost the template
56
+ # answers 404, the next may own it; the last 404 propagates.
57
+ for i, peer in enumerate(candidates):
58
+ try:
59
+ self._request(peer, "DELETE", path, None, "delete template")
60
+ return
61
+ except APIError as exc:
62
+ if exc.status != 404 or i == len(candidates) - 1:
63
+ raise
64
+
65
+ def lookup(self, id: str, token: str) -> Sandbox:
66
+ """Relocates a handle from id + token: asks the entry node, then
67
+ each mesh peer, and binds to whichever confirms ownership."""
68
+ for addr in [self.addr, *(self.info().get("peers") or [])]:
69
+ try:
70
+ reply = self._request(addr, "GET", f"/v1/sandboxes/{id}/owner", None, "owner", bearer=token)
71
+ except APIError:
72
+ continue
73
+ return Sandbox(client=self, id=id, token=token,
74
+ owner=reply.get("owner_addr") or addr)
75
+ raise APIError("lookup", 404, f"no owner found for {id}")
76
+
77
+ def checkpoint(self, id: str) -> Checkpoint:
78
+ """A handle for a known checkpoint id, bound to the entry node — no
79
+ listing round-trip; an unknown id surfaces as 404 at claim time."""
80
+ return Checkpoint(self, self.addr, {"id": id})
81
+
82
+ def checkpoints(self) -> list[Checkpoint]:
83
+ """Lists the connected node's checkpoints, newest first."""
84
+ reply = self._request(self.addr, "GET", "/v1/checkpoints", None, "list checkpoints")
85
+ return [Checkpoint(self, self.addr, rec) for rec in reply.get("checkpoints") or []]
86
+
87
+ def info(self) -> dict:
88
+ """The node's pool/claim counters, as served by GET /v1/info."""
89
+ return self._request(self.addr, "GET", "/v1/info", None, "info")
90
+
91
+ def _handle_from(self, dialed: str, reply: dict) -> Sandbox:
92
+ return Sandbox(
93
+ client=self,
94
+ id=reply["id"],
95
+ token=reply["token"],
96
+ owner=reply.get("owner_addr") or dialed,
97
+ deadline=reply.get("deadline", ""),
98
+ from_checkpoint=reply.get("from_checkpoint", ""),
99
+ )
100
+
101
+ def _post_json(self, addr: str, path: str, body: dict, verb: str) -> dict:
102
+ return self._request(addr, "POST", path, body, verb)
103
+
104
+ def _request(self, addr: str, method: str, path: str, body, verb: str, bearer: str = "") -> dict:
105
+ """Issues one control-plane request. bearer overrides the api token —
106
+ sandbox-scoped verbs (release, hibernate) authenticate with the
107
+ per-sandbox token instead."""
108
+ data = json.dumps(body).encode() if body is not None else None
109
+ req = urllib.request.Request(f"http://{addr}{path}", data=data, method=method)
110
+ if data is not None:
111
+ req.add_header("Content-Type", "application/json")
112
+ token = bearer or self.api_token
113
+ if token:
114
+ req.add_header("Authorization", f"Bearer {token}")
115
+ try:
116
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
117
+ raw = resp.read()
118
+ except urllib.error.HTTPError as exc:
119
+ raise APIError(verb, exc.code, _error_message(exc.read())) from None
120
+ except urllib.error.URLError as exc:
121
+ raise APIError(verb, 0, str(exc.reason)) from None
122
+ return json.loads(raw) if raw else {}
123
+
124
+
125
+ def _claim_body(template: str, net: str, size: str, ttl_seconds: int) -> dict:
126
+ claim = {"template": template}
127
+ if net:
128
+ claim["net"] = net
129
+ if size:
130
+ claim["size"] = size
131
+ if ttl_seconds:
132
+ claim["ttl_seconds"] = ttl_seconds
133
+ return claim
134
+
135
+
136
+ def _error_message(raw: bytes) -> str:
137
+ try:
138
+ return json.loads(raw)["error"]
139
+ except (ValueError, KeyError, TypeError):
140
+ return raw.decode(errors="replace").strip()
141
+
@@ -0,0 +1,94 @@
1
+ """One relayed silkd connection per RPC: a raw TCP socket hand-upgraded to
2
+ the sandboxd agent relay (Upgrade: silkd), then newline-JSON frames."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import contextlib
7
+ import socket
8
+
9
+ from .errors import APIError, ProtocolError, SilkdError
10
+ from .frames import MAX_FRAME, decode_response, encode_request
11
+
12
+
13
+ class Conn:
14
+ """A live frame stream to one sandbox's silkd, via the owner node."""
15
+
16
+ def __init__(self, sock: socket.socket, reader):
17
+ self._sock = sock
18
+ self._reader = reader
19
+
20
+ def __enter__(self):
21
+ return self
22
+
23
+ def __exit__(self, *exc):
24
+ self.close()
25
+
26
+ def send(self, op: str, **fields) -> None:
27
+ self._sock.sendall(encode_request(op, **fields))
28
+
29
+ def recv(self) -> dict:
30
+ """Returns the next frame; raises SilkdError on an error frame and
31
+ ProtocolError on EOF or an oversized line."""
32
+ line = self._reader.readline(MAX_FRAME + 1)
33
+ if not line:
34
+ raise ProtocolError("connection closed mid-stream")
35
+ if len(line) > MAX_FRAME:
36
+ raise ProtocolError("frame exceeds the 8MiB cap")
37
+ frame = decode_response(line)
38
+ if frame["type"] == "error":
39
+ raise SilkdError(frame.get("kind", "internal"), frame.get("message", ""))
40
+ return frame
41
+
42
+ def recv_until(self, *terminal: str):
43
+ """Yields frames until one of the terminal types arrives; the
44
+ terminal frame is yielded last."""
45
+ while True:
46
+ frame = self.recv()
47
+ yield frame
48
+ if frame["type"] in terminal:
49
+ return
50
+
51
+ def close_write(self) -> None:
52
+ with contextlib.suppress(OSError):
53
+ self._sock.shutdown(socket.SHUT_WR)
54
+
55
+ def close(self) -> None:
56
+ try:
57
+ self._reader.close()
58
+ finally:
59
+ self._sock.close()
60
+
61
+ def dial_agent(addr: str, sandbox_id: str, token: str, timeout: float) -> Conn:
62
+ """Opens the data-plane connection: TCP dial plus a hand-rolled HTTP
63
+ Upgrade, so nothing pools or proxies underneath the byte stream."""
64
+ host, port = addr.rsplit(":", 1)
65
+ sock = socket.create_connection((host, int(port)), timeout=timeout)
66
+ try:
67
+ request = (
68
+ f"GET /v1/sandboxes/{sandbox_id}/agent HTTP/1.1\r\n"
69
+ f"Host: {addr}\r\n"
70
+ "Connection: Upgrade\r\n"
71
+ "Upgrade: silkd\r\n"
72
+ f"Authorization: Bearer {token}\r\n"
73
+ "\r\n"
74
+ )
75
+ sock.sendall(request.encode())
76
+ reader = sock.makefile("rb")
77
+ status = reader.readline(1024).decode(errors="replace")
78
+ parts = status.split(" ", 2)
79
+ code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0
80
+ body_len = 0
81
+ while True:
82
+ header = reader.readline(4096)
83
+ if header in (b"\r\n", b"\n", b""):
84
+ break
85
+ name, _, value = header.decode(errors="replace").partition(":")
86
+ if name.strip().lower() == "content-length":
87
+ body_len = int(value.strip())
88
+ if code != 101:
89
+ body = reader.read(body_len).decode(errors="replace") if body_len else ""
90
+ raise APIError("agent upgrade", code, body.strip() or status.strip())
91
+ return Conn(sock, reader)
92
+ except Exception:
93
+ sock.close()
94
+ raise
@@ -0,0 +1,41 @@
1
+ """Error types raised by the SDK."""
2
+
3
+
4
+ class SandboxError(Exception):
5
+ """Base class for all SDK errors."""
6
+
7
+
8
+ class APIError(SandboxError):
9
+ """A sandboxd control-plane call failed."""
10
+
11
+ def __init__(self, verb: str, status: int, message: str):
12
+ super().__init__(f"{verb}: {message} (HTTP {status})")
13
+ self.verb = verb
14
+ self.status = status
15
+ self.message = message
16
+
17
+
18
+ class SilkdError(SandboxError):
19
+ """The in-guest daemon answered an error frame.
20
+
21
+ kind is the typed wire kind: bad_request, not_found, unimplemented,
22
+ internal.
23
+ """
24
+
25
+ def __init__(self, kind: str, message: str):
26
+ super().__init__(f"{kind}: {message}")
27
+ self.kind = kind
28
+ self.message = message
29
+
30
+
31
+ class ExitError(SandboxError):
32
+ """A command exited non-zero; carries the exit code and stderr."""
33
+
34
+ def __init__(self, code: int, stderr: str):
35
+ super().__init__(f"exit status {code}: {stderr.strip()}")
36
+ self.code = code
37
+ self.stderr = stderr
38
+
39
+
40
+ class ProtocolError(SandboxError):
41
+ """The peer broke the frame protocol (unexpected frame, oversized line)."""
@@ -0,0 +1,39 @@
1
+ """silkd wire frames: newline-delimited JSON, requests tagged by "op",
2
+ responses by "type", binary payloads base64 in "data" fields. Mirrors the Go
3
+ and Rust implementations; all three round-trip protocol/fixtures/v1."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import base64
8
+ import json
9
+
10
+ PROTO_VERSION = 1
11
+ MAX_FRAME = 8 * 1024 * 1024
12
+ FS_CHUNK = 32 * 1024
13
+ # Bulk streams (push tars, port bytes) chunk larger — fewer frames for the
14
+ # same bytes, still far under MAX_FRAME after base64; mirrors the Go SDK.
15
+ BULK_CHUNK = 1 << 20
16
+
17
+
18
+ def encode_request(op: str, **fields) -> bytes:
19
+ """Renders {"v":1,"op":...,fields} with a trailing newline; None fields
20
+ are omitted and bytes values ride base64 under their field name."""
21
+ frame = {"v": PROTO_VERSION, "op": op}
22
+ for key, value in fields.items():
23
+ if value is None:
24
+ continue
25
+ if isinstance(value, (bytes, bytearray, memoryview)):
26
+ value = base64.b64encode(value).decode()
27
+ frame[key] = value
28
+ return json.dumps(frame, separators=(",", ":")).encode() + b"\n"
29
+
30
+
31
+ def decode_response(line: bytes) -> dict:
32
+ """Parses one response frame; the returned dict carries its tag under
33
+ "type" and any binary payload decoded under "data"."""
34
+ frame = json.loads(line)
35
+ if not isinstance(frame, dict) or "type" not in frame:
36
+ raise ValueError(f"frame without a type tag: {line[:80]!r}")
37
+ if isinstance(frame.get("data"), str):
38
+ frame["data"] = base64.b64decode(frame["data"])
39
+ return frame