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.
- cocoonstack_sandbox-0.1.0/PKG-INFO +32 -0
- cocoonstack_sandbox-0.1.0/README.md +24 -0
- cocoonstack_sandbox-0.1.0/cocoonsandbox/__init__.py +33 -0
- cocoonstack_sandbox-0.1.0/cocoonsandbox/checkpoint.py +28 -0
- cocoonstack_sandbox-0.1.0/cocoonsandbox/client.py +141 -0
- cocoonstack_sandbox-0.1.0/cocoonsandbox/conn.py +94 -0
- cocoonstack_sandbox-0.1.0/cocoonsandbox/errors.py +41 -0
- cocoonstack_sandbox-0.1.0/cocoonsandbox/frames.py +39 -0
- cocoonstack_sandbox-0.1.0/cocoonsandbox/sandbox.py +499 -0
- cocoonstack_sandbox-0.1.0/cocoonsandbox/template.py +40 -0
- cocoonstack_sandbox-0.1.0/cocoonstack_sandbox.egg-info/PKG-INFO +32 -0
- cocoonstack_sandbox-0.1.0/cocoonstack_sandbox.egg-info/SOURCES.txt +19 -0
- cocoonstack_sandbox-0.1.0/cocoonstack_sandbox.egg-info/dependency_links.txt +1 -0
- cocoonstack_sandbox-0.1.0/cocoonstack_sandbox.egg-info/top_level.txt +1 -0
- cocoonstack_sandbox-0.1.0/pyproject.toml +22 -0
- cocoonstack_sandbox-0.1.0/setup.cfg +4 -0
- cocoonstack_sandbox-0.1.0/tests/test_client.py +96 -0
- cocoonstack_sandbox-0.1.0/tests/test_fixtures.py +36 -0
- cocoonstack_sandbox-0.1.0/tests/test_proc.py +79 -0
- cocoonstack_sandbox-0.1.0/tests/test_proxy.py +52 -0
- cocoonstack_sandbox-0.1.0/tests/test_wire_binding.py +129 -0
|
@@ -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
|