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.
- {cocoonstack_sandbox-0.1.2/cocoonstack_sandbox.egg-info → cocoonstack_sandbox-0.1.4}/PKG-INFO +1 -1
- cocoonstack_sandbox-0.1.4/cocoonsandbox/checkpoint.py +55 -0
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/cocoonsandbox/client.py +54 -11
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/cocoonsandbox/conn.py +2 -0
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/cocoonsandbox/frames.py +5 -7
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/cocoonsandbox/sandbox.py +2 -3
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4/cocoonstack_sandbox.egg-info}/PKG-INFO +1 -1
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/cocoonstack_sandbox.egg-info/SOURCES.txt +1 -0
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/pyproject.toml +1 -1
- cocoonstack_sandbox-0.1.4/tests/test_checkpoint.py +122 -0
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/tests/test_fault_matrix.py +101 -5
- cocoonstack_sandbox-0.1.2/cocoonsandbox/checkpoint.py +0 -32
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/LICENSE +0 -0
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/README.md +0 -0
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/cocoonsandbox/__init__.py +0 -0
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/cocoonsandbox/errors.py +0 -0
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/cocoonsandbox/template.py +0 -0
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/cocoonstack_sandbox.egg-info/dependency_links.txt +0 -0
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/cocoonstack_sandbox.egg-info/top_level.txt +0 -0
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/setup.cfg +0 -0
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/tests/test_client.py +0 -0
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/tests/test_fixtures.py +0 -0
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/tests/test_frames.py +0 -0
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/tests/test_hardening.py +0 -0
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/tests/test_proc.py +0 -0
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/tests/test_proxy.py +0 -0
- {cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/tests/test_wire_binding.py +0 -0
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 =
|
|
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
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
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 = ""
|
|
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,
|
|
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]:
|
{cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/cocoonstack_sandbox.egg-info/SOURCES.txt
RENAMED
|
@@ -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
|
|
@@ -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,
|
|
3
|
-
|
|
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
|
|
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
|
-
|
|
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 ==
|
|
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")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{cocoonstack_sandbox-0.1.2 → cocoonstack_sandbox-0.1.4}/cocoonstack_sandbox.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|