From 3422f426a184bcc6d95931a9f524db63c213c183 Mon Sep 17 00:00:00 2001 From: CMGS Date: Mon, 28 Sep 2026 17:19:40 +0800 Subject: [PATCH 1/2] fix(sandboxd): a claim that requires a promoted template is never redirected to a warm peer redirectClaim offered the warm candidates first, and that redirect carries no require_promoted, so a raw caller following it took a pool VM the docs promise it never gets; a required claim now goes only to the template's owners, else 404. --- sandboxd/server/server.go | 2 +- sandboxd/server/server_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/sandboxd/server/server.go b/sandboxd/server/server.go index a88f05cc..11db0730 100644 --- a/sandboxd/server/server.go +++ b/sandboxd/server/server.go @@ -378,7 +378,7 @@ func (s *Server) redirectClaim(ctx context.Context, w http.ResponseWriter, req t if s.placer == nil || req.NoRedirect { return false } - if s.writeRedirect(w, s.placer.Candidates(hash)) { + if !req.RequirePromoted && s.writeRedirect(w, s.placer.Candidates(hash)) { return true } owners := s.templateOwners(s.placer.TemplateOwners, hash, tenant) diff --git a/sandboxd/server/server_test.go b/sandboxd/server/server_test.go index f5442998..d6225f85 100644 --- a/sandboxd/server/server_test.go +++ b/sandboxd/server/server_test.go @@ -1725,6 +1725,36 @@ func TestForeignTemplateGossipNeverEscalates(t *testing.T) { } } +func TestARequiredPromotedClaimIsNeverSentToAWarmPeer(t *testing.T) { + for _, tt := range []struct { + name string + require bool + wantStatus int + wantWarm bool + }{ + {"a plain claim follows the warm peer", false, http.StatusOK, true}, + {"a required promoted claim answers 404 rather than a warm peer", true, http.StatusNotFound, false}, + } { + t.Run(tt.name, func(t *testing.T) { + mgr := &fakeManager{claim: func(context.Context, types.PoolKey, time.Duration) (*types.Sandbox, error) { + return nil, pool.ErrUnknownTemplate + }} + srv := New("sekret", nil, "node-a:7777", mgr, &fakeDialer{}, &fakePlacer{addrs: []string{"warm:7777"}}, nil, nil, nil) + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(func() { ts.Close(); srv.CloseRelays() }) + + body, _ := json.Marshal(types.ClaimRequest{Template: "ns/app", RequirePromoted: tt.require}) + resp := postJSON(t, ts.URL+"/v1/claim", "sekret", string(body)) + defer resp.Body.Close() + var cr types.ClaimResponse + _ = json.UnmarshalRead(resp.Body, &cr) + if resp.StatusCode != tt.wantStatus || (len(cr.Redirect) > 0) != tt.wantWarm { + t.Errorf("status=%d redirect=%v, want %d and warm redirect %v", resp.StatusCode, cr.Redirect, tt.wantStatus, tt.wantWarm) + } + }) + } +} + func TestAPlainClaimRequiringAPromotedTemplateNeverColdBoots(t *testing.T) { for _, tt := range []struct { name string From dc41c926ff489e3ebb94f4408ab208826f2c46fa Mon Sep 17 00:00:00 2001 From: CMGS Date: Mon, 28 Sep 2026 17:19:40 +0800 Subject: [PATCH 2/2] fix(sdk-python): Template.new asks for the promoted template on its first request The Go SDK's 900b2c6 fix, mirrored: a stale Python handle could take a warm VM or cold-boot an image named like the template. --- sdk/python/cocoonsandbox/template.py | 1 + sdk/python/tests/test_client.py | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/sdk/python/cocoonsandbox/template.py b/sdk/python/cocoonsandbox/template.py index a7261c54..d3adfa71 100644 --- a/sdk/python/cocoonsandbox/template.py +++ b/sdk/python/cocoonsandbox/template.py @@ -36,6 +36,7 @@ def new( from .client import _claim_body claim = _claim_body(self.name, self.net, self.size, ttl_seconds, volumes, mount) + claim["require_promoted"] = True if volumes: return self._client._claim_from(self._addr, claim, deadline=deadline) claim["no_redirect"] = True diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index a3a7eac8..8dbfc492 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -131,6 +131,14 @@ def test_claim_keeps_mounting_by_default(node): assert "volumes_attach_only" not in seen[0] +def test_template_claim_asks_for_the_promoted_template(node): + seen = recording_claim({"id": "sb_9", "token": "tok"}) + Template(Client(node), node, "task:v1", "none", "small").new() + assert seen == [ + {"template": "task:v1", "net": "none", "size": "small", "no_redirect": True, "require_promoted": True} + ] + + def test_template_claim_sends_volumes(node): seen = recording_claim( {"id": "sb_2", "token": "tok", "volumes": [{"name": "imagenet", "mount": "/datasets/imagenet"}]} @@ -144,6 +152,7 @@ def test_template_claim_sends_volumes(node): "net": "none", "size": "small", "volumes": [{"name": "imagenet", "mount": "/datasets/imagenet"}], + "require_promoted": True, } ] assert sb.volumes == [{"name": "imagenet", "mount": "/datasets/imagenet"}]