diff --git a/README.md b/README.md index 8a82c99..94d79fc 100644 --- a/README.md +++ b/README.md @@ -141,8 +141,9 @@ A delivered sandbox also takes four action verbs, served as subresources so the standard `agents.x-k8s.io` schema stays untouched: `pause`, `resume`, `fork`, `snapshot`. They are not uniformly fast — `resume` and `fork` are node-local, while `pause`/`snapshot` cost time proportional to guest memory. Verb table, -a runnable walk-through over both surfaces, and the two consistency behaviors -callers must handle are in [docs/lifecycle.md](docs/lifecycle.md). +a runnable walk-through over both surfaces, how a sandbox's lifetime is set +and reported, and the two consistency behaviors callers must handle are in +[docs/lifecycle.md](docs/lifecycle.md). ## Use it with the e2b SDK diff --git a/docs/lifecycle.md b/docs/lifecycle.md index 96bb153..978a02c 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -61,6 +61,22 @@ prints what it did, so the output doubles as acceptance evidence: connect 201 — restored via the mmap fast path ``` +## Lifetime + +Nothing is stored for a claimed sandbox, so the lease is fixed by the node at +claim time and the submitted object is the only place `Create` can hear it: + +- `spec.shutdownTime` wins, rounded up to whole seconds; the + `sandbox.cocoonstack.io/ttl-seconds` annotation covers clients that cannot + set the field; neither (or `0`) asks for the node's default lease. +- A `shutdownTime` already in the past or a malformed/negative annotation is + a `400` before any warm microVM is spent. +- The node clamps the ask to its own default and maximum, so the response + carries the **granted** expiry as the `sandbox.cocoonstack.io/deadline` + annotation (RFC3339) — the submitted spec is echoed untouched. `Get`/`List` + stamp the same annotation once the owning node publishes the deadline in + its `NodeInventory`. + ## Two behaviors callers must handle - **Reads are eventually consistent.** `Create` returns as soon as the diff --git a/docs/scaling-design.md b/docs/scaling-design.md index 607e98c..468b2e8 100644 --- a/docs/scaling-design.md +++ b/docs/scaling-design.md @@ -177,7 +177,7 @@ type NodeInventory struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` Node string `json:"node"` - Entries []InventoryEntry `json:"entries"` // {name, phase, claimRef, addr} + Entries []InventoryEntry `json:"entries"` // {name, id, phase, claimRef, addr, deadline} } ``` diff --git a/extensions/api/v1beta1/nodeinventory_types.go b/extensions/api/v1beta1/nodeinventory_types.go index d495876..d2f1beb 100644 --- a/extensions/api/v1beta1/nodeinventory_types.go +++ b/extensions/api/v1beta1/nodeinventory_types.go @@ -45,6 +45,9 @@ type InventoryEntry struct { // addr is the sandbox "host:port" address, if published. // +optional Address string `json:"addr,omitempty"` + // deadline is the node-granted lease expiry, if published. + // +optional + Deadline *metav1.Time `json:"deadline,omitempty"` } // +kubebuilder:object:root=true diff --git a/extensions/api/v1beta1/zz_generated.deepcopy.go b/extensions/api/v1beta1/zz_generated.deepcopy.go index c48643a..39f47f9 100644 --- a/extensions/api/v1beta1/zz_generated.deepcopy.go +++ b/extensions/api/v1beta1/zz_generated.deepcopy.go @@ -29,6 +29,10 @@ func (in *EnvVar) DeepCopy() *EnvVar { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *InventoryEntry) DeepCopyInto(out *InventoryEntry) { *out = *in + if in.Deadline != nil { + in, out := &in.Deadline, &out.Deadline + *out = (*in).DeepCopy() + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InventoryEntry. @@ -102,7 +106,9 @@ func (in *NodeInventory) DeepCopyInto(out *NodeInventory) { if in.Entries != nil { in, out := &in.Entries, &out.Entries *out = make([]InventoryEntry, len(*in)) - copy(*out, *in) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } if in.Pools != nil { in, out := &in.Pools, &out.Pools diff --git a/helm/crds/extensions.agents.x-k8s.io_nodeinventories.yaml b/helm/crds/extensions.agents.x-k8s.io_nodeinventories.yaml index a969668..772f6c5 100644 --- a/helm/crds/extensions.agents.x-k8s.io_nodeinventories.yaml +++ b/helm/crds/extensions.agents.x-k8s.io_nodeinventories.yaml @@ -36,6 +36,9 @@ spec: type: string claimRef: type: string + deadline: + format: date-time + type: string id: type: string name: diff --git a/k8s/crds/extensions.agents.x-k8s.io_nodeinventories.yaml b/k8s/crds/extensions.agents.x-k8s.io_nodeinventories.yaml index a969668..772f6c5 100644 --- a/k8s/crds/extensions.agents.x-k8s.io_nodeinventories.yaml +++ b/k8s/crds/extensions.agents.x-k8s.io_nodeinventories.yaml @@ -36,6 +36,9 @@ spec: type: string claimRef: type: string + deadline: + format: date-time + type: string id: type: string name: diff --git a/pkg/scale/apiserver/storage.go b/pkg/scale/apiserver/storage.go index 8b4baa9..ef8f5d9 100644 --- a/pkg/scale/apiserver/storage.go +++ b/pkg/scale/apiserver/storage.go @@ -3,6 +3,9 @@ package apiserver import ( "context" "fmt" + "math" + "strconv" + "time" apierrors "k8s.io/apimachinery/pkg/api/errors" apimeta "k8s.io/apimachinery/pkg/api/meta" @@ -28,6 +31,9 @@ const ( // defined in package scale (whose store stamps it on synthesized reads); this // alias keeps apiserver call sites writing and reading the identical key. ClaimIDAnnotation = scale.ClaimIDAnnotation + // DeadlineAnnotation carries the node-granted lease expiry (RFC3339); the + // same scale-defined key the synthesized read path stamps from inventory. + DeadlineAnnotation = scale.DeadlineAnnotation // AddressAnnotation carries the delivered sandbox connection address. AddressAnnotation = "sandbox.cocoonstack.io/address" // TokenAnnotation carries the per-sandbox ownership token so a caller can @@ -35,6 +41,9 @@ const ( TokenAnnotation = "sandbox.cocoonstack.io/token" // NetAnnotation selects the pool network mode on Create (default "none"). NetAnnotation = "sandbox.cocoonstack.io/net" + // TTLSecondsAnnotation bounds the claim lease in whole seconds on Create for + // clients that cannot set spec.shutdownTime (0 = the owning node's default). + TTLSecondsAnnotation = "sandbox.cocoonstack.io/ttl-seconds" ) // The verb set an aggregated, scatter-gather resource implements: the read triad @@ -122,7 +131,11 @@ func (r *sandboxREST) Create(ctx context.Context, obj runtime.Object, createVali } pool := poolKeyForSandbox(sb) - assignment, err := r.store.Claim(ctx, namespace, name, pool, 0) + ttlSeconds, err := ttlSecondsForSandbox(sb, time.Now()) + if err != nil { + return nil, apierrors.NewBadRequest(err.Error()) + } + assignment, err := r.store.Claim(ctx, namespace, name, pool, ttlSeconds) if err != nil { if scale.IsNoWarmCapacity(err) { // Retryable: warm capacity refills asynchronously (the node's sandboxd @@ -202,10 +215,32 @@ func poolKeyForSandbox(sb *sandboxv1beta1.Sandbox) scale.PoolKey { return scale.PoolKeyFor(sb.Spec.PodTemplate.Spec.Containers, sb.Annotations[NetAnnotation]) } +// ttlSecondsForSandbox derives the claim lease: spec.shutdownTime wins, the +// ttl-seconds annotation is the fallback, 0 asks for the node default. +func ttlSecondsForSandbox(sb *sandboxv1beta1.Sandbox, now time.Time) (int, error) { + if t := sb.Spec.ShutdownTime; t != nil { + left := t.Sub(now) + if left <= 0 { + return 0, fmt.Errorf("spec.shutdownTime %s is not in the future", t.Format(time.RFC3339)) + } + return int(math.Ceil(left.Seconds())), nil + } + raw := sb.Annotations[TTLSecondsAnnotation] + if raw == "" { + return 0, nil + } + v, err := strconv.Atoi(raw) + if err != nil || v < 0 { + return 0, fmt.Errorf("invalid %s=%q: want a non-negative integer of seconds", TTLSecondsAnnotation, raw) + } + return v, nil +} + // synthesizeClaimedSandbox builds the Sandbox object Create returns: the submitted // spec echoed back under the request name/namespace, a fresh UID/creationTimestamp, -// the claim id + address annotations, and a Ready status pointing at the owning -// node. It is never persisted — it is the response for a node-local claim. +// the claim id + address + granted-deadline annotations, and a Ready status +// pointing at the owning node. It is never persisted — it is the response for a +// node-local claim. func synthesizeClaimedSandbox(namespace, name string, in *sandboxv1beta1.Sandbox, a scale.Assignment) *sandboxv1beta1.Sandbox { out := in.DeepCopy() out.Namespace = namespace @@ -224,6 +259,9 @@ func synthesizeClaimedSandbox(namespace, name string, in *sandboxv1beta1.Sandbox if a.Token != "" { out.Annotations[TokenAnnotation] = a.Token } + if !a.Deadline.IsZero() { + out.Annotations[DeadlineAnnotation] = a.Deadline.UTC().Format(time.RFC3339) + } out.Status = sandboxv1beta1.SandboxStatus{ NodeName: a.Node, PodIPs: scale.AddressIPs(a.Address), diff --git a/pkg/scale/apiserver/storage_test.go b/pkg/scale/apiserver/storage_test.go index a5919f2..b333189 100644 --- a/pkg/scale/apiserver/storage_test.go +++ b/pkg/scale/apiserver/storage_test.go @@ -3,9 +3,11 @@ package apiserver import ( "context" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/watch" @@ -30,7 +32,7 @@ func TestDelete_ReleasesByClaimIDAnnotation(t *testing.T) { store := &fakeStore{getSandbox: sb} r := NewSandboxREST(store).(*sandboxREST) - obj, ok, err := r.Delete(deleteCtx(t, "ns"), "s1", nil, &metav1.DeleteOptions{}) + obj, ok, err := r.Delete(nsCtx(t, "ns"), "s1", nil, &metav1.DeleteOptions{}) require.NoError(t, err) assert.True(t, ok) assert.NotNil(t, obj) @@ -50,19 +52,92 @@ func TestDelete_FailsLoudWithoutClaimID(t *testing.T) { store := &fakeStore{getSandbox: sb} r := NewSandboxREST(store).(*sandboxREST) - _, ok, err := r.Delete(deleteCtx(t, "ns"), "s1", nil, &metav1.DeleteOptions{}) + _, ok, err := r.Delete(nsCtx(t, "ns"), "s1", nil, &metav1.DeleteOptions{}) require.Error(t, err) assert.False(t, ok) assert.True(t, apierrors.IsInternalError(err), "expected an internal error, got %v", err) assert.False(t, store.released, "must not release when the sandboxd claim id is unknown") } -// fakeStore is a scale.SandboxStore stub for the Delete path: Get returns a -// preset sandbox, Release records its arguments. The read-path verbs are unused. +func TestTTLSecondsForSandbox(t *testing.T) { + now := time.Date(2026, 8, 17, 10, 0, 0, 0, time.UTC) + for name, tc := range map[string]struct { + shutdown time.Time + ttl string + want int + wantErr bool + }{ + "shutdownTime": {shutdown: now.Add(90 * time.Second), want: 90}, + "sub-second rounds up": {shutdown: now.Add(90*time.Second + 500*time.Millisecond), want: 91}, + "annotation": {ttl: "120", want: 120}, + "spec wins over annotation": {shutdown: now.Add(time.Hour), ttl: "10", want: 3600}, + "explicit zero asks the node default": {ttl: "0"}, + "no lifetime asks the node default": {}, + "expired shutdownTime": {shutdown: now, wantErr: true}, + "malformed annotation": {ttl: "banana", wantErr: true}, + "negative annotation": {ttl: "-5", wantErr: true}, + } { + t.Run(name, func(t *testing.T) { + sb := submittedSandbox("s", nil) + if tc.ttl != "" { + sb.Annotations = map[string]string{TTLSecondsAnnotation: tc.ttl} + } + if !tc.shutdown.IsZero() { + sb.Spec.ShutdownTime = &metav1.Time{Time: tc.shutdown} + } + got, err := ttlSecondsForSandbox(sb, now) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestCreate_TTLRidesTheClaim(t *testing.T) { + f := &fakeStore{claimAssign: scale.Assignment{SandboxName: "sb_1", Node: "n1"}} + r := NewSandboxREST(f).(*sandboxREST) + + _, err := r.Create(nsCtx(t, "ns"), submittedSandbox("s1", map[string]string{TTLSecondsAnnotation: "120"}), nil, nil) + require.NoError(t, err) + assert.Equal(t, 120, f.claimTTL, "the derived lease must reach the store") +} + +func TestCreate_RejectsUnusableLifetime(t *testing.T) { + f := &fakeStore{} + r := NewSandboxREST(f).(*sandboxREST) + + _, err := r.Create(nsCtx(t, "ns"), submittedSandbox("s1", map[string]string{TTLSecondsAnnotation: "banana"}), nil, nil) + require.Error(t, err) + assert.True(t, apierrors.IsBadRequest(err), "expected BadRequest, got %v", err) + assert.Equal(t, 0, f.claimCalls, "no claim may be spent on a rejected request") +} + +func TestCreate_ReportsGrantedDeadline(t *testing.T) { + deadline := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC) + f := &fakeStore{claimAssign: scale.Assignment{SandboxName: "sb_1", Node: "n1", Deadline: deadline}} + r := NewSandboxREST(f).(*sandboxREST) + + obj, err := r.Create(nsCtx(t, "ns"), submittedSandbox("s1", map[string]string{TTLSecondsAnnotation: "999999"}), nil, nil) + require.NoError(t, err) + out, ok := obj.(*sandboxv1beta1.Sandbox) + require.True(t, ok) + assert.Equal(t, "2026-08-17T12:00:00Z", out.Annotations[DeadlineAnnotation], "the node-granted deadline, not the caller's ask") + assert.Nil(t, out.Spec.ShutdownTime, "the submitted spec is echoed, not rewritten") +} + +// fakeStore is a scale.SandboxStore stub: Get returns a preset sandbox, Claim +// and Release record their arguments. The read-path verbs are unused. type fakeStore struct { getSandbox *sandboxv1beta1.Sandbox getErr error + claimCalls int + claimTTL int + claimAssign scale.Assignment + released bool releaseNode string releaseID string @@ -81,8 +156,10 @@ func (f *fakeStore) Watch(context.Context, scale.ListOptions) (watch.Interface, return watch.NewFake(), nil } -func (f *fakeStore) Claim(context.Context, string, string, scale.PoolKey, int) (scale.Assignment, error) { - return scale.Assignment{}, nil +func (f *fakeStore) Claim(_ context.Context, _, _ string, _ scale.PoolKey, ttlSeconds int) (scale.Assignment, error) { + f.claimCalls++ + f.claimTTL = ttlSeconds + return f.claimAssign, nil } func (f *fakeStore) Release(_ context.Context, node, id string) error { @@ -120,6 +197,12 @@ func (f *fakeStore) Stats(context.Context, string, string) (scale.SandboxStats, return scale.SandboxStats{}, nil } -func deleteCtx(t *testing.T, ns string) context.Context { +func nsCtx(t *testing.T, ns string) context.Context { return genericapirequest.WithNamespace(t.Context(), ns) } + +func submittedSandbox(name string, anns map[string]string) *sandboxv1beta1.Sandbox { + sb := &sandboxv1beta1.Sandbox{ObjectMeta: metav1.ObjectMeta{Name: name, Annotations: anns}} + sb.Spec.PodTemplate.Spec.Containers = []corev1.Container{{Name: "c", Image: "img"}} + return sb +} diff --git a/pkg/scale/claimgateway.go b/pkg/scale/claimgateway.go index 4220ccc..28f01a1 100644 --- a/pkg/scale/claimgateway.go +++ b/pkg/scale/claimgateway.go @@ -1,6 +1,9 @@ package scale -import "context" +import ( + "context" + "time" +) // ClaimRequest identifies a node-local warm-pool claim. type ClaimRequest struct { @@ -23,6 +26,9 @@ type Assignment struct { // claim. It authenticates agent/exec against the delivered VM; the L3 Create // path surfaces it as an annotation so a caller can exec into what it claimed. Token string + // Deadline is the node-granted lease expiry — authoritative over the requested + // TTL (node default when unasked, clamped to the node maximum); zero if unreported. + Deadline time.Time } // ClaimGateway is the L2 node-local fast path for warm-pool claims. A claim is diff --git a/pkg/scale/claimgateway_impl.go b/pkg/scale/claimgateway_impl.go index c723bea..bb03adc 100644 --- a/pkg/scale/claimgateway_impl.go +++ b/pkg/scale/claimgateway_impl.go @@ -187,7 +187,7 @@ func (g *nodeClaimGateway) Claim(ctx context.Context, req ClaimRequest) (Assignm return Assignment{}, fmt.Errorf("scale: sandboxd claim for %s/%s: %w", req.Namespace, req.ClaimName, err) } - a := Assignment{SandboxName: res.ID, Node: g.node, Address: res.OwnerAddr, Token: res.Token} + a := Assignment{SandboxName: res.ID, Node: g.node, Address: res.OwnerAddr, Token: res.Token, Deadline: res.Deadline} g.mu.Lock() g.holdings[a.SandboxName] = delivered{id: res.ID, token: res.Token} g.mu.Unlock() diff --git a/pkg/scale/claimgateway_impl_test.go b/pkg/scale/claimgateway_impl_test.go index 823735d..67b1ccf 100644 --- a/pkg/scale/claimgateway_impl_test.go +++ b/pkg/scale/claimgateway_impl_test.go @@ -9,6 +9,7 @@ import ( "strings" "sync/atomic" "testing" + "time" "github.com/go-logr/logr/testr" "github.com/stretchr/testify/require" @@ -227,7 +228,9 @@ func newFakeSandboxd(t *testing.T) *fakeSandboxd { id := fmt.Sprintf("sb_%d", f.nextID.Add(1)) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(sandboxd.ClaimResult{ - ID: id, Token: "tok_" + id, Deadline: "2026-07-06T00:05:00Z", OwnerAddr: "10.0.0.5:7777", + ID: id, Token: "tok_" + id, + Deadline: time.Date(2026, 7, 6, 0, 5, 0, 0, time.UTC), + OwnerAddr: "10.0.0.5:7777", }) }) mux.HandleFunc("/v1/sandboxes/", func(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/scale/sandboxd/client.go b/pkg/scale/sandboxd/client.go index 182d76c..3a06e22 100644 --- a/pkg/scale/sandboxd/client.go +++ b/pkg/scale/sandboxd/client.go @@ -15,6 +15,7 @@ import ( "net/http" "net/url" "strings" + "time" ) // ErrNodeAtCapacity is returned by Claim when sandboxd answers 429 (the node is @@ -69,10 +70,10 @@ type ClaimSpec struct { // ClaimResult is the POST /v1/claim success body. type ClaimResult struct { - ID string `json:"id"` - Token string `json:"token"` - Deadline string `json:"deadline"` - OwnerAddr string `json:"owner_addr"` + ID string `json:"id"` + Token string `json:"token"` + Deadline time.Time `json:"deadline"` + OwnerAddr string `json:"owner_addr"` // FromCheckpoint is the lineage edge when the claim branched from a checkpoint. FromCheckpoint string `json:"from_checkpoint,omitempty"` // Redirect, when non-empty on a 200, names warm peers to retry at instead of a diff --git a/pkg/scale/sandboxd/client_test.go b/pkg/scale/sandboxd/client_test.go index 9f562a3..6b6698f 100644 --- a/pkg/scale/sandboxd/client_test.go +++ b/pkg/scale/sandboxd/client_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "sync/atomic" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -22,7 +23,9 @@ func TestClaimSuccess(t *testing.T) { assert.Equal(t, "base:24.04", spec.Template) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(ClaimResult{ - ID: "sb_abc", Token: "sbtok", Deadline: "2026-07-06T00:05:00Z", OwnerAddr: "10.0.0.5:7777", + ID: "sb_abc", Token: "sbtok", + Deadline: time.Date(2026, 7, 6, 0, 5, 0, 0, time.UTC), + OwnerAddr: "10.0.0.5:7777", }) })) defer srv.Close() diff --git a/pkg/scale/sandboxstore_claim_test.go b/pkg/scale/sandboxstore_claim_test.go index 8f4d139..5bc6a3b 100644 --- a/pkg/scale/sandboxstore_claim_test.go +++ b/pkg/scale/sandboxstore_claim_test.go @@ -3,6 +3,7 @@ package scale import ( "context" "testing" + "time" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" @@ -17,7 +18,8 @@ var _ SandboxdClient = (*recordingClient)(nil) func TestStoreClaim_RoutesToAWarmNode(t *testing.T) { src := NewStaticInventorySource() src.Put(poolInv("n2", "10.0.0.2:7777", PoolCapacity{Template: "img", Warm: 4, Target: 5})) - f := &recordingFactory{claimResult: sandboxd.ClaimResult{ID: "sb-abc", Token: "sbtok", OwnerAddr: "10.0.0.2:9000"}} + deadline := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC) + f := &recordingFactory{claimResult: sandboxd.ClaimResult{ID: "sb-abc", Token: "sbtok", OwnerAddr: "10.0.0.2:9000", Deadline: deadline}} store := NewScatterGatherStore(src, WithLogger(logr.Discard()), WithClaimRouting("uniform-token", f.factory())) a, err := store.Claim(t.Context(), "ns", "s1", PoolKey{Template: "img"}, 600) @@ -30,6 +32,7 @@ func TestStoreClaim_RoutesToAWarmNode(t *testing.T) { assert.Equal(t, "uniform-token", f.builtToken) assert.Equal(t, "img", f.claimSpec.Template) assert.Equal(t, 600, f.claimSpec.TTLSeconds, "the caller's TTL must reach sandboxd") + assert.Equal(t, deadline, a.Deadline, "the node-granted deadline must ride the assignment back") // The claim carries the k8s "/" so the node echoes it into // its operator index and the aggregated read path can resolve this sandbox. assert.Equal(t, "ns/s1", f.claimSpec.ClaimRef) diff --git a/pkg/scale/sandboxstore_impl.go b/pkg/scale/sandboxstore_impl.go index f485b07..1966399 100644 --- a/pkg/scale/sandboxstore_impl.go +++ b/pkg/scale/sandboxstore_impl.go @@ -52,6 +52,10 @@ const ( // by k8s name would target the wrong claim). This is the single definition of // the key; apiserver.ClaimIDAnnotation aliases it so both write it identically. ClaimIDAnnotation = "sandbox.cocoonstack.io/claim-id" + // DeadlineAnnotation carries the node-granted lease expiry (RFC3339) of a + // Sandbox: stamped from inventory on reads and from the claim on Create. + // apiserver.DeadlineAnnotation aliases it. + DeadlineAnnotation = "sandbox.cocoonstack.io/deadline" // Connection pooling for the node-local claim path. Idle conns per host are // sized to the per-node claim fan-out so a burst reuses connections instead @@ -301,7 +305,7 @@ func (s *scatterGatherStore) Claim(ctx context.Context, namespace, name string, ClaimRef: namespace + "/" + name, }) if claimErr == nil { - return Assignment{SandboxName: res.ID, Node: best.node, Address: res.OwnerAddr, Token: res.Token}, nil + return Assignment{SandboxName: res.ID, Node: best.node, Address: res.OwnerAddr, Token: res.Token, Deadline: res.Deadline}, nil } if !errors.Is(claimErr, sandboxd.ErrNodeAtCapacity) { return Assignment{}, fmt.Errorf("scale: claim %s/%s on node %q: %w", namespace, name, best.node, claimErr) @@ -880,15 +884,28 @@ func synthLabels(node string, e InventoryEntry) map[string]string { return l } -// synthAnnotations carries the opaque node-local handles a synthesized Sandbox -// needs but that are not selector axes — the sandboxd claim id, which Delete -// uses to release the right microVM. Nil when the node has not published an id -// yet, so Delete refuses to release rather than guessing by name. +// synthAnnotations carries the node-reported facts that are not selector axes: +// the sandboxd claim id Delete releases by, and the granted deadline. Neither is +// stamped until the node publishes it, so Delete never guesses a claim by name. func synthAnnotations(e InventoryEntry) map[string]string { - if e.ID == "" { + a := map[string]string{} + if e.ID != "" { + a[ClaimIDAnnotation] = e.ID + } + if d := deadlineValue(e); d != "" { + a[DeadlineAnnotation] = d + } + if len(a) == 0 { return nil } - return map[string]string{ClaimIDAnnotation: e.ID} + return a +} + +func deadlineValue(e InventoryEntry) string { + if e.Deadline == nil || e.Deadline.IsZero() { + return "" + } + return e.Deadline.UTC().Format(time.RFC3339) } func sandboxFields(sb *sandboxv1beta1.Sandbox) fields.Set { @@ -918,7 +935,7 @@ func readyReason(phase string) string { // unchanged one. It is opaque, as the API contract requires. func resourceVersionFor(ns, name string, e InventoryEntry) string { h := fnv.New64a() - _, _ = h.Write([]byte(ns + "/" + name + "|" + e.ID + "|" + e.Phase + "|" + e.ClaimRef + "|" + e.Address)) + _, _ = h.Write([]byte(ns + "/" + name + "|" + e.ID + "|" + e.Phase + "|" + e.ClaimRef + "|" + e.Address + "|" + deadlineValue(e))) return strconv.FormatUint(h.Sum64(), 10) } diff --git a/pkg/scale/sandboxstore_impl_test.go b/pkg/scale/sandboxstore_impl_test.go index 3dff288..8462f0a 100644 --- a/pkg/scale/sandboxstore_impl_test.go +++ b/pkg/scale/sandboxstore_impl_test.go @@ -152,6 +152,17 @@ func TestEntryToSandbox_StampsClaimIDAnnotation(t *testing.T) { assert.False(t, ok, "expected no claim-id annotation when the entry has no id") } +func TestEntryToSandbox_StampsDeadlineAnnotation(t *testing.T) { + deadline := metav1.NewTime(time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)) + with := entryToSandbox("n1", InventoryEntry{Name: "ns/s1", ID: "sb_abc", Phase: "Running", Deadline: &deadline}) + assert.Equal(t, "2026-08-17T12:00:00Z", with.Annotations[DeadlineAnnotation]) + assert.NotEqual(t, entryToSandbox("n1", InventoryEntry{Name: "ns/s1", ID: "sb_abc", Phase: "Running"}).ResourceVersion, + with.ResourceVersion, "a refreshed deadline must surface as a Modified entry") + + _, ok := entryToSandbox("n1", InventoryEntry{Name: "ns/s1", Phase: "Running"}).Annotations[DeadlineAnnotation] + assert.False(t, ok, "expected no deadline annotation when the node published none") +} + func TestScatterGatherWatch_EmitsAddModifyDelete(t *testing.T) { src := NewStaticInventorySource() src.Put(inv("n1", entry("ns/s1", "Pending"))) diff --git a/pkg/scale/sandboxstore_lifecycle.go b/pkg/scale/sandboxstore_lifecycle.go index 2a0f769..3df32b2 100644 --- a/pkg/scale/sandboxstore_lifecycle.go +++ b/pkg/scale/sandboxstore_lifecycle.go @@ -52,6 +52,7 @@ func (s *scatterGatherStore) Fork(ctx context.Context, node, id string, count, t Node: node, Address: c.OwnerAddr, Token: c.Token, + Deadline: c.Deadline, }) } return out, nil @@ -114,6 +115,7 @@ func (s *scatterGatherStore) ClaimSnapshot(ctx context.Context, node, snapshotID Node: node, Address: res.OwnerAddr, Token: res.Token, + Deadline: res.Deadline, }, nil } diff --git a/test/l2bench/main.go b/test/l2bench/main.go index 1b42818..6c16db2 100644 --- a/test/l2bench/main.go +++ b/test/l2bench/main.go @@ -74,7 +74,9 @@ func newFakeSandboxd() *fakeSandboxd { id := fmt.Sprintf("sb_%d", f.nextID.Add(1)) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(sandboxd.ClaimResult{ - ID: id, Token: "tok_" + id, Deadline: "2026-07-06T00:05:00Z", OwnerAddr: "10.0.0.5:7777", + ID: id, Token: "tok_" + id, + Deadline: time.Date(2026, 7, 6, 0, 5, 0, 0, time.UTC), + OwnerAddr: "10.0.0.5:7777", }) }) mux.HandleFunc("/v1/sandboxes/", func(w http.ResponseWriter, r *http.Request) {