diff --git a/docs/api/index.md b/docs/api/index.md index 919d52309..dc2eb5ef2 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -40,6 +40,11 @@ Defines the complete lifecycle interfaces for creating, managing, and destroying - `PATCH /sandboxes/{sandboxId}/metadata` - Patch sandbox metadata (JSON Merge Patch, RFC 7396) - `GET /sandboxes/{sandboxId}/endpoints/{port}` - Get an access endpoint for a service port +**Optional `Sandbox.allocation` response field:** +- Returned only when the runtime confirms the sandbox's current concrete Pool allocation. +- Omitted for unconfirmed allocations, non-Pool sandboxes, and allocations being released. +- This field is not a request echo, allocation history, or readiness signal, and does not expose Pod names or other Kubernetes-internal fields. + **Authentication:** - HTTP Header: `OPEN-SANDBOX-API-KEY: your-api-key` - Environment Variable: `OPEN_SANDBOX_API_KEY` (for SDK clients) diff --git a/kubernetes/AGENTS.md b/kubernetes/AGENTS.md index 2e73d2f2f..6e9db34cf 100644 --- a/kubernetes/AGENTS.md +++ b/kubernetes/AGENTS.md @@ -46,7 +46,7 @@ For E2E test failure diagnosis, see [docs/E2E-TROUBLESHOOTING.md](./docs/E2E-TRO The controller communicates allocation state through annotations on BatchSandbox objects. These are treated as internal but stability-sensitive: -- `sandbox.opensandbox.io/alloc-status`: JSON `{"pods":["pod-1","pod-2"]}` — current pod allocation +- `sandbox.opensandbox.io/alloc-status`: current pool allocation. Legacy pods-only JSON such as `{"pods":["pod-1","pod-2"]}` remains accepted and readable. Current controller writes add `poolRef` and `generation`: `{"pods":["pod-1","pod-2"],"poolRef":"pool-a","generation":42}`. `generation` traces the BatchSandbox generation for the write; it is not an evidence-freshness predicate. - `sandbox.opensandbox.io/alloc-release`: JSON `{"pods":["pod-3"]}` — pods released back to pool - `sandbox.opensandbox.io/endpoints`: JSON endpoint list consumed by server-side endpoint resolution diff --git a/kubernetes/DEVELOPMENT.md b/kubernetes/DEVELOPMENT.md index 34479ecdc..07510beef 100644 --- a/kubernetes/DEVELOPMENT.md +++ b/kubernetes/DEVELOPMENT.md @@ -143,7 +143,7 @@ PoolReconciler.Reconcile ``` Allocation state is stored in memory (`InMemoryAllocationStore`) and persisted to BatchSandbox annotations: -- `sandbox.opensandbox.io/alloc-status`: `{"pods":["pod-1","pod-2"]}` +- `sandbox.opensandbox.io/alloc-status`: current pool allocation. Legacy `{"pods":["pod-1","pod-2"]}` remains accepted and readable. Current controller writes include additive `poolRef` and `generation` fields, for example `{"pods":["pod-1","pod-2"],"poolRef":"pool-a","generation":42}`. `generation` records the BatchSandbox generation associated with the write; it is not an evidence-freshness predicate. - `sandbox.opensandbox.io/alloc-release`: `{"pods":["pod-3"]}` On startup, `InMemoryAllocationStore.Recover` rebuilds the in-memory state from all BatchSandbox annotations. @@ -408,10 +408,10 @@ The controller communicates allocation state through annotations on BatchSandbox | Annotation Key | JSON Shape | Writer | Reader | |---|---|---|---| -| `sandbox.opensandbox.io/alloc-status` | `{"pods":["pod-1"]}` | `allocator.go` via `apis.go` | `batchsandbox_controller.go` | +| `sandbox.opensandbox.io/alloc-status` | Legacy: `{"pods":["pod-1"]}`; current writer: `{"pods":["pod-1"],"poolRef":"pool-a","generation":42}` | `allocator.go` via `apis.go` | `batchsandbox_controller.go` | | `sandbox.opensandbox.io/alloc-release` | `{"pods":["pod-3"]}` | `batchsandbox_controller.go` | `allocator.go` | -When changing annotation shapes, update all readers and writers, and add migration logic if the change is not backward-compatible. +The `poolRef` and `generation` fields in `alloc-status` are additive. Continue to accept and read the legacy pods-only shape. `generation` traces the BatchSandbox generation for the annotation write; do not use it as an evidence-freshness predicate. When changing annotation shapes, update all readers and writers, and add migration logic if the change is not backward-compatible. ## Build and Deploy diff --git a/kubernetes/internal/controller/allocator.go b/kubernetes/internal/controller/allocator.go index 008b14669..0933c0167 100644 --- a/kubernetes/internal/controller/allocator.go +++ b/kubernetes/internal/controller/allocator.go @@ -269,6 +269,8 @@ func NewAnnoAllocationSyncer(client client.Client) AllocationSyncer { } func (syncer *annoAllocationSyncer) SetAllocation(ctx context.Context, sandbox *sandboxv1alpha1.BatchSandbox, allocation *SandboxAllocation) error { + allocation.PoolRef = sandbox.Spec.PoolRef + allocation.Generation = sandbox.Generation js, err := json.Marshal(allocation) if err != nil { return err diff --git a/kubernetes/internal/controller/allocator_test.go b/kubernetes/internal/controller/allocator_test.go index b102ea837..210e6e28d 100644 --- a/kubernetes/internal/controller/allocator_test.go +++ b/kubernetes/internal/controller/allocator_test.go @@ -350,7 +350,7 @@ func newTestSyncer(sandbox *sandboxv1alpha1.BatchSandbox) (*annoAllocationSyncer func TestSetAllocation_AddsFinalizer(t *testing.T) { sandbox := &sandboxv1alpha1.BatchSandbox{ - ObjectMeta: metav1.ObjectMeta{Name: "sbx1", Namespace: "default"}, + ObjectMeta: metav1.ObjectMeta{Name: "sbx1", Namespace: "default", Generation: 7}, Spec: sandboxv1alpha1.BatchSandboxSpec{PoolRef: "pool1"}, } syncer, sbx := newTestSyncer(sandbox) @@ -358,6 +358,12 @@ func TestSetAllocation_AddsFinalizer(t *testing.T) { err := syncer.SetAllocation(context.Background(), sbx, &SandboxAllocation{Pods: []string{"pod1"}}) assert.NoError(t, err) assert.Contains(t, sbx.Finalizers, FinalizerPoolAllocation) + + allocation, err := syncer.GetAllocation(context.Background(), sbx) + assert.NoError(t, err) + assert.Equal(t, []string{"pod1"}, allocation.Pods) + assert.Equal(t, "pool1", allocation.PoolRef) + assert.Equal(t, int64(7), allocation.Generation) } func TestSetReleased_FinalizerBehavior(t *testing.T) { diff --git a/kubernetes/internal/controller/apis.go b/kubernetes/internal/controller/apis.go index 0d5fb4d70..1ec9a4a10 100644 --- a/kubernetes/internal/controller/apis.go +++ b/kubernetes/internal/controller/apis.go @@ -39,7 +39,9 @@ const ( var AnnotationSandboxEndpoints = pkgutils.AnnotationEndpoints type SandboxAllocation struct { - Pods []string `json:"pods"` + Pods []string `json:"pods"` + PoolRef string `json:"poolRef"` + Generation int64 `json:"generation"` } type AllocationRelease struct { diff --git a/kubernetes/internal/controller/pool_allocation_backfill_test.go b/kubernetes/internal/controller/pool_allocation_backfill_test.go new file mode 100644 index 000000000..9c5154fd7 --- /dev/null +++ b/kubernetes/internal/controller/pool_allocation_backfill_test.go @@ -0,0 +1,367 @@ +// Copyright 2025 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controller + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + sandboxv1alpha1 "github.com/alibaba/OpenSandbox/sandbox-k8s/apis/sandbox/v1alpha1" + "github.com/alibaba/OpenSandbox/sandbox-k8s/internal/controller/algorithm" +) + +func TestBackfillLegacyPoolAllocation(t *testing.T) { + ctx := context.Background() + pool := &sandboxv1alpha1.Pool{ObjectMeta: metav1.ObjectMeta{Name: "pool-a", Namespace: "default"}} + pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{ + Name: "pool-pod", + Namespace: "default", + Labels: map[string]string{LabelPoolName: pool.Name}, + }} + + t.Run("success stamps exact record and is idempotent", func(t *testing.T) { + sandbox := newLegacyAllocationSandbox("sandbox", pool.Name) + r := newBackfillTestReconciler(t, sandbox) + + if err := r.backfillLegacyPoolAllocation(ctx, pool, sandbox, []*corev1.Pod{pod}, map[string]string{"pool-pod": sandbox.Name}); err != nil { + t.Fatalf("backfillLegacyPoolAllocation() error = %v", err) + } + updated := getBackfillSandbox(t, ctx, r, sandbox) + allocation := parseBackfillAllocation(t, updated) + want := SandboxAllocation{Pods: []string{"pool-pod"}, PoolRef: pool.Name, Generation: sandbox.Generation} + if !reflect.DeepEqual(allocation, want) { + t.Fatalf("allocation = %#v, want %#v", allocation, want) + } + firstAnnotations := updated.GetAnnotations() + firstResourceVersion := updated.ResourceVersion + + if err := r.backfillLegacyPoolAllocation(ctx, pool, updated, []*corev1.Pod{pod}, map[string]string{"pool-pod": sandbox.Name}); err != nil { + t.Fatalf("second backfillLegacyPoolAllocation() error = %v", err) + } + again := getBackfillSandbox(t, ctx, r, sandbox) + if !reflect.DeepEqual(again.GetAnnotations(), firstAnnotations) { + t.Fatalf("second backfill changed annotations: got %#v, want %#v", again.GetAnnotations(), firstAnnotations) + } + if again.ResourceVersion != firstResourceVersion { + t.Fatalf("second backfill changed resource version: got %q, want %q", again.ResourceVersion, firstResourceVersion) + } + }) + + tests := []struct { + name string + mutate func(*sandboxv1alpha1.BatchSandbox) + pods []*corev1.Pod + latestAllocation map[string]string + }{ + { + name: "missing pool pod", + pods: nil, + }, + { + name: "release intersects allocation", + mutate: func(sandbox *sandboxv1alpha1.BatchSandbox) { + sandbox.Annotations[AnnoAllocReleaseKey] = `{"pods":["pool-pod"]}` + }, + pods: []*corev1.Pod{pod}, + }, + { + name: "malformed release", + mutate: func(sandbox *sandboxv1alpha1.BatchSandbox) { + sandbox.Annotations[AnnoAllocReleaseKey] = `{` + }, + pods: []*corev1.Pod{pod}, + }, + { + name: "release missing pods", + mutate: func(sandbox *sandboxv1alpha1.BatchSandbox) { + sandbox.Annotations[AnnoAllocReleaseKey] = `{}` + }, + pods: []*corev1.Pod{pod}, + }, + { + name: "release null pods", + mutate: func(sandbox *sandboxv1alpha1.BatchSandbox) { + sandbox.Annotations[AnnoAllocReleaseKey] = `{"pods":null}` + }, + pods: []*corev1.Pod{pod}, + }, + { + name: "release duplicate pod", + mutate: func(sandbox *sandboxv1alpha1.BatchSandbox) { + sandbox.Annotations[AnnoAllocReleaseKey] = `{"pods":["released-pod","released-pod"]}` + }, + pods: []*corev1.Pod{pod}, + }, + { + name: "malformed released", + mutate: func(sandbox *sandboxv1alpha1.BatchSandbox) { + sandbox.Annotations[AnnoAllocReleasedKey] = `{` + }, + pods: []*corev1.Pod{pod}, + }, + { + name: "released missing pods", + mutate: func(sandbox *sandboxv1alpha1.BatchSandbox) { + sandbox.Annotations[AnnoAllocReleasedKey] = `{}` + }, + pods: []*corev1.Pod{pod}, + }, + { + name: "released invalid pod", + mutate: func(sandbox *sandboxv1alpha1.BatchSandbox) { + sandbox.Annotations[AnnoAllocReleasedKey] = `{"pods":["INVALID_POD"]}` + }, + pods: []*corev1.Pod{pod}, + }, + { + name: "deleting sandbox", + mutate: func(sandbox *sandboxv1alpha1.BatchSandbox) { + now := metav1.NewTime(time.Now()) + sandbox.DeletionTimestamp = &now + sandbox.Finalizers = append(sandbox.Finalizers, "keep-deleting-object") + }, + pods: []*corev1.Pod{pod}, + }, + { + name: "missing allocation finalizer", + mutate: func(sandbox *sandboxv1alpha1.BatchSandbox) { + sandbox.Finalizers = nil + }, + pods: []*corev1.Pod{pod}, + }, + { + name: "nonempty mismatched pool ref", + mutate: func(sandbox *sandboxv1alpha1.BatchSandbox) { + sandbox.Annotations[AnnoAllocStatusKey] = `{"pods":["pool-pod"],"poolRef":"other-pool","generation":1}` + }, + pods: []*corev1.Pod{pod}, + }, + { + name: "explicit empty pool ref is not legacy", + mutate: func(sandbox *sandboxv1alpha1.BatchSandbox) { + sandbox.Annotations[AnnoAllocStatusKey] = `{"pods":["pool-pod"],"poolRef":""}` + }, + pods: []*corev1.Pod{pod}, + }, + { + name: "explicit generation is not legacy", + mutate: func(sandbox *sandboxv1alpha1.BatchSandbox) { + sandbox.Annotations[AnnoAllocStatusKey] = `{"pods":["pool-pod"],"generation":0}` + }, + pods: []*corev1.Pod{pod}, + }, + { + name: "extra allocation field is not legacy", + mutate: func(sandbox *sandboxv1alpha1.BatchSandbox) { + sandbox.Annotations[AnnoAllocStatusKey] = `{"pods":["pool-pod"],"unexpected":"value"}` + }, + pods: []*corev1.Pod{pod}, + }, + { + name: "idle pool pod is not backfilled", + pods: []*corev1.Pod{pod}, + }, + { + name: "pod owned by another sandbox is not backfilled", + pods: []*corev1.Pod{pod}, + latestAllocation: map[string]string{ + "pool-pod": "other-sandbox", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sandbox := newLegacyAllocationSandbox("sandbox", pool.Name) + if tt.mutate != nil { + tt.mutate(sandbox) + } + original := sandbox.Annotations[AnnoAllocStatusKey] + r := newBackfillTestReconciler(t, sandbox) + + latestAllocation := tt.latestAllocation + if latestAllocation == nil { + latestAllocation = map[string]string{} + } + if err := r.backfillLegacyPoolAllocation(ctx, pool, sandbox, tt.pods, latestAllocation); err != nil { + t.Fatalf("backfillLegacyPoolAllocation() error = %v", err) + } + updated := getBackfillSandbox(t, ctx, r, sandbox) + if got := updated.Annotations[AnnoAllocStatusKey]; got != original { + t.Fatalf("alloc-status = %q, want unchanged %q", got, original) + } + }) + } +} + +func TestReconcilePoolRequeuesAfterBackfillPatchFailure(t *testing.T) { + ctx := context.Background() + pool := &sandboxv1alpha1.Pool{ + ObjectMeta: metav1.ObjectMeta{Name: "pool-a", Namespace: "default", Generation: 1}, + Spec: sandboxv1alpha1.PoolSpec{ + CapacitySpec: sandboxv1alpha1.CapacitySpec{PoolMax: 2}, + }, + } + sandbox := newLegacyAllocationSandbox("sandbox", pool.Name) + allocatedPod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{ + Name: "pool-pod", + Namespace: "default", + Labels: map[string]string{LabelPoolName: pool.Name}, + }} + idlePod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{ + Name: "idle-pod", + Namespace: "default", + Labels: map[string]string{LabelPoolName: pool.Name}, + }} + + r := newBackfillTestReconciler(t, pool, sandbox, allocatedPod, idlePod) + failingClient := &backfillPatchFailingClient{ + Client: r.Client, + patchErr: errors.New("backfill patch failed"), + } + r.Client = failingClient + r.Allocator = &backfillReconcileAllocator{ + latestAllocation: map[string]string{allocatedPod.Name: sandbox.Name}, + } + + result, err := r.reconcilePool(ctx, pool, []*sandboxv1alpha1.BatchSandbox{sandbox}, []*corev1.Pod{allocatedPod, idlePod}) + if err != nil { + t.Fatalf("reconcilePool() error = %v, want nil", err) + } + if result.RequeueAfter != defaultRetryTime { + t.Fatalf("RequeueAfter = %v, want %v", result.RequeueAfter, defaultRetryTime) + } + if failingClient.patchCalls != 1 { + t.Fatalf("backfill patch calls = %d, want 1", failingClient.patchCalls) + } + + if err := r.Get(ctx, types.NamespacedName{Name: idlePod.Name, Namespace: idlePod.Namespace}, &corev1.Pod{}); err == nil { + t.Fatal("idle pod still exists; pool scaling did not run after backfill failure") + } + updatedPool := &sandboxv1alpha1.Pool{} + if err := r.Get(ctx, client.ObjectKeyFromObject(pool), updatedPool); err != nil { + t.Fatalf("get updated pool: %v", err) + } + if updatedPool.Status.Allocated != 1 { + t.Fatalf("pool status allocated = %d, want 1", updatedPool.Status.Allocated) + } +} + +func newLegacyAllocationSandbox(name, poolRef string) *sandboxv1alpha1.BatchSandbox { + return &sandboxv1alpha1.BatchSandbox{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + Generation: 7, + Finalizers: []string{FinalizerPoolAllocation}, + Annotations: map[string]string{AnnoAllocStatusKey: `{"pods":["pool-pod"]}`}, + }, + Spec: sandboxv1alpha1.BatchSandboxSpec{PoolRef: poolRef}, + } +} + +func newBackfillTestReconciler(t *testing.T, objects ...runtime.Object) *PoolReconciler { + t.Helper() + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := sandboxv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + return &PoolReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&sandboxv1alpha1.Pool{}).WithRuntimeObjects(objects...).Build(), + Scheme: scheme, + Recorder: record.NewFakeRecorder(10), + } +} + +func getBackfillSandbox(t *testing.T, ctx context.Context, r *PoolReconciler, sandbox *sandboxv1alpha1.BatchSandbox) *sandboxv1alpha1.BatchSandbox { + t.Helper() + updated := &sandboxv1alpha1.BatchSandbox{} + if err := r.Get(ctx, types.NamespacedName{Name: sandbox.Name, Namespace: sandbox.Namespace}, updated); err != nil { + t.Fatal(err) + } + return updated +} + +func parseBackfillAllocation(t *testing.T, sandbox *sandboxv1alpha1.BatchSandbox) SandboxAllocation { + t.Helper() + allocation := SandboxAllocation{} + if err := json.Unmarshal([]byte(sandbox.Annotations[AnnoAllocStatusKey]), &allocation); err != nil { + t.Fatal(err) + } + return allocation +} + +type backfillPatchFailingClient struct { + client.Client + patchErr error + patchCalls int +} + +func (c *backfillPatchFailingClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + if _, ok := obj.(*sandboxv1alpha1.BatchSandbox); ok { + c.patchCalls++ + return c.patchErr + } + return c.Client.Patch(ctx, obj, patch, opts...) +} + +type backfillReconcileAllocator struct { + latestAllocation map[string]string +} + +func (a *backfillReconcileAllocator) Schedule(context.Context, *AllocSpec) (*algorithm.AllocAction, error) { + return &algorithm.AllocAction{}, nil +} + +func (a *backfillReconcileAllocator) GetPoolAllocation(context.Context, *sandboxv1alpha1.Pool) (map[string]string, error) { + return a.latestAllocation, nil +} + +func (a *backfillReconcileAllocator) ClearPoolAllocation(context.Context, string, string) error { + return nil +} + +func (a *backfillReconcileAllocator) ReleasePodsAllocation(context.Context, string, string, []string) { +} + +func (a *backfillReconcileAllocator) SyncSandboxAllocation(context.Context, *sandboxv1alpha1.BatchSandbox, []string) error { + return nil +} + +func (a *backfillReconcileAllocator) SyncSandboxReleased(context.Context, *sandboxv1alpha1.BatchSandbox, []string) error { + return nil +} + +func (a *backfillReconcileAllocator) GetSandboxAllocation(context.Context, *sandboxv1alpha1.BatchSandbox) ([]string, error) { + return nil, nil +} + +func (a *backfillReconcileAllocator) GetSandboxReleased(context.Context, *sandboxv1alpha1.BatchSandbox) ([]string, error) { + return nil, nil +} diff --git a/kubernetes/internal/controller/pool_controller.go b/kubernetes/internal/controller/pool_controller.go index d23534385..6856fb6bc 100644 --- a/kubernetes/internal/controller/pool_controller.go +++ b/kubernetes/internal/controller/pool_controller.go @@ -18,6 +18,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + stdjson "encoding/json" gerrors "errors" "fmt" "os" @@ -35,6 +36,7 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/apimachinery/pkg/util/json" + "k8s.io/apimachinery/pkg/util/validation" "k8s.io/client-go/rest" "k8s.io/client-go/tools/record" "k8s.io/client-go/util/retry" @@ -43,6 +45,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" logf "sigs.k8s.io/controller-runtime/pkg/log" @@ -209,6 +212,18 @@ func (r *PoolReconciler) reconcilePool(ctx context.Context, pool *sandboxv1alpha result = ctrl.Result{RequeueAfter: defaultRetryTime} } + // Best-effort compatibility migration for allocations written before PoolRef + // was included in the alloc-status annotation. Do not let a patch failure + // interrupt scheduling, releasing, or scaling. + for _, sandbox := range batchSandboxes { + if err := r.backfillLegacyPoolAllocation(ctx, latestPool, sandbox, schedulePods, schedResult.LatestAllocation); err != nil { + logf.FromContext(ctx).Error(err, "Failed to backfill legacy pool allocation", "pool", latestPool.Name, "sandbox", sandbox.Name) + if result.RequeueAfter == 0 || result.RequeueAfter > defaultRetryTime { + result.RequeueAfter = defaultRetryTime + } + } + } + // 4. Handle pool upgrade updateResult, err := r.updatePool(ctx, latestPool, schedulePods, schedResult.IdlePods) if err != nil { @@ -246,6 +261,156 @@ func (r *PoolReconciler) reconcilePool(ctx context.Context, pool *sandboxv1alpha return result, err } +// backfillLegacyPoolAllocation adds PoolRef and Generation to a verified legacy +// allocation annotation. It intentionally leaves all newer or ambiguous records +// untouched. +func (r *PoolReconciler) backfillLegacyPoolAllocation(ctx context.Context, pool *sandboxv1alpha1.Pool, sandbox *sandboxv1alpha1.BatchSandbox, pods []*corev1.Pod, latestAllocation map[string]string) error { + if sandbox.Spec.PoolRef == "" || sandbox.Spec.PoolRef != pool.Name || + !sandbox.DeletionTimestamp.IsZero() || + !controllerutil.ContainsFinalizer(sandbox, FinalizerPoolAllocation) { + return nil + } + + allocation, valid := parseLegacySandboxAllocation(sandbox) + if !valid { + return nil + } + + releasePods, valid := validLegacyAllocationRelease(sandbox.GetAnnotations(), AnnoAllocReleaseKey) + if !valid { + return nil + } + releasedPods, valid := validLegacyAllocationRelease(sandbox.GetAnnotations(), AnnoAllocReleasedKey) + if !valid { + return nil + } + if allocationIntersects(allocation.Pods, releasePods) || allocationIntersects(allocation.Pods, releasedPods) { + return nil + } + + poolPods := make(map[string]struct{}, len(pods)) + for _, pod := range pods { + if pod != nil && pod.DeletionTimestamp.IsZero() && pod.Labels[LabelPoolName] == pool.Name { + poolPods[pod.Name] = struct{}{} + } + } + for _, podName := range allocation.Pods { + if _, ok := poolPods[podName]; !ok { + return nil + } + if latestAllocation[podName] != sandbox.Name { + return nil + } + } + + allocation.PoolRef = pool.Name + allocation.Generation = sandbox.Generation + rawAllocation, err := json.Marshal(allocation) + if err != nil { + return err + } + patchData, err := json.Marshal(map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]string{AnnoAllocStatusKey: string(rawAllocation)}, + }, + }) + if err != nil { + return err + } + obj := &sandboxv1alpha1.BatchSandbox{} + obj.Name = sandbox.Name + obj.Namespace = sandbox.Namespace + return r.Patch(ctx, obj, client.RawPatch(types.MergePatchType, patchData)) +} + +// parseLegacySandboxAllocation accepts only the historical pods-only JSON +// shape. Explicit or partial newer evidence is never rewritten by migration. +func parseLegacySandboxAllocation(sandbox *sandboxv1alpha1.BatchSandbox) (SandboxAllocation, bool) { + raw, ok := sandbox.GetAnnotations()[AnnoAllocStatusKey] + if !ok { + return SandboxAllocation{}, false + } + var payload map[string]stdjson.RawMessage + if err := json.Unmarshal([]byte(raw), &payload); err != nil || payload == nil { + return SandboxAllocation{}, false + } + if _, exists := payload["poolRef"]; exists { + return SandboxAllocation{}, false + } + if _, exists := payload["generation"]; exists { + return SandboxAllocation{}, false + } + if len(payload) != 1 { + return SandboxAllocation{}, false + } + rawPods, ok := payload["pods"] + if !ok { + return SandboxAllocation{}, false + } + var pods []string + if err := json.Unmarshal(rawPods, &pods); err != nil || !validLegacyAllocationPods(pods) { + return SandboxAllocation{}, false + } + return SandboxAllocation{Pods: pods}, true +} + +func validLegacyAllocationPods(pods []string) bool { + if len(pods) == 0 { + return false + } + return validUniqueDNS1123PodNames(pods) +} + +// validLegacyAllocationRelease verifies that an optional release annotation has +// an explicit, non-null pods list whose pod names are valid and unique. +func validLegacyAllocationRelease(annotations map[string]string, key string) ([]string, bool) { + raw, ok := annotations[key] + if !ok { + return nil, true + } + + var payload map[string]stdjson.RawMessage + if err := json.Unmarshal([]byte(raw), &payload); err != nil || payload == nil { + return nil, false + } + rawPods, ok := payload["pods"] + if !ok { + return nil, false + } + var pods []string + if err := json.Unmarshal(rawPods, &pods); err != nil || pods == nil || !validUniqueDNS1123PodNames(pods) { + return nil, false + } + return pods, true +} + +func validUniqueDNS1123PodNames(pods []string) bool { + seen := make(map[string]struct{}, len(pods)) + for _, podName := range pods { + if podName == "" || len(validation.IsDNS1123Subdomain(podName)) != 0 { + return false + } + if _, ok := seen[podName]; ok { + return false + } + seen[podName] = struct{}{} + } + return true +} + +func allocationIntersects(allocationPods, otherPods []string) bool { + allocationSet := make(map[string]struct{}, len(allocationPods)) + for _, podName := range allocationPods { + allocationSet[podName] = struct{}{} + } + for _, podName := range otherPods { + if _, ok := allocationSet[podName]; ok { + return true + } + } + return false +} + func (r *PoolReconciler) calculateRevision(pool *sandboxv1alpha1.Pool) (string, error) { template, err := json.Marshal(pool.Spec.Template) if err != nil { diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Adapters/SandboxesAdapter.cs b/sdks/sandbox/csharp/src/OpenSandbox/Adapters/SandboxesAdapter.cs index 83a6a4c60..379c43d0c 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Adapters/SandboxesAdapter.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Adapters/SandboxesAdapter.cs @@ -339,6 +339,14 @@ private static SandboxInfo ParseSandboxInfo(JsonElement element) Platform = element.TryGetProperty("platform", out var platform) && platform.ValueKind == JsonValueKind.Object ? JsonSerializer.Deserialize(platform.GetRawText(), JsonOptions) : null, + Allocation = element.TryGetProperty("allocation", out var allocation) && allocation.ValueKind == JsonValueKind.Object + ? new AllocationSummary + { + Mode = allocation.GetProperty("mode").GetString() ?? throw new SandboxApiException("Missing allocation.mode in response"), + PoolRef = allocation.GetProperty("poolRef").GetString() ?? throw new SandboxApiException("Missing allocation.poolRef in response"), + State = allocation.GetProperty("state").GetString() ?? throw new SandboxApiException("Missing allocation.state in response") + } + : null, Entrypoint = element.GetProperty("entrypoint").EnumerateArray().Select(e => e.GetString() ?? string.Empty).ToList(), Metadata = ParseStringMap(element, "metadata"), Extensions = ParseStringMap(element, "extensions"), diff --git a/sdks/sandbox/csharp/src/OpenSandbox/Models/Sandboxes.cs b/sdks/sandbox/csharp/src/OpenSandbox/Models/Sandboxes.cs index d8c8784c1..7ac5d8fe2 100644 --- a/sdks/sandbox/csharp/src/OpenSandbox/Models/Sandboxes.cs +++ b/sdks/sandbox/csharp/src/OpenSandbox/Models/Sandboxes.cs @@ -712,6 +712,30 @@ public class SandboxStatus public string? Message { get; set; } } +/// +/// Runtime-confirmed Pool allocation for a sandbox. +/// +public class AllocationSummary +{ + /// + /// Gets or sets the confirmed allocation mode. Currently, this is "pool". + /// + [JsonPropertyName("mode")] + public required string Mode { get; set; } + + /// + /// Gets or sets the concrete Pool reference allocated to the sandbox. + /// + [JsonPropertyName("poolRef")] + public required string PoolRef { get; set; } + + /// + /// Gets or sets the confirmed allocation state. Currently, this is "allocated". + /// + [JsonPropertyName("state")] + public required string State { get; set; } +} + /// /// Information about a sandbox. /// @@ -765,6 +789,12 @@ public class SandboxInfo [JsonPropertyName("platform")] public PlatformSpec? Platform { get; set; } + /// + /// Gets or sets the current runtime-confirmed Pool allocation, when available. + /// + [JsonPropertyName("allocation")] + public AllocationSummary? Allocation { get; set; } + /// /// Gets or sets the sandbox creation time. /// diff --git a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/ModelsTests.cs b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/ModelsTests.cs index 3fceaeba0..ca2a33d3d 100644 --- a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/ModelsTests.cs +++ b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/ModelsTests.cs @@ -208,6 +208,21 @@ public void SandboxInfo_ShouldStoreProperties() info.Metadata.Should().ContainKey("key"); } + [Fact] + public void AllocationSummary_ShouldStorePoolAllocation() + { + var allocation = new AllocationSummary + { + Mode = "pool", + PoolRef = "default/python", + State = "allocated" + }; + + allocation.Mode.Should().Be("pool"); + allocation.PoolRef.Should().Be("default/python"); + allocation.State.Should().Be("allocated"); + } + [Fact] public void SandboxStatus_ShouldStoreProperties() { diff --git a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxesAdapterTests.cs b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxesAdapterTests.cs index 4a8b827c9..2f933df29 100644 --- a/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxesAdapterTests.cs +++ b/sdks/sandbox/csharp/tests/OpenSandbox.Tests/SandboxesAdapterTests.cs @@ -79,12 +79,74 @@ public async Task GetSandboxAsync_ShouldTreatMissingExpiresAtAsNull() SandboxInfo sandbox = await adapter.GetSandboxAsync("sbx-1"); sandbox.ExpiresAt.Should().BeNull(); + sandbox.Allocation.Should().BeNull(); sandbox.Platform.Should().NotBeNull(); sandbox.Platform!.Arch.Should().Be("amd64"); sandbox.Extensions.Should().ContainKey("opensandbox.extensions.custom-label") .WhoseValue.Should().Be("中文数据"); } + [Fact] + public async Task GetSandboxAsync_ShouldParseAllocation() + { + const string payload = """ + { + "id": "sbx-pool", + "status": { "state": "Running" }, + "entrypoint": ["/bin/sh"], + "createdAt": "2026-03-14T12:00:00Z", + "allocation": { + "mode": "pool", + "poolRef": "default/python", + "state": "allocated" + } + } + """; + var adapter = CreateAdapterWithJsonResponse(payload); + + SandboxInfo sandbox = await adapter.GetSandboxAsync("sbx-pool"); + + sandbox.Allocation.Should().NotBeNull(); + sandbox.Allocation!.Mode.Should().Be("pool"); + sandbox.Allocation.PoolRef.Should().Be("default/python"); + sandbox.Allocation.State.Should().Be("allocated"); + } + + [Fact] + public async Task ListSandboxesAsync_ShouldParseAllocation() + { + const string payload = """ + { + "items": [ + { + "id": "sbx-pool", + "status": { "state": "Running" }, + "entrypoint": ["/bin/sh"], + "createdAt": "2026-03-14T12:00:00Z", + "allocation": { + "mode": "pool", + "poolRef": "default/python", + "state": "allocated" + } + }, + { + "id": "sbx-legacy", + "status": { "state": "Running" }, + "entrypoint": ["/bin/sh"], + "createdAt": "2026-03-14T12:00:00Z" + } + ] + } + """; + var adapter = CreateAdapterWithJsonResponse(payload); + + ListSandboxesResponse response = await adapter.ListSandboxesAsync(); + + response.Items[0].Allocation.Should().NotBeNull(); + response.Items[0].Allocation!.PoolRef.Should().Be("default/python"); + response.Items[1].Allocation.Should().BeNull(); + } + [Fact] public async Task CreateSandboxAsync_ShouldTreatMissingExpiresAtAsNull() { diff --git a/sdks/sandbox/go/allocation_test.go b/sdks/sandbox/go/allocation_test.go new file mode 100644 index 000000000..9acb585b4 --- /dev/null +++ b/sdks/sandbox/go/allocation_test.go @@ -0,0 +1,100 @@ +// Copyright 2026 Alibaba Group Holding Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package opensandbox + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" +) + +func TestSandboxInfoAllocationJSON(t *testing.T) { + var info SandboxInfo + err := json.Unmarshal([]byte(`{ + "id":"sbx-pooled", + "status":{"state":"Running"}, + "createdAt":"2026-07-10T00:00:00Z", + "allocation":{"mode":"pool","poolRef":"pool-runc","state":"allocated"} + }`), &info) + require.NoErrorf(t, err, "unmarshal sandbox allocation") + require.NotNil(t, info.Allocation, "allocation should be present") + require.Equal(t, AllocationModePool, info.Allocation.Mode, "allocation mode") + require.Equal(t, "pool-runc", info.Allocation.PoolRef, "allocation pool ref") + require.Equal(t, AllocationStateAllocated, info.Allocation.State, "allocation state") +} + +func TestSandboxInfoAllocationAbsentIsOmitted(t *testing.T) { + info := SandboxInfo{ + ID: "sbx-unpooled", + Status: SandboxStatus{State: StateRunning}, + CreatedAt: mustParseTime(t, "2026-07-10T00:00:00Z"), + } + + body, err := json.Marshal(info) + require.NoErrorf(t, err, "marshal sandbox without allocation") + var fields map[string]json.RawMessage + require.NoErrorf(t, json.Unmarshal(body, &fields), "unmarshal sandbox JSON") + _, present := fields["allocation"] + if present { + t.Fatal("absent allocation should remain omitted") + } +} + +func TestLifecycleClientAllocationInGetAndListResponses(t *testing.T) { + _, client := newLifecycleServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/sandboxes/sbx-pooled": + _, _ = w.Write([]byte(`{ + "id":"sbx-pooled", + "status":{"state":"Running"}, + "createdAt":"2026-07-10T00:00:00Z", + "allocation":{"mode":"pool","poolRef":"pool-runc","state":"allocated"} + }`)) + case "/sandboxes": + _, _ = w.Write([]byte(`{ + "items":[{ + "id":"sbx-pooled", + "status":{"state":"Running"}, + "createdAt":"2026-07-10T00:00:00Z", + "allocation":{"mode":"pool","poolRef":"pool-runc","state":"allocated"} + }], + "pagination":{"page":1,"pageSize":20,"totalItems":1,"totalPages":1,"hasNextPage":false} + }`)) + default: + http.NotFound(w, r) + } + }) + + info, err := client.GetSandbox(context.Background(), "sbx-pooled") + require.NoErrorf(t, err, "get sandbox") + require.NotNil(t, info.Allocation, "get allocation") + require.Equal(t, "pool-runc", info.Allocation.PoolRef, "get allocation pool ref") + + list, err := client.ListSandboxes(context.Background(), ListOptions{}) + require.NoErrorf(t, err, "list sandboxes") + require.Len(t, list.Items, 1) + require.NotNil(t, list.Items[0].Allocation, "list allocation") + require.Equal(t, AllocationModePool, list.Items[0].Allocation.Mode, "list allocation mode") +} + +func mustParseTime(t *testing.T, value string) time.Time { + t.Helper() + parsed, err := time.Parse(time.RFC3339, value) + require.NoErrorf(t, err, "parse time") + return parsed +} diff --git a/sdks/sandbox/go/types.go b/sdks/sandbox/go/types.go index a322ca31c..eb3e5747d 100644 --- a/sdks/sandbox/go/types.go +++ b/sdks/sandbox/go/types.go @@ -165,19 +165,45 @@ type CreateSandboxRequest struct { Platform *PlatformSpec `json:"platform,omitempty"` } +// AllocationMode identifies how the runtime allocated a sandbox. +type AllocationMode string + +const ( + // AllocationModePool indicates that the sandbox was allocated from a pool. + AllocationModePool AllocationMode = "pool" +) + +// AllocationState describes the confirmed allocation state of a sandbox. +type AllocationState string + +const ( + // AllocationStateAllocated indicates that the pool allocation is active. + AllocationStateAllocated AllocationState = "allocated" +) + +// AllocationSummary is the public summary of a confirmed active pool +// allocation. It is present only when the runtime confirms an active pool +// allocation. +type AllocationSummary struct { + Mode AllocationMode `json:"mode"` + PoolRef string `json:"poolRef"` + State AllocationState `json:"state"` +} + // SandboxInfo represents a runtime execution environment provisioned from a // container image, as returned by the lifecycle API. type SandboxInfo struct { - ID string `json:"id"` - Image *ImageSpec `json:"image,omitempty"` - SnapshotID string `json:"snapshotId,omitempty"` - Status SandboxStatus `json:"status"` - Metadata map[string]string `json:"metadata,omitempty"` - Extensions map[string]string `json:"extensions,omitempty"` - Entrypoint []string `json:"entrypoint"` - ExpiresAt *time.Time `json:"expiresAt,omitempty"` - CreatedAt time.Time `json:"createdAt"` - Platform *PlatformSpec `json:"platform,omitempty"` + ID string `json:"id"` + Image *ImageSpec `json:"image,omitempty"` + SnapshotID string `json:"snapshotId,omitempty"` + Status SandboxStatus `json:"status"` + Metadata map[string]string `json:"metadata,omitempty"` + Extensions map[string]string `json:"extensions,omitempty"` + Entrypoint []string `json:"entrypoint"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + Platform *PlatformSpec `json:"platform,omitempty"` + Allocation *AllocationSummary `json:"allocation,omitempty"` } type SnapshotState string diff --git a/sdks/sandbox/javascript/src/adapters/sandboxesAdapter.ts b/sdks/sandbox/javascript/src/adapters/sandboxesAdapter.ts index 057926f5d..211dd688f 100644 --- a/sdks/sandbox/javascript/src/adapters/sandboxesAdapter.ts +++ b/sdks/sandbox/javascript/src/adapters/sandboxesAdapter.ts @@ -23,6 +23,7 @@ import type { CreateSnapshotRequest, CreateSandboxRequest, CreateSandboxResponse, + AllocationSummary, Endpoint, ListSnapshotsParams, ListSnapshotsResponse, @@ -65,6 +66,10 @@ type ApiListSnapshotsOk = type ApiEndpointOk = LifecyclePaths["/sandboxes/{sandboxId}/endpoints/{port}"]["get"]["responses"][200]["content"]["application/json"]; +type ApiSandboxWithAllocation = ApiGetSandboxOk & { + allocation?: AllocationSummary; +}; + function encodeMetadataFilter(metadata: Record): string { // The Lifecycle API expects a single `metadata` query parameter whose value is `k=v&k2=v2`. // The query serializer will URL-encode the value (e.g. `=` -> %3D and `&` -> %26). @@ -122,8 +127,10 @@ export class SandboxesAdapter implements Sandboxes { } private mapSandboxInfo(raw: ApiGetSandboxOk): SandboxInfo { + const { allocation, ...sandbox } = raw as ApiSandboxWithAllocation; return { - ...(raw ?? {}), + ...sandbox, + ...(allocation == null ? {} : { allocation }), createdAt: this.parseIsoDate("createdAt", raw?.createdAt), expiresAt: this.parseOptionalIsoDate("expiresAt", raw?.expiresAt), } as SandboxInfo; diff --git a/sdks/sandbox/javascript/src/api/lifecycle.ts b/sdks/sandbox/javascript/src/api/lifecycle.ts index 00da47b57..ad6d54ca8 100644 --- a/sdks/sandbox/javascript/src/api/lifecycle.ts +++ b/sdks/sandbox/javascript/src/api/lifecycle.ts @@ -932,6 +932,12 @@ export interface components { extensions?: { [key: string]: string; }; + /** + * @description Current runtime-confirmed pool allocation. Omitted unless an active pool + * allocation is confirmed; this is not a request echo, allocation history, + * readiness signal, or Kubernetes introspection result. + */ + allocation?: components["schemas"]["AllocationSummary"]; /** * @description The command to execute as the sandbox's entry process. * Always present in responses. For image-created sandboxes, this is copied @@ -950,6 +956,21 @@ export interface components { */ createdAt: string; }; + /** @description Public summary of a confirmed active pool allocation. */ + AllocationSummary: { + /** + * @description Allocation mode. + * @enum {string} + */ + mode: "pool"; + /** @description Concrete pool reference currently allocated. */ + poolRef: string; + /** + * @description Current confirmed allocation state. + * @enum {string} + */ + state: "allocated"; + }; /** * @description High-level lifecycle state of the sandbox. * diff --git a/sdks/sandbox/javascript/src/index.ts b/sdks/sandbox/javascript/src/index.ts index 608fe995e..2d25023ae 100644 --- a/sdks/sandbox/javascript/src/index.ts +++ b/sdks/sandbox/javascript/src/index.ts @@ -30,6 +30,7 @@ export { ConnectionConfig } from "./config/connection.js"; export type { ConnectionConfigOptions, ConnectionProtocol } from "./config/connection.js"; export type { + AllocationSummary, Credential, CredentialAuth, CredentialAuthMetadata, diff --git a/sdks/sandbox/javascript/src/models/sandboxes.ts b/sdks/sandbox/javascript/src/models/sandboxes.ts index 71ddd6810..f3e1e67bd 100644 --- a/sdks/sandbox/javascript/src/models/sandboxes.ts +++ b/sdks/sandbox/javascript/src/models/sandboxes.ts @@ -426,6 +426,24 @@ export interface SandboxStatus extends Record { message?: string; } +/** + * Current runtime-confirmed Pool allocation for a sandbox. + */ +export interface AllocationSummary extends Record { + /** + * Confirmed allocation mode. + */ + mode: "pool"; + /** + * Concrete Pool reference currently allocated to the sandbox. + */ + poolRef: string; + /** + * Current confirmed allocation state. + */ + state: "allocated"; +} + export interface SandboxInfo extends Record { id: SandboxId; image?: ImageSpec; @@ -435,6 +453,10 @@ export interface SandboxInfo extends Record { metadata?: Record; extensions?: Record; status: SandboxStatus; + /** + * Current runtime-confirmed Pool allocation, when available. + */ + allocation?: AllocationSummary; /** * Sandbox creation time. */ diff --git a/sdks/sandbox/javascript/tests/sandboxes.allocation.test.mjs b/sdks/sandbox/javascript/tests/sandboxes.allocation.test.mjs new file mode 100644 index 000000000..5fb0c4fa1 --- /dev/null +++ b/sdks/sandbox/javascript/tests/sandboxes.allocation.test.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SandboxesAdapter } from "../dist/internal.js"; + +function sandbox(overrides = {}) { + return { + id: "sandbox-1", + status: { state: "Running" }, + entrypoint: ["sleep", "infinity"], + createdAt: "2026-07-10T00:00:00Z", + expiresAt: null, + ...overrides, + }; +} + +test("getSandbox and listSandboxes expose a confirmed Pool allocation", async () => { + const allocation = { + mode: "pool", + poolRef: "default-pool", + state: "allocated", + }; + const client = { + async GET(path) { + if (path === "/sandboxes/{sandboxId}") { + return { + data: sandbox({ allocation }), + response: new Response(null, { status: 200 }), + }; + } + assert.equal(path, "/sandboxes"); + return { + data: { + items: [sandbox({ allocation })], + pagination: { + page: 1, + pageSize: 20, + totalItems: 1, + totalPages: 1, + hasNextPage: false, + }, + }, + response: new Response(null, { status: 200 }), + }; + }, + }; + + const adapter = new SandboxesAdapter(client); + const result = await adapter.getSandbox("sandbox-1"); + const listed = await adapter.listSandboxes(); + + assert.deepEqual(result.allocation, allocation); + assert.deepEqual(listed.items[0].allocation, allocation); +}); + +test("getSandbox and listSandboxes leave allocation absent for non-Pool sandboxes", async () => { + const client = { + async GET(path) { + if (path === "/sandboxes/{sandboxId}") { + return { + data: sandbox(), + response: new Response(null, { status: 200 }), + }; + } + assert.equal(path, "/sandboxes"); + return { + data: { + items: [sandbox()], + pagination: { + page: 1, + pageSize: 20, + totalItems: 1, + totalPages: 1, + hasNextPage: false, + }, + }, + response: new Response(null, { status: 200 }), + }; + }, + }; + + const adapter = new SandboxesAdapter(client); + const result = await adapter.getSandbox("sandbox-1"); + const listed = await adapter.listSandboxes(); + + assert.equal(Object.hasOwn(result, "allocation"), false); + assert.equal(result.allocation, undefined); + assert.equal(Object.hasOwn(listed.items[0], "allocation"), false); + assert.equal(listed.items[0].allocation, undefined); +}); diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/SandboxModels.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/SandboxModels.kt index f9043d156..d9d1036e7 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/SandboxModels.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/domain/models/sandboxes/SandboxModels.kt @@ -715,6 +715,7 @@ class Volume private constructor( * @property platform Effective platform used for sandbox provisioning * @property metadata Custom metadata attached to the sandbox * @property extensions Opaque extension data returned by the server + * @property allocation Current runtime-confirmed pool allocation, when available */ class SandboxInfo( val id: String, @@ -727,6 +728,45 @@ class SandboxInfo( val platform: PlatformSpec? = null, val metadata: Map? = null, val extensions: Map? = null, + val allocation: SandboxAllocation? = null, +) { + constructor( + id: String, + status: SandboxStatus, + entrypoint: List, + expiresAt: OffsetDateTime?, + createdAt: OffsetDateTime, + image: SandboxImageSpec?, + snapshotId: String?, + platform: PlatformSpec?, + metadata: Map?, + extensions: Map?, + ) : this( + id = id, + status = status, + entrypoint = entrypoint, + expiresAt = expiresAt, + createdAt = createdAt, + image = image, + snapshotId = snapshotId, + platform = platform, + metadata = metadata, + extensions = extensions, + allocation = null, + ) +} + +/** + * Current runtime-confirmed pool allocation for a sandbox. + * + * @property mode Confirmed allocation mode. Currently always `pool`. + * @property poolRef Concrete pool reference currently allocated to the sandbox. + * @property state Current confirmed allocation state. Currently always `allocated`. + */ +class SandboxAllocation( + val mode: String, + val poolRef: String, + val state: String, ) /** diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/SandboxModelConverter.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/SandboxModelConverter.kt index 6674b87ec..f55e9cc38 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/SandboxModelConverter.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/converter/SandboxModelConverter.kt @@ -43,6 +43,7 @@ import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxEndpoint import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxImageAuth import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxImageSpec import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxAllocation import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxMetrics import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxRenewResponse import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SnapshotInfo @@ -322,6 +323,14 @@ internal object SandboxModelConverter { snapshotId = this.snapshotId, platform = this.platform?.toDomainPlatformSpec(), status = this.status.toSandboxStatus(), + allocation = + this.allocation?.let { + SandboxAllocation( + mode = it.mode.value, + poolRef = it.poolRef, + state = it.state.value, + ) + }, metadata = metadata, extensions = extensions, ) diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/domain/models/SandboxInfoCompatibilityTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/domain/models/SandboxInfoCompatibilityTest.kt new file mode 100644 index 000000000..39633c8d5 --- /dev/null +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/domain/models/SandboxInfoCompatibilityTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2025 Alibaba Group Holding Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.alibaba.opensandbox.sandbox.domain.models + +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.PlatformSpec +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxImageSpec +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxInfo +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxState +import com.alibaba.opensandbox.sandbox.domain.models.sandboxes.SandboxStatus +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.time.OffsetDateTime + +class SandboxInfoCompatibilityTest { + @Test + fun `SandboxInfo retains the pre-allocation JVM constructor`() { + val constructor = + SandboxInfo::class.java.getConstructor( + String::class.java, + SandboxStatus::class.java, + List::class.java, + OffsetDateTime::class.java, + OffsetDateTime::class.java, + SandboxImageSpec::class.java, + String::class.java, + PlatformSpec::class.java, + Map::class.java, + Map::class.java, + ) + val createdAt = OffsetDateTime.parse("2026-07-10T00:00:00Z") + val status = SandboxStatus(SandboxState.RUNNING, null, null, createdAt) + + val sandboxInfo = + constructor.newInstance( + "sandbox-id", + status, + listOf("/bin/sh"), + null, + createdAt, + null, + null, + null, + null, + null, + ) + + assertEquals("sandbox-id", sandboxInfo.id) + assertEquals(status, sandboxInfo.status) + assertNull(sandboxInfo.allocation) + } +} diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/SandboxesAdapterTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/SandboxesAdapterTest.kt index bf5b6f51b..dd5c4fb1c 100644 --- a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/SandboxesAdapterTest.kt +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/SandboxesAdapterTest.kt @@ -555,6 +555,11 @@ class SandboxesAdapterTest { "image": { "uri": "ubuntu:latest" }, + "allocation": { + "mode": "pool", + "poolRef": "default/python", + "state": "allocated" + }, "metadata": {}, "extensions": { "opensandbox.extensions.custom-label": "中文数据" @@ -569,6 +574,9 @@ class SandboxesAdapterTest { assertEquals(sandboxId, result.id) assertEquals(SandboxState.RUNNING, result.status.state) assertEquals("ubuntu:latest", result.image!!.image) + assertEquals("pool", result.allocation!!.mode) + assertEquals("default/python", result.allocation!!.poolRef) + assertEquals("allocated", result.allocation!!.state) assertEquals("中文数据", result.extensions!!["opensandbox.extensions.custom-label"]) val request = mockWebServer.takeRequest() @@ -604,6 +612,7 @@ class SandboxesAdapterTest { assertEquals(sandboxId, result.id) assertEquals(null, result.expiresAt) + assertEquals(null, result.allocation) } @Test @@ -647,12 +656,24 @@ class SandboxesAdapterTest { val responseBody = """ { - "items": [], + "items": [ + { + "id": "pooled-sandbox", + "status": { "state": "Running" }, + "entrypoint": ["/bin/bash"], + "createdAt": "2023-01-01T10:00:00Z", + "allocation": { + "mode": "pool", + "poolRef": "default/python", + "state": "allocated" + } + } + ], "pagination": { "page": 0, "pageSize": 10, - "totalItems": 0, - "totalPages": 0, + "totalItems": 1, + "totalPages": 1, "hasNextPage": false } } @@ -668,7 +689,9 @@ class SandboxesAdapterTest { .pageSize(20) .build() - sandboxesAdapter.listSandboxes(filter) + val result = sandboxesAdapter.listSandboxes(filter) + + assertEquals("default/python", result.sandboxInfos.single().allocation!!.poolRef) val request = mockWebServer.takeRequest() val url = request.requestUrl diff --git a/sdks/sandbox/python/src/opensandbox/adapters/converter/sandbox_model_converter.py b/sdks/sandbox/python/src/opensandbox/adapters/converter/sandbox_model_converter.py index 854ba55cb..39e1f1cde 100644 --- a/sdks/sandbox/python/src/opensandbox/adapters/converter/sandbox_model_converter.py +++ b/sdks/sandbox/python/src/opensandbox/adapters/converter/sandbox_model_converter.py @@ -456,6 +456,7 @@ def to_sandbox_info(api_sandbox: Sandbox) -> SandboxInfo: """Convert API Sandbox to domain SandboxInfo.""" from opensandbox.api.lifecycle.types import Unset from opensandbox.models.sandboxes import ( + SandboxAllocation, SandboxImageAuth, SandboxImageSpec, SandboxInfo, @@ -500,6 +501,21 @@ def to_sandbox_info(api_sandbox: Sandbox) -> SandboxInfo: } ) + allocation: SandboxAllocation | None = None + api_allocation = getattr(api_sandbox, "allocation", None) + if not isinstance(api_allocation, Unset) and api_allocation is not None: + allocation = SandboxAllocation( + mode=cast( + Literal["pool"], + str(getattr(api_allocation.mode, "value", api_allocation.mode)), + ), + pool_ref=api_allocation.pool_ref, + state=cast( + Literal["allocated"], + str(getattr(api_allocation.state, "value", api_allocation.state)), + ), + ) + return SandboxInfo( id=api_sandbox.id, status=SandboxModelConverter._convert_sandbox_status(api_sandbox.status), @@ -510,6 +526,7 @@ def to_sandbox_info(api_sandbox: Sandbox) -> SandboxInfo: else getattr(api_sandbox, "snapshot_id", None) ), platform=platform, + allocation=allocation, created_at=api_sandbox.created_at, expires_at=expires_at, entrypoint=api_sandbox.entrypoint, diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/__init__.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/__init__.py index 55c195d47..26589ec37 100644 --- a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/__init__.py +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/__init__.py @@ -16,6 +16,9 @@ """Contains all the data models used in inputs/outputs""" +from .allocation_summary import AllocationSummary +from .allocation_summary_mode import AllocationSummaryMode +from .allocation_summary_state import AllocationSummaryState from .create_sandbox_request import CreateSandboxRequest from .create_sandbox_request_env import CreateSandboxRequestEnv from .create_sandbox_request_extensions import CreateSandboxRequestExtensions @@ -59,6 +62,9 @@ from .volume import Volume __all__ = ( + "AllocationSummary", + "AllocationSummaryMode", + "AllocationSummaryState", "CreateSandboxRequest", "CreateSandboxRequestEnv", "CreateSandboxRequestExtensions", diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/allocation_summary.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/allocation_summary.py new file mode 100644 index 000000000..89c15dc9b --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/allocation_summary.py @@ -0,0 +1,78 @@ +# +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define + +from ..models.allocation_summary_mode import AllocationSummaryMode +from ..models.allocation_summary_state import AllocationSummaryState + +T = TypeVar("T", bound="AllocationSummary") + + +@_attrs_define +class AllocationSummary: + """Public summary of a confirmed active pool allocation. + + Attributes: + mode (AllocationSummaryMode): Allocation mode. + pool_ref (str): Concrete pool reference currently allocated. + state (AllocationSummaryState): Current confirmed allocation state. + """ + + mode: AllocationSummaryMode + pool_ref: str + state: AllocationSummaryState + + def to_dict(self) -> dict[str, Any]: + mode = self.mode.value + + pool_ref = self.pool_ref + + state = self.state.value + + field_dict: dict[str, Any] = {} + + field_dict.update( + { + "mode": mode, + "poolRef": pool_ref, + "state": state, + } + ) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + mode = AllocationSummaryMode(d.pop("mode")) + + pool_ref = d.pop("poolRef") + + state = AllocationSummaryState(d.pop("state")) + + allocation_summary = cls( + mode=mode, + pool_ref=pool_ref, + state=state, + ) + + return allocation_summary diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/allocation_summary_mode.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/allocation_summary_mode.py new file mode 100644 index 000000000..522c3418d --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/allocation_summary_mode.py @@ -0,0 +1,24 @@ +# +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from enum import Enum + + +class AllocationSummaryMode(str, Enum): + POOL = "pool" + + def __str__(self) -> str: + return str(self.value) diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/allocation_summary_state.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/allocation_summary_state.py new file mode 100644 index 000000000..b17f7a0fd --- /dev/null +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/allocation_summary_state.py @@ -0,0 +1,24 @@ +# +# Copyright 2026 Alibaba Group Holding Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +from enum import Enum + + +class AllocationSummaryState(str, Enum): + ALLOCATED = "allocated" + + def __str__(self) -> str: + return str(self.value) diff --git a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/sandbox.py b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/sandbox.py index 35ac5f58d..8a15315b3 100644 --- a/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/sandbox.py +++ b/sdks/sandbox/python/src/opensandbox/api/lifecycle/models/sandbox.py @@ -27,6 +27,7 @@ from ..types import UNSET, Unset if TYPE_CHECKING: + from ..models.allocation_summary import AllocationSummary from ..models.image_spec import ImageSpec from ..models.platform_spec import PlatformSpec from ..models.sandbox_extensions import SandboxExtensions @@ -69,6 +70,7 @@ class Sandbox: request must fail explicitly. metadata (SandboxMetadata | Unset): Custom metadata from creation request extensions (SandboxExtensions | Unset): Opaque extension data restored from provider-specific storage + allocation (AllocationSummary | Unset): Public summary of a confirmed active pool allocation. expires_at (datetime.datetime | Unset): Timestamp when sandbox will auto-terminate. Omitted when manual cleanup is enabled. """ @@ -82,6 +84,7 @@ class Sandbox: platform: PlatformSpec | Unset = UNSET metadata: SandboxMetadata | Unset = UNSET extensions: SandboxExtensions | Unset = UNSET + allocation: AllocationSummary | Unset = UNSET expires_at: datetime.datetime | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) @@ -112,6 +115,10 @@ def to_dict(self) -> dict[str, Any]: if not isinstance(self.extensions, Unset): extensions = self.extensions.to_dict() + allocation: dict[str, Any] | Unset = UNSET + if not isinstance(self.allocation, Unset): + allocation = self.allocation.to_dict() + expires_at: str | Unset = UNSET if not isinstance(self.expires_at, Unset): expires_at = self.expires_at.isoformat() @@ -136,6 +143,8 @@ def to_dict(self) -> dict[str, Any]: field_dict["metadata"] = metadata if extensions is not UNSET: field_dict["extensions"] = extensions + if allocation is not UNSET: + field_dict["allocation"] = allocation if expires_at is not UNSET: field_dict["expiresAt"] = expires_at @@ -143,6 +152,7 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + from ..models.allocation_summary import AllocationSummary from ..models.image_spec import ImageSpec from ..models.platform_spec import PlatformSpec from ..models.sandbox_extensions import SandboxExtensions @@ -188,6 +198,13 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: else: extensions = SandboxExtensions.from_dict(_extensions) + _allocation = d.pop("allocation", UNSET) + allocation: AllocationSummary | Unset + if isinstance(_allocation, Unset): + allocation = UNSET + else: + allocation = AllocationSummary.from_dict(_allocation) + _expires_at = d.pop("expiresAt", UNSET) expires_at: datetime.datetime | Unset if isinstance(_expires_at, Unset): @@ -205,6 +222,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: platform=platform, metadata=metadata, extensions=extensions, + allocation=allocation, expires_at=expires_at, ) diff --git a/sdks/sandbox/python/src/opensandbox/models/__init__.py b/sdks/sandbox/python/src/opensandbox/models/__init__.py index d73e297ee..d2ec14174 100644 --- a/sdks/sandbox/python/src/opensandbox/models/__init__.py +++ b/sdks/sandbox/python/src/opensandbox/models/__init__.py @@ -76,6 +76,7 @@ PagedSandboxInfos, PaginationInfo, PlatformSpec, + SandboxAllocation, SandboxCreateResponse, SandboxEndpoint, SandboxFilter, @@ -121,6 +122,7 @@ "SearchEntry", # Sandbox models "SandboxInfo", + "SandboxAllocation", "SandboxStatus", "SandboxState", "NetworkPolicy", diff --git a/sdks/sandbox/python/src/opensandbox/models/sandboxes.py b/sdks/sandbox/python/src/opensandbox/models/sandboxes.py index bdeb75059..034fda0fa 100644 --- a/sdks/sandbox/python/src/opensandbox/models/sandboxes.py +++ b/sdks/sandbox/python/src/opensandbox/models/sandboxes.py @@ -584,6 +584,18 @@ class SandboxStatus(BaseModel): model_config = ConfigDict(populate_by_name=True) +class SandboxAllocation(BaseModel): + """Current runtime-confirmed Pool allocation for a sandbox.""" + + mode: Literal["pool"] = Field(description="Confirmed allocation mode.") + pool_ref: str = Field( + description="Concrete Pool reference currently allocated to the sandbox." + ) + state: Literal["allocated"] = Field( + description="Current confirmed allocation state." + ) + + class SnapshotStatus(BaseModel): """ Status information for a snapshot. @@ -649,6 +661,10 @@ class SandboxInfo(BaseModel): platform: PlatformSpec | None = Field( default=None, description="Effective platform used for sandbox provisioning." ) + allocation: SandboxAllocation | None = Field( + default=None, + description="Current runtime-confirmed Pool allocation, when available.", + ) metadata: dict[str, str] | None = Field(default=None, description="Custom metadata") extensions: dict[str, str] | None = Field( default=None, description="Opaque extension data returned by the server" diff --git a/sdks/sandbox/python/tests/test_converters_and_error_handling.py b/sdks/sandbox/python/tests/test_converters_and_error_handling.py index 334f14217..f31bc0f25 100644 --- a/sdks/sandbox/python/tests/test_converters_and_error_handling.py +++ b/sdks/sandbox/python/tests/test_converters_and_error_handling.py @@ -602,6 +602,80 @@ def test_sandbox_model_converter_preserves_missing_metadata_default() -> None: converted = SandboxModelConverter.to_sandbox_info(api_sandbox) assert converted.metadata == {} assert converted.extensions is None + assert converted.allocation is None + + +def test_sandbox_model_converter_maps_allocation() -> None: + from opensandbox.api.lifecycle.models.allocation_summary import AllocationSummary + from opensandbox.api.lifecycle.models.allocation_summary_mode import ( + AllocationSummaryMode, + ) + from opensandbox.api.lifecycle.models.allocation_summary_state import ( + AllocationSummaryState, + ) + from opensandbox.api.lifecycle.models.sandbox import Sandbox + from opensandbox.api.lifecycle.models.sandbox_status import SandboxStatus + + api_sandbox = Sandbox( + id="sbx-1", + status=SandboxStatus(state="Running"), + created_at=datetime(2025, 1, 1), + entrypoint=["/bin/sh"], + allocation=AllocationSummary( + mode=AllocationSummaryMode.POOL, + pool_ref="default/python", + state=AllocationSummaryState.ALLOCATED, + ), + ) + + converted = SandboxModelConverter.to_sandbox_info(api_sandbox) + assert converted.allocation is not None + assert converted.allocation.mode == "pool" + assert converted.allocation.pool_ref == "default/python" + assert converted.allocation.state == "allocated" + + +def test_sandbox_model_converter_maps_allocation_for_list_results() -> None: + from opensandbox.api.lifecycle.models.allocation_summary import AllocationSummary + from opensandbox.api.lifecycle.models.allocation_summary_mode import ( + AllocationSummaryMode, + ) + from opensandbox.api.lifecycle.models.allocation_summary_state import ( + AllocationSummaryState, + ) + from opensandbox.api.lifecycle.models.list_sandboxes_response import ( + ListSandboxesResponse, + ) + from opensandbox.api.lifecycle.models.pagination_info import PaginationInfo + from opensandbox.api.lifecycle.models.sandbox import Sandbox + from opensandbox.api.lifecycle.models.sandbox_status import SandboxStatus + + api_response = ListSandboxesResponse( + items=[ + Sandbox( + id="sbx-1", + status=SandboxStatus(state="Running"), + created_at=datetime(2025, 1, 1), + entrypoint=["/bin/sh"], + allocation=AllocationSummary( + mode=AllocationSummaryMode.POOL, + pool_ref="default/python", + state=AllocationSummaryState.ALLOCATED, + ), + ) + ], + pagination=PaginationInfo( + page=1, + page_size=10, + total_items=1, + total_pages=1, + has_next_page=False, + ), + ) + + converted = SandboxModelConverter.to_paged_sandbox_infos(api_response) + assert converted.sandbox_infos[0].allocation is not None + assert converted.sandbox_infos[0].allocation.pool_ref == "default/python" def test_sandbox_model_converter_supports_windows_platform_request() -> None: diff --git a/sdks/sandbox/python/tests/test_models_stability.py b/sdks/sandbox/python/tests/test_models_stability.py index 0d7ac9c17..3d9186cad 100644 --- a/sdks/sandbox/python/tests/test_models_stability.py +++ b/sdks/sandbox/python/tests/test_models_stability.py @@ -16,9 +16,11 @@ from __future__ import annotations from datetime import datetime, timezone +from typing import cast import pytest +from opensandbox.api.lifecycle.models.allocation_summary import AllocationSummary from opensandbox.api.lifecycle.models.create_sandbox_response import ( CreateSandboxResponse as ApiCreateSandboxResponse, ) @@ -43,6 +45,7 @@ OSSFS, PVC, Host, + SandboxAllocation, SandboxFilter, SandboxImageAuth, SandboxImageSpec, @@ -102,11 +105,31 @@ def test_api_sandbox_tolerates_omitted_optional_fields() -> None: "createdAt": "2025-01-01T00:00:00Z", } ) + assert sandbox.allocation is UNSET assert sandbox.metadata is UNSET assert sandbox.expires_at is UNSET assert sandbox.status.last_transition_at is UNSET +def test_api_sandbox_parses_optional_allocation() -> None: + sandbox = ApiSandbox.from_dict( + { + "id": "sandbox-1", + "status": {"state": "Running"}, + "entrypoint": ["/bin/sh"], + "createdAt": "2025-01-01T00:00:00Z", + "allocation": { + "mode": "pool", + "poolRef": "default/python", + "state": "allocated", + }, + } + ) + + allocation = cast(AllocationSummary, sandbox.allocation) + assert allocation.pool_ref == "default/python" + + def test_sandbox_image_auth_rejects_blank_username_and_password() -> None: with pytest.raises(ValueError): SandboxImageAuth(username=" ", password="x") @@ -156,6 +179,23 @@ def test_sandbox_info_supports_manual_cleanup_expiration() -> None: assert dumped["expires_at"] is None +def test_sandbox_info_exposes_optional_allocation() -> None: + info = SandboxInfo( + id=str(__import__("uuid").uuid4()), + status=SandboxStatus(state="RUNNING"), + entrypoint=["/bin/sh"], + created_at=datetime(2025, 1, 1, tzinfo=timezone.utc), + allocation=SandboxAllocation( + mode="pool", + pool_ref="default/python", + state="allocated", + ), + ) + + assert info.allocation is not None + assert info.allocation.pool_ref == "default/python" + + def test_filesystem_models_aliases_and_validation() -> None: m = MoveEntry(source="/a", destination="/b") assert m.src == "/a" diff --git a/server/opensandbox_server/api/schema.py b/server/opensandbox_server/api/schema.py index 406baae73..df8076765 100644 --- a/server/opensandbox_server/api/schema.py +++ b/server/opensandbox_server/api/schema.py @@ -566,6 +566,17 @@ class Config: populate_by_name = True +class AllocationSummary(BaseModel): + """Current runtime-confirmed pool allocation summary.""" + mode: Literal["pool"] = Field("pool", description="Allocation mode.") + pool_ref: str = Field(..., alias="poolRef", description="Concrete pool reference currently allocated.") + state: Literal["allocated"] = Field("allocated", description="Current confirmed allocation state.") + + class Config: + populate_by_name = True + extra = "forbid" + + class Sandbox(BaseModel): """ Runtime execution environment provisioned from a container image. @@ -592,6 +603,14 @@ class Sandbox(BaseModel): None, description="Opaque extension data restored from provider-specific storage", ) + allocation: Optional[AllocationSummary] = Field( + None, + description=( + "Current runtime-confirmed pool allocation summary. Omitted unless the runtime confirms " + "an active pool allocation; it is not a request echo, allocation history, readiness signal, " + "or Kubernetes introspection result." + ), + ) entrypoint: Optional[List[str]] = Field(None, description="The command to execute as the sandbox's entry process") expires_at: Optional[datetime] = Field( None, diff --git a/server/opensandbox_server/services/k8s/workload_mapper.py b/server/opensandbox_server/services/k8s/workload_mapper.py index b7134522f..129767cbd 100644 --- a/server/opensandbox_server/services/k8s/workload_mapper.py +++ b/server/opensandbox_server/services/k8s/workload_mapper.py @@ -14,9 +14,17 @@ from __future__ import annotations +import json +import re from typing import Any, Optional -from opensandbox_server.api.schema import ImageSpec, PlatformSpec, Sandbox, SandboxStatus +from opensandbox_server.api.schema import ( + AllocationSummary, + ImageSpec, + PlatformSpec, + Sandbox, + SandboxStatus, +) from opensandbox_server.extensions import extract_extensions_from_mapping from opensandbox_server.services.constants import SANDBOX_ID_LABEL, SANDBOX_SNAPSHOT_ID_LABEL @@ -43,6 +51,7 @@ def _build_sandbox_from_workload(workload: Any, workload_provider: Any) -> Sandb snapshot_id = labels.get(SANDBOX_SNAPSHOT_ID_LABEL) expires_at = workload_provider.get_expiration(workload) status_info = workload_provider.get_status(workload) + workload_status = workload.get("status", {}) if isinstance(workload, dict) else workload.status user_metadata = { k: v for k, v in labels.items() if not _is_opensandbox_label(k) @@ -67,6 +76,7 @@ def _build_sandbox_from_workload(workload: Any, workload_provider: Any) -> Sandb if not snapshot_id: image_spec = ImageSpec(uri=image_uri) if image_uri else ImageSpec(uri="unknown") platform_spec = _extract_platform_from_workload(workload) + allocation = _extract_confirmed_pool_allocation(metadata, spec, workload_status) return Sandbox( id=sandbox_id, status=SandboxStatus( @@ -83,9 +93,120 @@ def _build_sandbox_from_workload(workload: Any, workload_provider: Any) -> Sandb snapshotId=snapshot_id, entrypoint=entrypoint, platform=platform_spec, + allocation=allocation, ) +_POD_NAME_PATTERN = re.compile(r"^[a-z0-9](?:[-a-z0-9.]{0,251}[a-z0-9])?$") +_ALLOCATION_RELEASE_ANNOTATION_KEYS = ( + "sandbox.opensandbox.io/alloc-release", + "sandbox.opensandbox.io/alloc-released", +) + + +def _extract_confirmed_pool_allocation( + metadata: Any, + spec: Any, + status: Any, +) -> Optional[AllocationSummary]: + """Return a summary only when current pool allocation evidence is complete.""" + pool_ref = _field(spec, "poolRef", "pool_ref") + if not isinstance(pool_ref, str) or not pool_ref.strip() or pool_ref == "*": + return None + + if _field(metadata, "deletionTimestamp", "deletion_timestamp"): + return None + finalizers = _field(metadata, "finalizers") + if ( + not isinstance(finalizers, list) + or "pool.sandbox.opensandbox.io/pool-allocation" not in finalizers + ): + return None + + annotations = _field(metadata, "annotations") + if not isinstance(annotations, dict): + return None + raw_allocation = annotations.get("sandbox.opensandbox.io/alloc-status") + if not isinstance(raw_allocation, str): + return None + try: + annotation = json.loads(raw_allocation) + except (TypeError, ValueError): + return None + if not isinstance(annotation, dict) or annotation.get("poolRef") != pool_ref: + return None + + pods = annotation.get("pods") + if ( + not isinstance(pods, list) + or not pods + or any(not isinstance(pod, str) or not _POD_NAME_PATTERN.fullmatch(pod) for pod in pods) + or len(set(pods)) != len(pods) + ): + return None + allocated = _field(status, "allocated") + if ( + not isinstance(allocated, int) + or isinstance(allocated, bool) + or allocated != len(pods) + ): + return None + if _has_released_or_releasing_allocation_pods(annotations, pods): + return None + + return AllocationSummary(poolRef=pool_ref) + + +def _has_released_or_releasing_allocation_pods( + annotations: dict[Any, Any], + allocation_pods: list[str], +) -> bool: + """Return whether release state is malformed or includes allocated pods.""" + allocation_pod_names = set(allocation_pods) + for key in _ALLOCATION_RELEASE_ANNOTATION_KEYS: + if key not in annotations: + continue + + raw_release = annotations[key] + if not isinstance(raw_release, str): + return True + try: + release = json.loads(raw_release) + except (TypeError, ValueError): + return True + if not isinstance(release, dict): + return True + + released_pods = release.get("pods") + if ( + not isinstance(released_pods, list) + or any( + not isinstance(pod, str) + or not _POD_NAME_PATTERN.fullmatch(pod) + for pod in released_pods + ) + or len(set(released_pods)) != len(released_pods) + ): + return True + if allocation_pod_names.intersection(released_pods): + return True + + return False + + +def _field(value: Any, *names: str) -> Any: + if isinstance(value, dict): + for name in names: + if name in value: + return value[name] + return None + for name in names: + field = getattr(value, name, None) + if field is not None: + return field + return None + + def _extract_platform_from_workload(workload: Any) -> Optional[PlatformSpec]: if isinstance(workload, dict): spec = workload.get("spec") or {} diff --git a/server/tests/k8s/test_workload_mapper.py b/server/tests/k8s/test_workload_mapper.py index 0bd32fb09..651802bee 100644 --- a/server/tests/k8s/test_workload_mapper.py +++ b/server/tests/k8s/test_workload_mapper.py @@ -12,6 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json +from types import SimpleNamespace + +import pytest + from opensandbox_server.services.k8s.workload_mapper import ( _build_sandbox_from_workload, _extract_platform_from_workload, @@ -51,6 +56,151 @@ def test_restores_extensions_from_annotations(self): assert sandbox.extensions == {"opensandbox.extensions.custom-label": "中文数据"} + def test_returns_confirmed_pool_allocation_for_dict_workload(self): + sandbox = _build_sandbox_from_workload(_allocated_workload(), _WorkloadProvider()) + + assert sandbox.allocation is not None + assert sandbox.allocation.model_dump(by_alias=True) == { + "mode": "pool", + "poolRef": "pool-runc", + "state": "allocated", + } + + def test_returns_confirmed_pool_allocation_for_object_workload(self): + workload = SimpleNamespace( + metadata=SimpleNamespace( + labels={"opensandbox.io/id": "sandbox-1"}, + annotations={ + "sandbox.opensandbox.io/alloc-status": json.dumps( + {"pods": ["pod-1"], "poolRef": "pool-runc", "generation": 4} + ) + }, + finalizers=["pool.sandbox.opensandbox.io/pool-allocation"], + deletion_timestamp=None, + creation_timestamp="2026-06-22T00:00:00Z", + ), + spec=SimpleNamespace(pool_ref="pool-runc", containers=[]), + status=SimpleNamespace(allocated=1), + ) + + sandbox = _build_sandbox_from_workload(workload, _WorkloadProvider()) + + assert sandbox.allocation is not None + assert sandbox.allocation.pool_ref == "pool-runc" + + @pytest.mark.parametrize( + ("name", "mutate"), + [ + ("wrong annotation pool reference", lambda w: _set_annotation(w, {"pods": ["pod-1"], "poolRef": "other", "generation": 4})), + ("missing annotation pool reference", lambda w: _set_annotation(w, {"pods": ["pod-1"], "generation": 4})), + ("legacy pods-only annotation", lambda w: _set_annotation(w, {"pods": ["pod-1"]})), + ("deleting", lambda w: w["metadata"].update({"deletionTimestamp": "2026-06-23T00:00:00Z"})), + ("missing finalizer", lambda w: w["metadata"].update({"finalizers": []})), + ("missing allocation annotation", lambda w: w["metadata"].update({"annotations": {}})), + ("invalid annotation JSON", lambda w: w["metadata"]["annotations"].update({"sandbox.opensandbox.io/alloc-status": "{"})), + ("empty pods", lambda w: _set_annotation(w, {"pods": [], "poolRef": "pool-runc", "generation": 4})), + ("empty pod name", lambda w: _set_annotation(w, {"pods": [""], "poolRef": "pool-runc", "generation": 4})), + ("invalid pod name", lambda w: _set_annotation(w, {"pods": ["Pod-1"], "poolRef": "pool-runc", "generation": 4})), + ("duplicate pod names", lambda w: _set_annotation(w, {"pods": ["pod-1", "pod-1"], "poolRef": "pool-runc", "generation": 4})), + ("allocated count mismatch", lambda w: w["status"].update({"allocated": 2})), + ("wildcard pool reference", lambda w: w["spec"].update({"poolRef": "*"})), + ("non-pool workload", lambda w: w["spec"].pop("poolRef")), + ], + ) + def test_omits_unconfirmed_pool_allocation(self, name, mutate): + workload = _allocated_workload() + mutate(workload) + + sandbox = _build_sandbox_from_workload(workload, _WorkloadProvider()) + + assert sandbox.allocation is None, name + + def test_renewal_generation_drift_remains_confirmed(self): + workload = _allocated_workload() + workload["metadata"]["generation"] = 12 + _set_annotation( + workload, + {"pods": ["pod-1"], "poolRef": "pool-runc", "generation": 4}, + ) + + sandbox = _build_sandbox_from_workload(workload, _WorkloadProvider()) + + assert sandbox.allocation is not None + assert sandbox.allocation.pool_ref == "pool-runc" + + @pytest.mark.parametrize( + "annotation_key", + [ + "sandbox.opensandbox.io/alloc-release", + "sandbox.opensandbox.io/alloc-released", + ], + ) + def test_omits_allocation_when_release_state_intersects_allocation( + self, annotation_key + ): + workload = _allocated_workload() + workload["metadata"]["annotations"][annotation_key] = json.dumps( + {"pods": ["pod-1"]} + ) + + sandbox = _build_sandbox_from_workload(workload, _WorkloadProvider()) + + assert sandbox.allocation is None + + @pytest.mark.parametrize( + "annotation_key", + [ + "sandbox.opensandbox.io/alloc-release", + "sandbox.opensandbox.io/alloc-released", + ], + ) + def test_omits_allocation_when_release_state_is_malformed(self, annotation_key): + workload = _allocated_workload() + workload["metadata"]["annotations"][annotation_key] = "{" + + sandbox = _build_sandbox_from_workload(workload, _WorkloadProvider()) + + assert sandbox.allocation is None + + @pytest.mark.parametrize( + "annotation_key", + [ + "sandbox.opensandbox.io/alloc-release", + "sandbox.opensandbox.io/alloc-released", + ], + ) + def test_returns_allocation_for_non_intersecting_release_state(self, annotation_key): + workload = _allocated_workload() + workload["metadata"]["annotations"][annotation_key] = json.dumps( + {"pods": ["pod-2"]} + ) + + sandbox = _build_sandbox_from_workload(workload, _WorkloadProvider()) + + assert sandbox.allocation is not None + assert sandbox.allocation.pool_ref == "pool-runc" + + +def _allocated_workload(pool_ref="pool-runc"): + return { + "metadata": { + "labels": {"opensandbox.io/id": "sandbox-1"}, + "annotations": { + "sandbox.opensandbox.io/alloc-status": json.dumps( + {"pods": ["pod-1"], "poolRef": pool_ref, "generation": 4} + ) + }, + "finalizers": ["pool.sandbox.opensandbox.io/pool-allocation"], + "creationTimestamp": "2026-06-22T00:00:00Z", + }, + "spec": {"poolRef": pool_ref, "template": None}, + "status": {"allocated": 1}, + } + + +def _set_annotation(workload, allocation): + workload["metadata"]["annotations"]["sandbox.opensandbox.io/alloc-status"] = json.dumps(allocation) + class TestExtractPlatformFromWorkload: """Regression tests for _extract_platform_from_workload. diff --git a/server/tests/test_routes_get_sandbox.py b/server/tests/test_routes_get_sandbox.py index d1079da06..bbbecd10f 100644 --- a/server/tests/test_routes_get_sandbox.py +++ b/server/tests/test_routes_get_sandbox.py @@ -18,7 +18,7 @@ from fastapi.testclient import TestClient from opensandbox_server.api import lifecycle -from opensandbox_server.api.schema import ImageSpec, Sandbox, SandboxStatus +from opensandbox_server.api.schema import AllocationSummary, ImageSpec, Sandbox, SandboxStatus def test_get_sandbox_returns_service_payload( @@ -37,6 +37,7 @@ def get_sandbox(sandbox_id: str) -> Sandbox: image=ImageSpec(uri="python:3.11"), status=SandboxStatus(state="Running"), metadata={"team": "infra"}, + allocation=AllocationSummary(poolRef="pool-runc"), entrypoint=["python", "-V"], expiresAt=now + timedelta(hours=1), createdAt=now, @@ -51,6 +52,11 @@ def get_sandbox(sandbox_id: str) -> Sandbox: assert payload["id"] == "sbx-001" assert payload["status"]["state"] == "Running" assert payload["image"]["uri"] == "python:3.11" + assert payload["allocation"] == { + "mode": "pool", + "poolRef": "pool-runc", + "state": "allocated", + } def test_get_sandbox_propagates_not_found( @@ -108,6 +114,7 @@ def get_sandbox(sandbox_id: str) -> Sandbox: payload = response.json() assert "expiresAt" not in payload assert "metadata" not in payload + assert "allocation" not in payload assert "reason" not in payload["status"] assert "message" not in payload["status"] assert "lastTransitionAt" not in payload["status"] diff --git a/specs/sandbox-lifecycle.yml b/specs/sandbox-lifecycle.yml index 8564db4f8..b6cd440ef 100644 --- a/specs/sandbox-lifecycle.yml +++ b/specs/sandbox-lifecycle.yml @@ -1079,6 +1079,13 @@ components: type: string description: Opaque extension data restored from provider-specific storage + allocation: + $ref: '#/components/schemas/AllocationSummary' + description: | + Current runtime-confirmed pool allocation. Omitted unless an active pool + allocation is confirmed; this is not a request echo, allocation history, + readiness signal, or Kubernetes introspection result. + entrypoint: type: array items: @@ -1104,6 +1111,23 @@ components: - status - createdAt - entrypoint + AllocationSummary: + type: object + description: Public summary of a confirmed active pool allocation. + properties: + mode: + type: string + enum: [pool] + description: Allocation mode. + poolRef: + type: string + description: Concrete pool reference currently allocated. + state: + type: string + enum: [allocated] + description: Current confirmed allocation state. + required: [mode, poolRef, state] + additionalProperties: false SandboxState: type: string description: |