cocoonstack-sandbox 0.1.2__tar.gz → 0.1.4__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.
Files changed (27) hide show
  1. {cocoonstack_sandbox-0.1.2/cocoonstack_sandbox.egg-info → cocoonstack_sandbox-0.1.4}/PKG-INFO +1 -1
  2. cocoonstack_sandbox-0.1.4/cocoonsandbox/checkpoint.py +55 -0
  3. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/cocoonsandbox/client.py +54 -11
  4. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/cocoonsandbox/conn.py +2 -0
  5. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/cocoonsandbox/frames.py +5 -7
  6. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/cocoonsandbox/sandbox.py +2 -3
  7. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4/cocoonstack_sandbox.egg-info}/PKG-INFO +1 -1
  8. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/cocoonstack_sandbox.egg-info/SOURCES.txt +1 -0
  9. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/pyproject.toml +1 -1
  10. cocoonstack_sandbox-0.1.4/tests/test_checkpoint.py +122 -0
  11. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/tests/test_fault_matrix.py +101 -5
  12. cocoonstack_sandbox-0.1.2/cocoonsandbox/checkpoint.py +0 -32
  13. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/LICENSE +0 -0
  14. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/README.md +0 -0
  15. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/cocoonsandbox/__init__.py +0 -0
  16. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/cocoonsandbox/errors.py +0 -0
  17. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/cocoonsandbox/template.py +0 -0
  18. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/cocoonstack_sandbox.egg-info/dependency_links.txt +0 -0
  19. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/cocoonstack_sandbox.egg-info/top_level.txt +0 -0
  20. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/setup.cfg +0 -0
  21. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/tests/test_client.py +0 -0
  22. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/tests/test_fixtures.py +0 -0
  23. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/tests/test_frames.py +0 -0
  24. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/tests/test_hardening.py +0 -0
  25. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/tests/test_proc.py +0 -0
  26. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/tests/test_proxy.py +0 -0
  27. {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/tests/test_wire_binding.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cocoonstack-sandbox
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Python SDK for the cocoon sandbox control plane
5
5
  License: Apache-2.0
6
6
  Requires-Python: >=3.9
@@ -0,0 +1,55 @@
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
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from .sandbox import Sandbox
11
+
12
+
13
+ class Checkpoint:
14
+ """A captured sandbox state on its owner node."""
15
+
16
+ def __init__(self, client, addr: str, rec: dict):
17
+ self._client = client
18
+ self._addr = addr
19
+ self.id = rec["id"]
20
+ self.name = rec.get("name", "")
21
+ self.sandbox_id = rec.get("sandbox_id", "")
22
+ self.created_at = rec.get("created_at", "")
23
+
24
+ def new(self, ttl_seconds: int = 0) -> Sandbox:
25
+ """Claims a fresh sandbox branched from the checkpoint, following a
26
+ redirect to the node that actually holds it; if every candidate
27
+ fails transiently, the claim falls back to the origin once so it
28
+ heals (pulls the checkpoint) locally."""
29
+ # Local import: a top-level one would close the client -> sandbox ->
30
+ # checkpoint cycle.
31
+ from .client import _redirect_fallback
32
+
33
+ claim = {"ttl_seconds": ttl_seconds} if ttl_seconds else {}
34
+ path = f"/v1/checkpoints/{self.id}/claim"
35
+ reply = self._client._post_json(self._addr, path, claim, "claim checkpoint")
36
+ redirect = reply.get("redirect") or []
37
+ if not redirect:
38
+ return self._client._handle_from(self._addr, reply)
39
+ claim["no_redirect"] = True
40
+
41
+ def post(peer):
42
+ return self._client._post_json(peer, path, claim, "claim checkpoint")
43
+
44
+ addr, reply = _redirect_fallback(self._addr, redirect, post, "claim checkpoint")
45
+ return self._client._handle_from(addr, reply)
46
+
47
+ def delete(self) -> None:
48
+ """Removes the checkpoint from its node and asks every peer that node
49
+ currently sees to drop any replica a heal pulled — best-effort eventual
50
+ cleanup, not a fleet-wide revocation. A peer that misses that broadcast
51
+ (offline, partitioned, or joined later) keeps serving branches from its
52
+ replica until the node's checkpoint_ttl_hours ages it out; with that
53
+ TTL at its default of 0 (keep forever), an unreachable peer's replica
54
+ has no cleanup bound at all."""
55
+ self._client._request(self._addr, "DELETE", f"/v1/checkpoints/{self.id}", None, "delete checkpoint")
@@ -29,20 +29,21 @@ class Client:
29
29
 
30
30
  def new(self, template: str, net: str = "", size: str = "", ttl_seconds: int = 0) -> Sandbox:
31
31
  """Claims a sandbox; a warm hit is milliseconds. On a cluster a warm
32
- miss may redirect to a peer, followed transparently."""
32
+ miss may redirect to a peer, followed transparently; if every
33
+ candidate fails transiently, the claim falls back to the origin
34
+ once so it provisions or heals locally."""
33
35
  claim = _claim_body(template, net, size, ttl_seconds)
34
36
  reply = self._post_json(self.addr, "/v1/claim", claim, "claim")
35
37
  redirect = reply.get("redirect") or []
36
- if redirect:
37
- # Retry at a peer with no_redirect set: it warm-or-provisions and
38
- # cannot bounce us again.
39
- claim["no_redirect"] = True
40
- # A candidate that is full (429) or erroring must not abort the
41
- # follow — try every one until a node answers, matching the Go SDK.
42
- return _try_each(redirect, lambda peer: self._handle_from(
43
- peer, self._post_json(peer, "/v1/claim", claim, "claim")),
44
- retry=lambda _: True)
45
- return self._handle_from(self.addr, reply)
38
+ if not redirect:
39
+ return self._handle_from(self.addr, reply)
40
+ claim["no_redirect"] = True
41
+
42
+ def post(peer):
43
+ return self._post_json(peer, "/v1/claim", claim, "claim")
44
+
45
+ addr, reply = _redirect_fallback(self.addr, redirect, post, "claim")
46
+ return self._handle_from(addr, reply)
46
47
 
47
48
  def delete_template(self, template: str, net: str = "", size: str = "") -> None:
48
49
  """Removes a promoted template by name; on a cluster the delete
@@ -191,6 +192,48 @@ def _try_each(candidates, call, retry=lambda exc: exc.status in (404, 0)):
191
192
  raise last_error
192
193
 
193
194
 
195
+ def _retry_transient(exc: APIError) -> bool:
196
+ """Origin-fallback policy: worth the round-trip for a transport failure
197
+ (status 0), a miss (404), full (429), mid-heal (503), an engine/proxy
198
+ failure (500/502/504), or a mid-rotation 401 (the origin proved the
199
+ token valid by issuing the redirect). A served 4xx like a bad request,
200
+ a forbidden token, or an egress conflict is definitive: the origin
201
+ would fail the same way."""
202
+ return exc.status in (0, 401, 404, 429, 503, 500, 502, 504)
203
+
204
+
205
+ def _redirect_fallback(origin: str, candidates: list, post, verb: str):
206
+ """Walks candidates via post(addr) -> raw reply dict, retrying broadly
207
+ (any candidate failure moves to the next) so one wrong candidate doesn't
208
+ cost a candidate that would still succeed. If every candidate is
209
+ exhausted and the last failure was transient (_retry_transient), gives
210
+ the origin one more no_redirect attempt -- the node that issued the
211
+ redirect provisions or heals locally instead of leaving the claim stuck
212
+ on stale gossip. A definitive last failure skips the fallback: the origin
213
+ would fail the same way. A second-level redirect (a compliant server
214
+ never sends one once no_redirect is set) fails the candidate rather than
215
+ being followed. Returns (addr, reply)."""
216
+ def attempt(addr):
217
+ reply = post(addr)
218
+ if reply.get("redirect"):
219
+ raise APIError(verb, 0, f"{addr} redirected again despite no_redirect")
220
+ return addr, reply
221
+
222
+ try:
223
+ return _try_each(candidates, attempt, retry=lambda _: True)
224
+ except APIError as exc:
225
+ if not _retry_transient(exc):
226
+ raise
227
+ try:
228
+ return attempt(origin)
229
+ except APIError as origin_exc:
230
+ # Both halves matter to whoever reads this: the peers' failure says
231
+ # why the claim left the origin, the origin's why returning did not help.
232
+ origin_exc.message = f"{origin_exc.message} (after redirect targets failed: {exc.message})"
233
+ origin_exc.args = (f"{verb}: {origin_exc.message} (HTTP {origin_exc.status})",)
234
+ raise
235
+
236
+
194
237
  def _scatter(addrs, probe):
195
238
  """Probes every addr concurrently and returns the first success; when
196
239
  all probes fail the last error propagates. Loser threads are daemons
@@ -69,6 +69,8 @@ def dial_agent(addr: str, sandbox_id: str, token: str, timeout: float) -> Conn:
69
69
  raise APIError("agent upgrade", 0, f"{name} contains a control character")
70
70
  host, port = addr.rsplit(":", 1)
71
71
  sock = socket.create_connection((host, int(port)), timeout=timeout)
72
+ # Nagle off: exec/write send small back-to-back frames before the first read.
73
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
72
74
  reader = None
73
75
  try:
74
76
  request = (
@@ -10,7 +10,7 @@ import json
10
10
 
11
11
  PROTO_VERSION = 1
12
12
  MAX_FRAME = 8 * 1024 * 1024
13
- FS_CHUNK = 32 * 1024
13
+ FS_CHUNK = 256 * 1024 # silkd's per-frame chunk size, distinct from BULK_CHUNK below
14
14
  # Bulk streams (push tars, port bytes) chunk larger — fewer frames for the
15
15
  # same bytes, still far under MAX_FRAME after base64; mirrors the Go SDK.
16
16
  BULK_CHUNK = 1 << 20
@@ -32,12 +32,10 @@ def encode_request(op: str, **fields) -> bytes:
32
32
  def decode_response(line: bytes) -> dict:
33
33
  """Parses one response frame; the returned dict carries its tag under
34
34
  "type" and any binary payload decoded under "data"."""
35
- # Bulk frames (stdout/stderr/data) are the throughput path where json.loads
36
- # of the big base64 string dominates. base64 is JSON-escape-free and these
37
- # frames carry exactly {"type":...,"data":...}, so slice both out and skip
38
- # json — but only for that exact shape; anything else (extra fields,
39
- # trailing bytes, non-alphabet bytes in the segment) gets the full parse
40
- # instead of a silently wrong slice.
35
+ # base64 is JSON-escape-free, so a frame shaped exactly {"type":...,
36
+ # "data":...} can be sliced directly, skipping json.loads; any other
37
+ # shape (extra fields, trailing bytes, non-alphabet bytes) falls through
38
+ # to the full parse instead of risking a silently wrong slice.
41
39
  if line.startswith(b'{"type":"'):
42
40
  te = line.find(b'"', 9)
43
41
  if te > 0 and line[9:te] in (b"stdout", b"stderr", b"data") and line.startswith(b'","data":"', te):
@@ -67,12 +67,11 @@ class Sandbox:
67
67
  return code
68
68
 
69
69
  def spawn(self, *argv: str, cwd: str = "", env: dict | None = None,
70
- user: str = "", session: str = "") -> int:
70
+ user: str = "") -> int:
71
71
  """Starts argv detached, returning its pid immediately; the process
72
72
  keeps a bounded output ring readable later via logs()/attach()."""
73
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)
74
+ env=env, user=user or None, detach=True)
76
75
  return started["pid"]
77
76
 
78
77
  def ps(self) -> list[dict]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cocoonstack-sandbox
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Python SDK for the cocoon sandbox control plane
5
5
  License: Apache-2.0
6
6
  Requires-Python: >=3.9
@@ -13,6 +13,7 @@ cocoonstack_sandbox.egg-info/PKG-INFO
13
13
  cocoonstack_sandbox.egg-info/SOURCES.txt
14
14
  cocoonstack_sandbox.egg-info/dependency_links.txt
15
15
  cocoonstack_sandbox.egg-info/top_level.txt
16
+ tests/test_checkpoint.py
16
17
  tests/test_client.py
17
18
  tests/test_fault_matrix.py
18
19
  tests/test_fixtures.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cocoonstack-sandbox"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "Python SDK for the cocoon sandbox control plane"
9
9
  requires-python = ">=3.9"
10
10
  license = { text = "Apache-2.0" }
@@ -0,0 +1,122 @@
1
+ """Checkpoint claim redirect: mirrors the cluster redirect follow tested for
2
+ Client.new in test_fault_matrix.py, applied to Checkpoint.new."""
3
+
4
+ import socket
5
+ import threading
6
+ from http.server import HTTPServer
7
+
8
+ import pytest
9
+ from test_client import FakeNode
10
+
11
+ from cocoonsandbox import APIError, Client
12
+
13
+
14
+ @pytest.fixture
15
+ def spawn_node():
16
+ servers = []
17
+
18
+ def spawn(routes):
19
+ handler = type("Node", (FakeNode,), {"routes": routes})
20
+ server = HTTPServer(("127.0.0.1", 0), handler)
21
+ threading.Thread(target=server.serve_forever, daemon=True).start()
22
+ servers.append(server)
23
+ return f"127.0.0.1:{server.server_port}"
24
+
25
+ yield spawn
26
+ for server in servers:
27
+ server.shutdown()
28
+
29
+
30
+ @pytest.fixture
31
+ def dead_addr():
32
+ sock = socket.socket()
33
+ sock.bind(("127.0.0.1", 0))
34
+ port = sock.getsockname()[1]
35
+ sock.close()
36
+ return f"127.0.0.1:{port}"
37
+
38
+
39
+ def test_checkpoint_new_follows_redirect(spawn_node):
40
+ seen = []
41
+
42
+ def claim_at_b(body, path):
43
+ seen.append(body)
44
+ return 200, {"id": "sb_ck1", "token": "tok"}
45
+
46
+ node_b = spawn_node({("POST", "/v1/checkpoints/ck_1/claim"): claim_at_b})
47
+
48
+ entry_hits = []
49
+
50
+ def redirect(body, path):
51
+ entry_hits.append(body)
52
+ return 200, {"redirect": [node_b]}
53
+
54
+ entry = spawn_node({("POST", "/v1/checkpoints/ck_1/claim"): redirect})
55
+
56
+ sb = Client(entry).checkpoint("ck_1").new()
57
+ assert sb.id == "sb_ck1"
58
+ assert sb.owner == node_b
59
+ assert len(entry_hits) == 1
60
+ assert seen[0]["no_redirect"] is True
61
+
62
+
63
+ def test_checkpoint_new_all_candidates_fail(spawn_node):
64
+ # The probed owner transiently fails (500); once exhausted, new() falls
65
+ # back to the origin, which this time fails definitively too.
66
+ path = "/v1/checkpoints/ck_2/claim"
67
+ broken = spawn_node({("POST", path): lambda body, p: (500, {"error": "boom"})})
68
+
69
+ calls = []
70
+
71
+ def entry_claim(body, p):
72
+ calls.append(body)
73
+ if len(calls) == 1:
74
+ return 200, {"redirect": [broken]}
75
+ return 409, {"error": "origin also failed"}
76
+
77
+ entry = spawn_node({("POST", path): entry_claim})
78
+ with pytest.raises(APIError) as exc:
79
+ Client(entry).checkpoint("ck_2").new()
80
+ assert exc.value.status == 409 and "origin also failed" in exc.value.message
81
+ assert len(calls) == 2 and calls[1]["no_redirect"] is True
82
+
83
+
84
+ def test_checkpoint_new_redirect_fallback_heals(spawn_node):
85
+ # The only probed owner is mid-heal (503); once exhausted, new() falls
86
+ # back to the origin, which heals (pulls the checkpoint) locally.
87
+ path = "/v1/checkpoints/ck_5/claim"
88
+ busy = spawn_node({("POST", path): lambda body, p: (503, {"error": "healing"})})
89
+
90
+ calls = []
91
+
92
+ def entry_claim(body, p):
93
+ calls.append(body)
94
+ if len(calls) == 1:
95
+ return 200, {"redirect": [busy]}
96
+ return 200, {"id": "sb_healed", "token": "t"}
97
+
98
+ entry = spawn_node({("POST", path): entry_claim})
99
+ sb = Client(entry).checkpoint("ck_5").new()
100
+ assert sb.id == "sb_healed" and sb.owner == entry
101
+ assert len(calls) == 2 and calls[1]["no_redirect"] is True
102
+
103
+
104
+ def test_checkpoint_new_redirect_never_yields_empty_id(spawn_node, dead_addr):
105
+ # Regression: new() used to hand a bare redirect reply straight to
106
+ # _handle_from, producing a Sandbox with no id/token. It must now follow
107
+ # the redirect and raise when no candidate answers.
108
+ entry = spawn_node({("POST", "/v1/checkpoints/ck_3/claim"): lambda body, path: (200, {"redirect": [dead_addr]})})
109
+
110
+ with pytest.raises(APIError):
111
+ Client(entry).checkpoint("ck_3").new()
112
+
113
+
114
+ def test_checkpoint_new_second_level_redirect_fails(spawn_node):
115
+ # A compliant server never redirects a no_redirect retry; if one does
116
+ # anyway, it must be treated as a failed candidate rather than followed.
117
+ path = "/v1/checkpoints/ck_4/claim"
118
+ node_b = spawn_node({("POST", path): lambda body, p: (200, {"redirect": ["127.0.0.1:1"]})})
119
+ entry = spawn_node({("POST", path): lambda body, p: (200, {"redirect": [node_b]})})
120
+
121
+ with pytest.raises(APIError):
122
+ Client(entry).checkpoint("ck_4").new()
@@ -1,6 +1,8 @@
1
1
  """Fault matrix for the cluster redirect follow and the lookup scatter:
2
- dead first candidate (connection refused), 404 first candidate, all-404 —
3
- and lookup must not pay a hung or dead peer's full timeout."""
2
+ dead first candidate (connection refused), 404 first candidate, a definitive
3
+ candidate error that still tries the next one, every candidate exhausted
4
+ (then the origin fallback heals, fails, or is skipped for a definitive
5
+ error) — and lookup must not pay a hung or dead peer's full timeout."""
4
6
 
5
7
  import socket
6
8
  import threading
@@ -61,13 +63,107 @@ def test_claim_redirect_skips_404_candidate(spawn_node):
61
63
  assert Client(entry).new("rt:24.04").id == "sb_2"
62
64
 
63
65
 
64
- def test_claim_redirect_all_404_propagates_last(spawn_node):
66
+ def test_claim_redirect_all_candidates_fail_then_origin_fallback_fails(spawn_node):
67
+ # Every candidate is a stale miss (404); once exhausted, new() falls
68
+ # back to the origin, which this time fails definitively too.
65
69
  a = spawn_node({("POST", "/v1/claim"): lambda body, path: (404, {"error": "gone a"})})
66
70
  b = spawn_node({("POST", "/v1/claim"): lambda body, path: (404, {"error": "gone b"})})
67
- entry = spawn_node({("POST", "/v1/claim"): lambda body, path: (200, {"redirect": [a, b]})})
71
+
72
+ calls = []
73
+
74
+ def entry_claim(body, path):
75
+ calls.append(body)
76
+ if len(calls) == 1:
77
+ return 200, {"redirect": [a, b]}
78
+ return 409, {"error": "origin also failed"}
79
+
80
+ entry = spawn_node({("POST", "/v1/claim"): entry_claim})
81
+ with pytest.raises(APIError) as exc:
82
+ Client(entry).new("rt:24.04")
83
+ assert exc.value.status == 409 and "origin also failed" in exc.value.message
84
+ # The last candidate's failure must survive into the message: it is what
85
+ # says why the claim left the origin in the first place.
86
+ assert "gone b" in exc.value.message
87
+ assert len(calls) == 2 and calls[1]["no_redirect"] is True
88
+
89
+
90
+ def test_claim_redirect_all_candidates_fail_then_origin_heals(spawn_node):
91
+ # The only candidate holds the record but is full (429); once exhausted,
92
+ # new() falls back to the origin, which heals (provisions locally).
93
+ full_calls = []
94
+
95
+ def full_claim(body, path):
96
+ full_calls.append(body)
97
+ return 429, {"error": "full"}
98
+
99
+ full = spawn_node({("POST", "/v1/claim"): full_claim})
100
+
101
+ calls = []
102
+
103
+ def entry_claim(body, path):
104
+ calls.append(body)
105
+ if len(calls) == 1:
106
+ return 200, {"redirect": [full]}
107
+ return 200, {"id": "sb_healed", "token": "t"}
108
+
109
+ entry = spawn_node({("POST", "/v1/claim"): entry_claim})
110
+ sb = Client(entry).new("rt:24.04")
111
+ assert sb.id == "sb_healed" and sb.owner == entry
112
+ assert len(calls) == 2 and calls[1]["no_redirect"] is True
113
+ assert len(full_calls) == 1
114
+
115
+
116
+ @pytest.mark.parametrize(("status", "message"), [(409, "no egress"), (403, "tenant not allowed")])
117
+ def test_claim_redirect_definitive_error_skips_origin_fallback(spawn_node, status, message):
118
+ # A definitive 4xx is not worth trying another candidate, and not worth
119
+ # falling back to the origin either -- the origin would fail the same way.
120
+ bad = spawn_node({("POST", "/v1/claim"): lambda body, path: (status, {"error": message})})
121
+
122
+ calls = []
123
+
124
+ def entry_claim(body, path):
125
+ calls.append(body)
126
+ return 200, {"redirect": [bad]}
127
+
128
+ entry = spawn_node({("POST", "/v1/claim"): entry_claim})
68
129
  with pytest.raises(APIError) as exc:
69
130
  Client(entry).new("rt:24.04")
70
- assert exc.value.status == 404 and "gone b" in exc.value.message
131
+ assert exc.value.status == status
132
+ assert len(calls) == 1
133
+
134
+
135
+ def test_claim_redirect_401_candidate_falls_back_to_origin(spawn_node):
136
+ stale_calls = []
137
+
138
+ def stale_claim(body, path):
139
+ stale_calls.append(body)
140
+ return 401, {"error": "invalid api token"}
141
+
142
+ stale = spawn_node({("POST", "/v1/claim"): stale_claim})
143
+
144
+ calls = []
145
+
146
+ def entry_claim(body, path):
147
+ calls.append(body)
148
+ if len(calls) == 1:
149
+ return 200, {"redirect": [stale]}
150
+ return 200, {"id": "sb_local", "token": "t"}
151
+
152
+ entry = spawn_node({("POST", "/v1/claim"): entry_claim})
153
+ sb = Client(entry).new("rt:24.04")
154
+ assert sb.id == "sb_local" and sb.owner == entry
155
+ assert len(calls) == 2 and calls[1]["no_redirect"] is True
156
+ assert len(stale_calls) == 1
157
+
158
+
159
+ def test_claim_redirect_candidate_definitive_error_still_tries_next_candidate(spawn_node):
160
+ # Candidate one is wrong for this claim (403, definitive) but candidate
161
+ # two would still succeed -- the per-candidate walk retries broadly, so
162
+ # one ill-suited candidate must not cost a candidate that would answer.
163
+ forbidden = spawn_node({("POST", "/v1/claim"): lambda body, path: (403, {"error": "tenant not allowed"})})
164
+ good = spawn_node({("POST", "/v1/claim"): lambda body, path: (200, {"id": "sb_3", "token": "t"})})
165
+ entry = spawn_node({("POST", "/v1/claim"): lambda body, path: (200, {"redirect": [forbidden, good]})})
166
+ assert Client(entry).new("rt:24.04").id == "sb_3"
71
167
 
72
168
 
73
169
  def test_delete_template_skips_dead_owner(spawn_node, dead_addr):
@@ -1,32 +0,0 @@
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
- from typing import TYPE_CHECKING
8
-
9
- if TYPE_CHECKING:
10
- from .sandbox import Sandbox
11
-
12
-
13
- class Checkpoint:
14
- """A captured sandbox state on its owner node."""
15
-
16
- def __init__(self, client, addr: str, rec: dict):
17
- self._client = client
18
- self._addr = addr
19
- self.id = rec["id"]
20
- self.name = rec.get("name", "")
21
- self.sandbox_id = rec.get("sandbox_id", "")
22
- self.created_at = rec.get("created_at", "")
23
-
24
- def new(self, ttl_seconds: int = 0) -> Sandbox:
25
- """Claims a fresh sandbox branched from the checkpoint."""
26
- body = {"ttl_seconds": ttl_seconds} if ttl_seconds else {}
27
- reply = self._client._post_json(self._addr, f"/v1/checkpoints/{self.id}/claim", body, "claim checkpoint")
28
- return self._client._handle_from(self._addr, reply)
29
-
30
- def delete(self) -> None:
31
- """Removes the checkpoint from its node."""
32
- self._client._request(self._addr, "DELETE", f"/v1/checkpoints/{self.id}", None, "delete checkpoint")