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.
- cocoonsandbox/__init__.py +33 -0
- cocoonsandbox/checkpoint.py +28 -0
- cocoonsandbox/client.py +141 -0
- cocoonsandbox/conn.py +94 -0
- cocoonsandbox/errors.py +41 -0
- cocoonsandbox/frames.py +39 -0
- cocoonsandbox/sandbox.py +499 -0
- cocoonsandbox/template.py +40 -0
- cocoonstack_sandbox-0.1.0.dist-info/METADATA +32 -0
- cocoonstack_sandbox-0.1.0.dist-info/RECORD +12 -0
- cocoonstack_sandbox-0.1.0.dist-info/WHEEL +5 -0
- cocoonstack_sandbox-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
|
cocoonsandbox/client.py
ADDED
|
@@ -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
|
cocoonsandbox/errors.py
ADDED
|
@@ -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)."""
|
cocoonsandbox/frames.py
ADDED
|
@@ -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
|
cocoonsandbox/sandbox.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
cocoonsandbox
|