cocoonstack-sandbox 0.1.0__py3-none-any.whl

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,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
+
cocoonsandbox/conn.py ADDED
@@ -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
@@ -0,0 +1,499 @@
1
+ """Sandbox: the data-plane handle. Every RPC opens one relayed silkd
2
+ connection via the owner node; sessions and processes are server-side state,
3
+ so nothing is lost between calls — including across a transparent hibernate
4
+ wake."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import contextlib
9
+ import socket
10
+ import threading
11
+
12
+ from .checkpoint import Checkpoint
13
+ from .conn import Conn, dial_agent
14
+ from .errors import APIError, ExitError, ProtocolError, SandboxError
15
+ from .frames import BULK_CHUNK, FS_CHUNK
16
+ from .template import Template
17
+
18
+
19
+ class Sandbox:
20
+ """One claimed microVM."""
21
+
22
+ def __init__(self, client, id: str, token: str, owner: str,
23
+ deadline: str = "", from_checkpoint: str = ""):
24
+ self._client = client
25
+ self.id = id
26
+ self.token = token
27
+ self.owner = owner
28
+ self.deadline = deadline
29
+ self.from_checkpoint = from_checkpoint
30
+
31
+ def __enter__(self):
32
+ return self
33
+
34
+ def __exit__(self, *exc):
35
+ with contextlib.suppress(Exception):
36
+ self.close()
37
+
38
+ def exec(self, *argv: str, cwd: str = "", env: dict | None = None,
39
+ user: str = "", session: str = "", stdin: bytes = b"") -> str:
40
+ """Runs argv to completion and returns stdout; a non-zero exit raises
41
+ ExitError carrying stderr."""
42
+ out, err = bytearray(), bytearray()
43
+ code = self.run(list(argv), cwd=cwd, env=env, user=user, session=session,
44
+ stdin=stdin, on_stdout=out.extend, on_stderr=err.extend)
45
+ if code != 0:
46
+ raise ExitError(code, err.decode(errors="replace"))
47
+ return out.decode(errors="replace")
48
+
49
+ def run(self, argv: list[str], cwd: str = "", env: dict | None = None,
50
+ user: str = "", session: str = "", stdin: bytes = b"",
51
+ on_stdout=None, on_stderr=None) -> int:
52
+ """Runs argv streaming stdio through the callbacks (raw bytes — chunk
53
+ boundaries may split multi-byte sequences); returns the exit code."""
54
+ with self._dial() as conn:
55
+ conn.send("exec", argv=argv, cwd=cwd or None, env=env,
56
+ user=user or None, session=session or None)
57
+ if stdin:
58
+ _send_chunks(conn, stdin, op="stdin")
59
+ conn.send("stdin_close")
60
+ for frame in conn.recv_until("exit"):
61
+ if frame["type"] == "stdout" and on_stdout:
62
+ on_stdout(frame["data"])
63
+ elif frame["type"] == "stderr" and on_stderr:
64
+ on_stderr(frame["data"])
65
+ elif frame["type"] == "exit":
66
+ return frame["code"]
67
+ raise ProtocolError("exec stream ended without an exit frame")
68
+
69
+ def spawn(self, *argv: str, cwd: str = "", env: dict | None = None,
70
+ user: str = "", session: str = "") -> int:
71
+ """Starts argv detached, returning its pid immediately; the process
72
+ keeps a bounded output ring readable later via logs()/attach()."""
73
+ started = self._call("exec", "started", argv=list(argv), cwd=cwd or None,
74
+ env=env, user=user or None, session=session or None,
75
+ detach=True)
76
+ return started["pid"]
77
+
78
+ def ps(self) -> list[dict]:
79
+ """Lists tracked processes: {pid, argv, detached, state,
80
+ exit_code?, started_at_epoch_secs}."""
81
+ return self._call("ps", "procs")["procs"]
82
+
83
+ def kill(self, pid: int, signal: int | None = None) -> None:
84
+ """Signals a tracked process (default SIGKILL); killing one that
85
+ already exited is a no-op success."""
86
+ self._done_rpc("kill", pid=pid, signal=signal)
87
+
88
+ def logs(self, pid: int, on_stdout=None, on_stderr=None) -> int | None:
89
+ """Replays a process's ring-buffered output through the callbacks;
90
+ returns its exit code if it already exited, else None."""
91
+ return self._drain_proc("logs", pid, on_stdout, on_stderr)
92
+
93
+ def attach(self, pid: int, on_stdout=None, on_stderr=None) -> int | None:
94
+ """Replays buffered output then follows live output until the
95
+ process exits, returning its exit code (None only if the proc table
96
+ dropped it mid-attach)."""
97
+ return self._drain_proc("attach", pid, on_stdout, on_stderr)
98
+
99
+ def write_file(self, path: str, data: bytes, mode: int | None = None) -> None:
100
+ """Writes data to path atomically (temp + rename on the guest)."""
101
+ with self._dial() as conn:
102
+ conn.send("fs_write", path=path, mode=mode)
103
+ _send_chunks(conn, data)
104
+ conn.send("data_end")
105
+ _expect(conn, "done")
106
+
107
+ def read_file(self, path: str) -> bytes:
108
+ with self._dial() as conn:
109
+ conn.send("fs_read", path=path)
110
+ return _drain_data(conn)
111
+
112
+ def list_dir(self, path: str) -> list[dict]:
113
+ with self._dial() as conn:
114
+ conn.send("fs_list", path=path)
115
+ entries: list[dict] = []
116
+ for frame in conn.recv_until("done"):
117
+ entries.extend(frame.get("entries") or [])
118
+ return entries
119
+
120
+ def stat(self, path: str) -> dict:
121
+ """Returns {kind, size, mode, mtime_epoch_secs} for path."""
122
+ return self._call("fs_stat", "stat", path=path)["info"]
123
+
124
+ def mkdir(self, path: str, parents: bool = False) -> None:
125
+ self._done_rpc("fs_mkdir", path=path, parents=parents or None)
126
+
127
+ def remove(self, path: str, recursive: bool = False) -> None:
128
+ self._done_rpc("fs_rm", path=path, recursive=recursive or None)
129
+
130
+ def rename(self, src: str, dst: str) -> None:
131
+ self._done_rpc("fs_rename", **{"from": src, "to": dst})
132
+
133
+ def push(self, dest: str, tar_stream: bytes) -> None:
134
+ """Extracts a tar archive into dest — atomic against a truncated
135
+ stream; the only project-ingestion path on the no-network lane."""
136
+ with self._dial() as conn:
137
+ conn.send("fs_push", dest=dest)
138
+ _send_chunks(conn, tar_stream, chunk=BULK_CHUNK)
139
+ conn.send("data_end")
140
+ _expect(conn, "done")
141
+
142
+ def pull(self, path: str) -> bytes:
143
+ """Returns path (file or tree) as a tar archive."""
144
+ with self._dial() as conn:
145
+ conn.send("fs_pull", path=path)
146
+ return _drain_data(conn)
147
+
148
+ def find(self, path: str, pattern: str, glob: str = "") -> list[dict]:
149
+ with self._dial() as conn:
150
+ conn.send("fs_find", path=path, pattern=pattern, glob=glob or None)
151
+ return [f for f in conn.recv_until("done") if f["type"] == "match"]
152
+
153
+ def replace(self, files: list[str], pattern: str, replacement: str) -> list[dict]:
154
+ with self._dial() as conn:
155
+ conn.send("fs_replace", files=files, pattern=pattern, replacement=replacement)
156
+ return [f for f in conn.recv_until("done") if f["type"] == "replaced"]
157
+
158
+ def git_clone(self, url: str, path: str, branch: str = "", depth: int = 0, auth: str = "") -> None:
159
+ """Clones into path (egress lane only; the none lane answers a typed
160
+ unimplemented error pointing at push)."""
161
+ self._done_rpc("git_clone", url=url, path=path, branch=branch or None,
162
+ depth=depth or None, auth=auth or None)
163
+
164
+ def git_status(self, path: str) -> dict:
165
+ return self._call("git_status", "git_status_result", path=path)
166
+
167
+ def git_add(self, path: str, files: list[str]) -> None:
168
+ self._done_rpc("git_add", path=path, files=files)
169
+
170
+ def git_commit(self, path: str, message: str, author: str) -> str:
171
+ """Commits staged changes; returns the commit hash."""
172
+ return self._call("git_commit", "git_commit_result",
173
+ path=path, message=message, author=author).get("hash", "")
174
+
175
+ def git_push(self, path: str, auth: str = "") -> None:
176
+ self._done_rpc("git_push", path=path, auth=auth or None)
177
+
178
+ def git_pull(self, path: str, auth: str = "") -> None:
179
+ self._done_rpc("git_pull", path=path, auth=auth or None)
180
+
181
+ def git_branches(self, path: str) -> dict:
182
+ return self._call("git_branch", "git_branches", path=path, action="list")
183
+
184
+ def git_checkout(self, path: str, name: str) -> None:
185
+ self._done_rpc("git_branch", path=path, action="checkout", name=name)
186
+
187
+ def git_create_branch(self, path: str, name: str) -> None:
188
+ self._done_rpc("git_branch", path=path, action="create", name=name)
189
+
190
+ def git_delete_branch(self, path: str, name: str) -> None:
191
+ self._done_rpc("git_branch", path=path, action="delete", name=name)
192
+
193
+ def watch(self, path: str, recursive: bool = False) -> Watcher:
194
+ """Streams filesystem events under path; events after the returned
195
+ Watcher exists are guaranteed captured. Close it to stop."""
196
+ conn, _ = self._open_stream("fs_watch", path=path, recursive=recursive or None)
197
+ return Watcher(conn)
198
+
199
+ def session(self, cwd: str = "", env: dict | None = None) -> Session:
200
+ """Creates a persistent shell: cd/export/aliases survive across exec
201
+ calls routed into it."""
202
+ created = self._call("session_create", "session_created", cwd=cwd or None, env=env)
203
+ return Session(self, created["id"])
204
+
205
+ def sessions(self) -> list[str]:
206
+ return self._call("session_list", "sessions").get("sessions") or []
207
+
208
+ def fork(self, count: int, ttl_seconds: int = 0) -> list[Sandbox]:
209
+ """Clones this sandbox into count independent children carrying its
210
+ exact memory and disk state; all-or-nothing."""
211
+ body = {"token": self.token, "count": count}
212
+ if ttl_seconds:
213
+ body["ttl_seconds"] = ttl_seconds
214
+ reply = self._client._post_json(self.owner, f"/v1/sandboxes/{self.id}/fork", body, "fork")
215
+ return [self._client._handle_from(self.owner, child) for child in reply.get("children") or []]
216
+
217
+ def hibernate(self) -> None:
218
+ """Snapshots and stops the VM, freeing its memory; the next call that
219
+ reaches the guest wakes it transparently, state intact."""
220
+ self._client._request(self.owner, "POST", f"/v1/sandboxes/{self.id}/hibernate",
221
+ None, "hibernate", bearer=self.token)
222
+
223
+ def checkpoint(self, name: str = "") -> Checkpoint:
224
+ """Captures full state without stopping the sandbox; the returned
225
+ Checkpoint branches fresh sandboxes from that exact moment."""
226
+ body = {"token": self.token}
227
+ if name:
228
+ body["name"] = name
229
+ reply = self._client._post_json(self.owner, f"/v1/sandboxes/{self.id}/checkpoint", body, "checkpoint")
230
+ return Checkpoint(self._client, self.owner, reply["checkpoint"])
231
+
232
+ def promote(self, template: str) -> Template:
233
+ """Publishes this sandbox's state as a claimable template on its
234
+ node; the returned handle is bound to that node."""
235
+ reply = self._client._post_json(self.owner, f"/v1/sandboxes/{self.id}/promote",
236
+ {"token": self.token, "template": template}, "promote")
237
+ key = reply["key"]
238
+ return Template(self._client, self.owner, key["template"], key.get("net", ""), key.get("size", ""))
239
+
240
+ def start_lsp(self, language: str, root: str = "") -> Lsp:
241
+ """Spawns the language server the flavor image provides for language;
242
+ the base image ships none, so it raises SilkdError(kind="not_found").
243
+ The returned handle streams JSON-RPC over the relay."""
244
+ started = self._call("lsp_start", "lsp_started", language=language, root=root or None)
245
+ return Lsp(self, started["server_id"])
246
+
247
+ def open_pty(self, cols: int = 80, rows: int = 24, cwd: str = "",
248
+ env: dict | None = None, user: str = "") -> Pty:
249
+ """Runs the guest shell under a pty; returns a byte-stream handle.
250
+ A pty is a process guest-side: resize goes through its pid."""
251
+ conn, started = self._open_stream("pty_open", expect="started", cols=cols,
252
+ rows=rows, cwd=cwd or None, env=env, user=user or None)
253
+ return Pty(self, conn, started["pid"])
254
+
255
+ def proxy_port(self, local_addr: str, port: int) -> socket.socket:
256
+ """Serves a guest port on a local listener for unmodified local
257
+ tools; returns the listening socket (close it to stop). local_addr
258
+ is "host:port"; port 0 picks a free one."""
259
+ host, _, lport = local_addr.rpartition(":")
260
+ listener = socket.create_server((host or "127.0.0.1", int(lport)))
261
+ threading.Thread(target=self._proxy_accept_loop,
262
+ args=(listener, port), daemon=True).start()
263
+ return listener
264
+
265
+ def preview_url(self, port: int, ttl_seconds: int = 0) -> str:
266
+ """Mints a shareable URL serving the guest HTTP port from a browser,
267
+ valid for ttl_seconds (the node clamps it to the claim's lease).
268
+ Requires the node to have preview configured."""
269
+ body = {"token": self.token, "port": port}
270
+ if ttl_seconds:
271
+ body["ttl_seconds"] = ttl_seconds
272
+ reply = self._client._post_json(self.owner, f"/v1/sandboxes/{self.id}/preview", body, "preview")
273
+ return reply["url"]
274
+
275
+ def dial_port(self, port: int) -> PortConn:
276
+ """Opens a byte stream to 127.0.0.1:port inside the guest."""
277
+ conn, _ = self._open_stream("port_forward", port=port)
278
+ return PortConn(conn)
279
+
280
+ def close(self) -> None:
281
+ """Releases the sandbox; its VM is destroyed. Releasing one already
282
+ gone is not an error — double-release and reap races stay silent,
283
+ matching the Go SDK."""
284
+ try:
285
+ self._client._request(self.owner, "POST", f"/v1/sandboxes/{self.id}/release",
286
+ None, "release", bearer=self.token)
287
+ except APIError as exc:
288
+ if exc.status != 404:
289
+ raise
290
+
291
+ def _dial(self) -> Conn:
292
+ return dial_agent(self.owner, self.id, self.token, self._client.timeout)
293
+
294
+ def _open_stream(self, op: str, expect: str = "ready", **fields) -> tuple[Conn, dict]:
295
+ """Dials, sends op, and waits for the handshake frame, closing the
296
+ conn on any failure so no socket leaks on the error path."""
297
+ conn = self._dial()
298
+ try:
299
+ conn.send(op, **fields)
300
+ frame = _expect(conn, expect)
301
+ except Exception:
302
+ conn.close()
303
+ raise
304
+ return conn, frame
305
+
306
+ def _proxy_accept_loop(self, listener: socket.socket, port: int) -> None:
307
+ with contextlib.suppress(OSError):
308
+ while True:
309
+ local, _ = listener.accept()
310
+ threading.Thread(target=self._proxy_conn,
311
+ args=(local, port), daemon=True).start()
312
+
313
+ def _proxy_conn(self, local: socket.socket, port: int) -> None:
314
+ try:
315
+ guest = self.dial_port(port)
316
+ except Exception:
317
+ local.close()
318
+ return
319
+
320
+ def pump_out():
321
+ with contextlib.suppress(Exception):
322
+ while True:
323
+ chunk = guest.recv()
324
+ if not chunk:
325
+ break
326
+ local.sendall(chunk)
327
+ with contextlib.suppress(OSError):
328
+ local.close()
329
+
330
+ threading.Thread(target=pump_out, daemon=True).start()
331
+ try:
332
+ while True:
333
+ chunk = local.recv(FS_CHUNK)
334
+ if not chunk:
335
+ break
336
+ guest.send(chunk)
337
+ guest.close_write()
338
+ except Exception:
339
+ guest.close()
340
+
341
+ def _call(self, op: str, expect: str, **fields) -> dict:
342
+ with self._dial() as conn:
343
+ conn.send(op, **fields)
344
+ return _expect(conn, expect)
345
+
346
+ def _done_rpc(self, op: str, **fields) -> None:
347
+ self._call(op, "done", **fields)
348
+
349
+ def _drain_proc(self, op: str, pid: int, on_stdout, on_stderr) -> int | None:
350
+ with self._dial() as conn:
351
+ conn.send(op, pid=pid)
352
+ for frame in conn.recv_until("exit", "done"):
353
+ t = frame["type"]
354
+ if t == "stdout" and on_stdout:
355
+ on_stdout(frame["data"])
356
+ elif t == "stderr" and on_stderr:
357
+ on_stderr(frame["data"])
358
+ elif t == "exit":
359
+ return frame["code"]
360
+ return None
361
+
362
+ class Session:
363
+ """A persistent shell inside the sandbox, addressed by id."""
364
+
365
+ def __init__(self, sandbox: Sandbox, id: str):
366
+ self._sandbox = sandbox
367
+ self.id = id
368
+
369
+ def exec(self, *argv: str) -> str:
370
+ """Runs argv inside the session's shell; state persists."""
371
+ return self._sandbox.exec(*argv, session=self.id)
372
+
373
+ def close(self) -> None:
374
+ self._sandbox._done_rpc("session_rm", id=self.id)
375
+
376
+ class Watcher:
377
+ """A live filesystem event stream; iterate for {kind, path} events."""
378
+
379
+ def __init__(self, conn: Conn):
380
+ self._conn = conn
381
+
382
+ def __iter__(self):
383
+ # The stream is connection-bound: closing the watcher (or the server
384
+ # dropping the conn) ends iteration rather than raising.
385
+ while True:
386
+ try:
387
+ frame = self._conn.recv()
388
+ except (SandboxError, OSError, ValueError):
389
+ return
390
+ if frame["type"] == "event":
391
+ yield frame
392
+
393
+ def close(self) -> None:
394
+ self._conn.close()
395
+
396
+ class Pty:
397
+ """An interactive shell under a guest pty; read/write are raw bytes."""
398
+
399
+ def __init__(self, sandbox: Sandbox, conn: Conn, pid: int):
400
+ self._sandbox = sandbox
401
+ self._conn = conn
402
+ self.pid = pid
403
+
404
+ def __enter__(self):
405
+ return self
406
+
407
+ def __exit__(self, *exc):
408
+ self.close()
409
+
410
+ def read(self) -> bytes:
411
+ """The next output chunk; b'' once the shell exits."""
412
+ frame = self._conn.recv()
413
+ if frame["type"] == "exit":
414
+ return b""
415
+ return frame.get("data") or b""
416
+
417
+ def write(self, data: bytes) -> None:
418
+ self._conn.send("stdin", data=data)
419
+
420
+ def resize(self, cols: int, rows: int) -> None:
421
+ self._sandbox._done_rpc("pty_resize", pid=self.pid, cols=cols, rows=rows)
422
+
423
+ def close(self) -> None:
424
+ self._conn.close()
425
+
426
+
427
+ class Lsp:
428
+ """A language server in the sandbox, spoken to over the relay. silkd is a
429
+ broker: it pipes JSON-RPC bytes; the caller frames and correlates."""
430
+
431
+ def __init__(self, sandbox: Sandbox, server_id: str):
432
+ self._sandbox = sandbox
433
+ self.server_id = server_id
434
+
435
+ def request(self) -> PortConn:
436
+ """Opens the JSON-RPC byte stream: writes go to the server's stdin,
437
+ recv returns its stdout. A server serves one request for its
438
+ lifetime; closing the stream ends the session and reaps it."""
439
+ conn, _ = self._sandbox._open_stream("lsp_request", server_id=self.server_id)
440
+ return PortConn(conn)
441
+
442
+ def stop(self) -> None:
443
+ """Kills the language server."""
444
+ self._sandbox._done_rpc("lsp_stop", server_id=self.server_id)
445
+
446
+
447
+ class PortConn:
448
+ """A byte stream to a guest port, relayed over the silkd connection."""
449
+
450
+ def __init__(self, conn: Conn):
451
+ self._conn = conn
452
+ self._eof = False
453
+
454
+ def __enter__(self):
455
+ return self
456
+
457
+ def __exit__(self, *exc):
458
+ self.close()
459
+
460
+ def send(self, data: bytes) -> None:
461
+ _send_chunks(self._conn, data, chunk=BULK_CHUNK)
462
+
463
+ def recv(self) -> bytes:
464
+ """Returns the next chunk from the guest; b'' on stream end."""
465
+ if self._eof:
466
+ return b""
467
+ frame = self._conn.recv()
468
+ if frame["type"] in ("done", "exit"):
469
+ self._eof = True
470
+ return b""
471
+ return frame.get("data") or b""
472
+
473
+ def close_write(self) -> None:
474
+ """Half-close: signals EOF to the guest side; reads keep working."""
475
+ self._conn.send("data_end")
476
+ self._conn.close_write()
477
+
478
+ def close(self) -> None:
479
+ self._conn.close()
480
+
481
+
482
+ def _send_chunks(conn: Conn, data: bytes, op: str = "data", chunk: int = FS_CHUNK) -> None:
483
+ view = memoryview(data)
484
+ for off in range(0, len(view), chunk):
485
+ conn.send(op, data=view[off:off + chunk])
486
+
487
+
488
+ def _expect(conn: Conn, frame_type: str) -> dict:
489
+ frame = conn.recv()
490
+ if frame["type"] != frame_type:
491
+ raise ProtocolError(f"expected {frame_type}, got {frame['type']}")
492
+ return frame
493
+
494
+ def _drain_data(conn: Conn) -> bytes:
495
+ chunks = []
496
+ for frame in conn.recv_until("done"):
497
+ if frame["type"] == "data":
498
+ chunks.append(frame["data"])
499
+ return b"".join(chunks)
@@ -0,0 +1,40 @@
1
+ """Template: a promoted sandbox state, claimable by name. The handle is
2
+ bound to the node that holds it, so it works the instant promote returns —
3
+ name-based Client calls route via gossip and lag a promote by about a tick."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import urllib.parse
8
+
9
+
10
+ class Template:
11
+ """A promoted template on its owner node."""
12
+
13
+ def __init__(self, client, addr: str, name: str, net: str, size: str):
14
+ self._client = client
15
+ self._addr = addr
16
+ self.name = name
17
+ self.net = net
18
+ self.size = size
19
+
20
+ def new(self, ttl_seconds: int = 0):
21
+ """Claims a sandbox cloned from the template, on the template's
22
+ node; the key axes are the template's own."""
23
+ # Local import: a top-level one would close the client → sandbox →
24
+ # template cycle.
25
+ from .client import _claim_body
26
+
27
+ claim = _claim_body(self.name, self.net, self.size, ttl_seconds)
28
+ claim["no_redirect"] = True
29
+ reply = self._client._post_json(self._addr, "/v1/claim", claim, "claim")
30
+ return self._client._handle_from(self._addr, reply)
31
+
32
+ def delete(self) -> None:
33
+ """Removes the template from its node."""
34
+ query = {"template": self.name, "no_redirect": "1"}
35
+ if self.net:
36
+ query["net"] = self.net
37
+ if self.size:
38
+ query["size"] = self.size
39
+ self._client._request(self._addr, "DELETE", "/v1/templates?" + urllib.parse.urlencode(query),
40
+ None, "delete template")
@@ -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,12 @@
1
+ cocoonsandbox/__init__.py,sha256=zAqiwobV2Ps_mLgMeBfws5VwDZytfMaZDBtG0HmQAFI,869
2
+ cocoonsandbox/checkpoint.py,sha256=WIcp_gJgCOMZwiOgG6U0Zi4eq779HqKH6L0Tk3-UIqw,1156
3
+ cocoonsandbox/client.py,sha256=yXZotPK6I78OXBBSMeZiuMTShbx0Gil2pa8KGeCJCY4,6019
4
+ cocoonsandbox/conn.py,sha256=Etd34Iqvrg1MErng5BQMjtf04YWyDqgP-2huRw4nQdU,3338
5
+ cocoonsandbox/errors.py,sha256=R20up-zVgz5TBURYZm7Fb4QHC6HEbFLkuD9lBnT7st8,1126
6
+ cocoonsandbox/frames.py,sha256=CYE0hVKmrhNq4x5Yaox33mQ0cNw7T95GnhdyHlSOL34,1510
7
+ cocoonsandbox/sandbox.py,sha256=g1twBAqe7FpZss0uzPzomtTKiCr3W5t944XYxwa8aMU,20600
8
+ cocoonsandbox/template.py,sha256=QPvC5LDHf2dDmctggip_kRxfNe-PzlDaeZOh7A5peFM,1536
9
+ cocoonstack_sandbox-0.1.0.dist-info/METADATA,sha256=PQYpetdPDBpAhKEDTRCPOlrWM8nx55EIHw8F4_VEgVM,1226
10
+ cocoonstack_sandbox-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
11
+ cocoonstack_sandbox-0.1.0.dist-info/top_level.txt,sha256=WUonHQ4lXa4-Qqy-HMEFjVCndRg-xu7-AkNYqXsYqKQ,14
12
+ cocoonstack_sandbox-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ cocoonsandbox