From 76d50d49d96abf4c6aa548b39e41916020105fbd Mon Sep 17 00:00:00 2001 From: Noel Date: Sun, 22 Mar 2026 10:32:36 +0100 Subject: [PATCH 1/4] feat: add workspace resource overrides to sandbox claims --- extensions/api/v1alpha1/sandboxclaim_types.go | 19 ++ .../api/v1alpha1/zz_generated.deepcopy.go | 20 ++ .../controllers/sandboxclaim_controller.go | 42 +++ .../sandboxclaim_controller_test.go | 277 ++++++++++++++++++ ...ensions.agents.x-k8s.io_sandboxclaims.yaml | 12 + 5 files changed, 370 insertions(+) diff --git a/extensions/api/v1alpha1/sandboxclaim_types.go b/extensions/api/v1alpha1/sandboxclaim_types.go index 725eeea76..b4509e071 100644 --- a/extensions/api/v1alpha1/sandboxclaim_types.go +++ b/extensions/api/v1alpha1/sandboxclaim_types.go @@ -95,6 +95,21 @@ type SandboxTemplateRef struct { Name string `json:"name,omitempty" protobuf:"bytes,1,name=name"` } +// WorkspaceResources defines per-claim resource overrides for the workspace container. +type WorkspaceResources struct { + // CPUMillicores is the desired CPU request/limit for the workspace container. + // +optional + CPUMillicores int32 `json:"cpuMillicores,omitempty"` + + // MemoryMB is the desired memory request/limit for the workspace container. + // +optional + MemoryMB int32 `json:"memoryMB,omitempty"` + + // DiskGB is the desired ephemeral-storage request/limit for the workspace container. + // +optional + DiskGB int32 `json:"diskGB,omitempty"` +} + // SandboxClaimSpec defines the desired state of Sandbox type SandboxClaimSpec struct { // sandboxTemplateRef defines the name of the SandboxTemplate to be used for creating a Sandbox. @@ -112,6 +127,10 @@ type SandboxClaimSpec struct { // +optional // +kubebuilder:default=default WarmPool *WarmPoolPolicy `json:"warmpool,omitempty"` + + // WorkspaceResources overrides resource requests/limits for the workspace container at claim time. + // +optional + WorkspaceResources *WorkspaceResources `json:"workspaceResources,omitempty"` } // SandboxClaimStatus defines the observed state of Sandbox. diff --git a/extensions/api/v1alpha1/zz_generated.deepcopy.go b/extensions/api/v1alpha1/zz_generated.deepcopy.go index d02750122..ef782d7e0 100644 --- a/extensions/api/v1alpha1/zz_generated.deepcopy.go +++ b/extensions/api/v1alpha1/zz_generated.deepcopy.go @@ -131,6 +131,11 @@ func (in *SandboxClaimSpec) DeepCopyInto(out *SandboxClaimSpec) { *out = new(WarmPoolPolicy) **out = **in } + if in.WorkspaceResources != nil { + in, out := &in.WorkspaceResources, &out.WorkspaceResources + *out = new(WorkspaceResources) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SandboxClaimSpec. @@ -291,6 +296,21 @@ func (in *SandboxTemplateStatus) DeepCopy() *SandboxTemplateStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkspaceResources) DeepCopyInto(out *WorkspaceResources) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkspaceResources. +func (in *WorkspaceResources) DeepCopy() *WorkspaceResources { + if in == nil { + return nil + } + out := new(WorkspaceResources) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SandboxWarmPool) DeepCopyInto(out *SandboxWarmPool) { *out = *in diff --git a/extensions/controllers/sandboxclaim_controller.go b/extensions/controllers/sandboxclaim_controller.go index 4447aec79..c909b4c07 100644 --- a/extensions/controllers/sandboxclaim_controller.go +++ b/extensions/controllers/sandboxclaim_controller.go @@ -26,6 +26,7 @@ import ( "k8s.io/apimachinery/pkg/api/equality" k8errors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/tools/record" @@ -376,6 +377,45 @@ func (r *SandboxClaimReconciler) computeAndSetStatus(claim *extensionsv1alpha1.S } } +func applyWorkspaceResourceOverrides(container *corev1.Container, overrides *extensionsv1alpha1.WorkspaceResources) { + if overrides == nil { + return + } + if container.Resources.Requests == nil { + container.Resources.Requests = corev1.ResourceList{} + } + if container.Resources.Limits == nil { + container.Resources.Limits = corev1.ResourceList{} + } + if overrides.CPUMillicores > 0 { + qty := resource.MustParse(fmt.Sprintf("%dm", overrides.CPUMillicores)) + container.Resources.Requests[corev1.ResourceCPU] = qty + container.Resources.Limits[corev1.ResourceCPU] = qty + } + if overrides.MemoryMB > 0 { + qty := resource.MustParse(fmt.Sprintf("%dMi", overrides.MemoryMB)) + container.Resources.Requests[corev1.ResourceMemory] = qty + container.Resources.Limits[corev1.ResourceMemory] = qty + } + if overrides.DiskGB > 0 { + qty := resource.MustParse(fmt.Sprintf("%dGi", overrides.DiskGB)) + container.Resources.Requests[corev1.ResourceEphemeralStorage] = qty + container.Resources.Limits[corev1.ResourceEphemeralStorage] = qty + } +} + +func applyClaimWorkspaceResourcesToPodSpec(spec *corev1.PodSpec, claim *extensionsv1alpha1.SandboxClaim) { + if claim.Spec.WorkspaceResources == nil { + return + } + for i := range spec.Containers { + container := &spec.Containers[i] + if container.Name != "workspace" { + continue + } + applyWorkspaceResourceOverrides(container, claim.Spec.WorkspaceResources) + } +} // adoptSandboxFromCandidates picks the best candidate and transfers ownership to the claim. func (r *SandboxClaimReconciler) adoptSandboxFromCandidates(ctx context.Context, claim *extensionsv1alpha1.SandboxClaim, candidates []*v1alpha1.Sandbox) (*v1alpha1.Sandbox, error) { logger := log.FromContext(ctx) @@ -446,6 +486,7 @@ func (r *SandboxClaimReconciler) adoptSandboxFromCandidates(ctx context.Context, adopted.Spec.PodTemplate.ObjectMeta.Labels = make(map[string]string) } adopted.Spec.PodTemplate.ObjectMeta.Labels[extensionsv1alpha1.SandboxIDLabel] = string(claim.UID) + applyClaimWorkspaceResourcesToPodSpec(&adopted.Spec.PodTemplate.Spec, claim) // Update uses optimistic concurrency (resourceVersion) so concurrent // claims racing to adopt the same sandbox will conflict and retry. @@ -520,6 +561,7 @@ func (r *SandboxClaimReconciler) createSandbox(ctx context.Context, claim *exten sandbox.Annotations[v1alpha1.SandboxTemplateRefAnnotation] = template.Name template.Spec.PodTemplate.DeepCopyInto(&sandbox.Spec.PodTemplate) + applyClaimWorkspaceResourcesToPodSpec(&sandbox.Spec.PodTemplate.Spec, claim) // TODO: this is a workaround, remove replica assignment related issue #202 replicas := int32(1) sandbox.Spec.Replicas = &replicas diff --git a/extensions/controllers/sandboxclaim_controller_test.go b/extensions/controllers/sandboxclaim_controller_test.go index 26932a9f8..4a4c13603 100644 --- a/extensions/controllers/sandboxclaim_controller_test.go +++ b/extensions/controllers/sandboxclaim_controller_test.go @@ -27,6 +27,7 @@ import ( corev1 "k8s.io/api/core/v1" networkingv1 "k8s.io/api/networking/v1" k8errors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -1132,6 +1133,282 @@ func TestSandboxClaimNoReAdoption(t *testing.T) { } } +func TestSandboxClaimCreateAppliesWorkspaceResources(t *testing.T) { + scheme := newScheme(t) + + template := &extensionsv1alpha1.SandboxTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "test-template", Namespace: "default"}, + Spec: extensionsv1alpha1.SandboxTemplateSpec{ + PodTemplate: sandboxv1alpha1.PodTemplate{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "workspace", Image: "workspace:latest"}, + {Name: "codewire-sidecar", Image: "sidecar:latest"}, + }, + }, + }, + }, + } + + claim := &extensionsv1alpha1.SandboxClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "test-claim", Namespace: "default", UID: "claim-uid"}, + Spec: extensionsv1alpha1.SandboxClaimSpec{ + TemplateRef: extensionsv1alpha1.SandboxTemplateRef{Name: "test-template"}, + WorkspaceResources: &extensionsv1alpha1.WorkspaceResources{ + CPUMillicores: 2000, + MemoryMB: 4096, + DiskGB: 20, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(template, claim). + WithStatusSubresource(claim). + Build() + + reconciler := &SandboxClaimReconciler{ + Client: fakeClient, + Scheme: scheme, + Recorder: record.NewFakeRecorder(10), + Tracer: asmetrics.NewNoOp(), + } + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: claim.Name, Namespace: claim.Namespace}} + if _, err := reconciler.Reconcile(context.Background(), req); err != nil { + t.Fatalf("reconcile failed: %v", err) + } + + var sandbox sandboxv1alpha1.Sandbox + if err := fakeClient.Get(context.Background(), req.NamespacedName, &sandbox); err != nil { + t.Fatalf("failed to get created sandbox: %v", err) + } + + var workspace, sidecar *corev1.Container + for i := range sandbox.Spec.PodTemplate.Spec.Containers { + container := &sandbox.Spec.PodTemplate.Spec.Containers[i] + switch container.Name { + case "workspace": + workspace = container + case "codewire-sidecar": + sidecar = container + } + } + if workspace == nil { + t.Fatal("workspace container not found in created sandbox") + } + if sidecar == nil { + t.Fatal("sidecar container not found in created sandbox") + } + + if got := workspace.Resources.Requests[corev1.ResourceCPU]; got.Cmp(resource.MustParse("2000m")) != 0 { + t.Fatalf("expected workspace CPU request 2000m, got %s", got.String()) + } + if got := workspace.Resources.Limits[corev1.ResourceCPU]; got.Cmp(resource.MustParse("2000m")) != 0 { + t.Fatalf("expected workspace CPU limit 2000m, got %s", got.String()) + } + if got := workspace.Resources.Requests[corev1.ResourceMemory]; got.Cmp(resource.MustParse("4096Mi")) != 0 { + t.Fatalf("expected workspace memory request 4096Mi, got %s", got.String()) + } + if got := workspace.Resources.Limits[corev1.ResourceMemory]; got.Cmp(resource.MustParse("4096Mi")) != 0 { + t.Fatalf("expected workspace memory limit 4096Mi, got %s", got.String()) + } + if got := workspace.Resources.Requests[corev1.ResourceEphemeralStorage]; got.Cmp(resource.MustParse("20Gi")) != 0 { + t.Fatalf("expected workspace disk request 20Gi, got %s", got.String()) + } + if got := workspace.Resources.Limits[corev1.ResourceEphemeralStorage]; got.Cmp(resource.MustParse("20Gi")) != 0 { + t.Fatalf("expected workspace disk limit 20Gi, got %s", got.String()) + } + if len(sidecar.Resources.Requests) != 0 || len(sidecar.Resources.Limits) != 0 { + t.Fatalf("expected sidecar resources to remain untouched, got requests=%v limits=%v", sidecar.Resources.Requests, sidecar.Resources.Limits) + } +} + +func TestSandboxClaimCreateIgnoresWorkspaceResourcesWithoutWorkspaceContainer(t *testing.T) { + scheme := newScheme(t) + + template := &extensionsv1alpha1.SandboxTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "test-template", Namespace: "default"}, + Spec: extensionsv1alpha1.SandboxTemplateSpec{ + PodTemplate: sandboxv1alpha1.PodTemplate{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "pause", Image: "registry.k8s.io/pause:3.10"}, + }, + }, + }, + }, + } + + claim := &extensionsv1alpha1.SandboxClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "test-claim", Namespace: "default", UID: "claim-uid"}, + Spec: extensionsv1alpha1.SandboxClaimSpec{ + TemplateRef: extensionsv1alpha1.SandboxTemplateRef{Name: "test-template"}, + WorkspaceResources: &extensionsv1alpha1.WorkspaceResources{ + CPUMillicores: 2000, + MemoryMB: 4096, + DiskGB: 20, + }, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(template, claim). + WithStatusSubresource(claim). + Build() + + reconciler := &SandboxClaimReconciler{ + Client: fakeClient, + Scheme: scheme, + Recorder: record.NewFakeRecorder(10), + Tracer: asmetrics.NewNoOp(), + } + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: claim.Name, Namespace: claim.Namespace}} + if _, err := reconciler.Reconcile(context.Background(), req); err != nil { + t.Fatalf("reconcile failed: %v", err) + } + + var sandbox sandboxv1alpha1.Sandbox + if err := fakeClient.Get(context.Background(), req.NamespacedName, &sandbox); err != nil { + t.Fatalf("failed to get created sandbox: %v", err) + } + + if len(sandbox.Spec.PodTemplate.Spec.Containers) != 1 { + t.Fatalf("expected one container, got %d", len(sandbox.Spec.PodTemplate.Spec.Containers)) + } + container := sandbox.Spec.PodTemplate.Spec.Containers[0] + if container.Name != "pause" { + t.Fatalf("expected pause container, got %q", container.Name) + } + if len(container.Resources.Requests) != 0 || len(container.Resources.Limits) != 0 { + t.Fatalf("expected non-workspace container resources to remain untouched, got requests=%v limits=%v", container.Resources.Requests, container.Resources.Limits) + } +} + +func TestSandboxClaimAdoptionAppliesWorkspaceResources(t *testing.T) { + scheme := newScheme(t) + + template := &extensionsv1alpha1.SandboxTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "test-template", Namespace: "default"}, + Spec: extensionsv1alpha1.SandboxTemplateSpec{ + PodTemplate: sandboxv1alpha1.PodTemplate{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "workspace", Image: "workspace:latest"}, + {Name: "codewire-sidecar", Image: "sidecar:latest"}, + }, + }, + }, + }, + } + + claim := &extensionsv1alpha1.SandboxClaim{ + ObjectMeta: metav1.ObjectMeta{Name: "test-claim", Namespace: "default", UID: "claim-uid"}, + Spec: extensionsv1alpha1.SandboxClaimSpec{ + TemplateRef: extensionsv1alpha1.SandboxTemplateRef{Name: "test-template"}, + WorkspaceResources: &extensionsv1alpha1.WorkspaceResources{ + CPUMillicores: 2000, + MemoryMB: 4096, + DiskGB: 20, + }, + }, + } + + warmSandbox := &sandboxv1alpha1.Sandbox{ + ObjectMeta: metav1.ObjectMeta{ + Name: "warm-sb", + Namespace: "default", + Labels: map[string]string{ + warmPoolSandboxLabel: sandboxcontrollers.NameHash("test-pool"), + sandboxTemplateRefHash: sandboxcontrollers.NameHash("test-template"), + }, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "extensions.agents.x-k8s.io/v1alpha1", + Kind: "SandboxWarmPool", + Name: "test-pool", + UID: "pool-uid", + Controller: ptr.To(true), + }, + }, + }, + Spec: sandboxv1alpha1.SandboxSpec{ + Replicas: ptr.To(int32(1)), + PodTemplate: sandboxv1alpha1.PodTemplate{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "workspace", Image: "workspace:latest"}, + {Name: "codewire-sidecar", Image: "sidecar:latest"}, + }, + }, + }, + }, + Status: sandboxv1alpha1.SandboxStatus{ + Conditions: []metav1.Condition{{ + Type: string(sandboxv1alpha1.SandboxConditionReady), + Status: metav1.ConditionTrue, + Reason: "Ready", + }}, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(template, claim, warmSandbox). + WithStatusSubresource(claim). + Build() + + reconciler := &SandboxClaimReconciler{ + Client: fakeClient, + Scheme: scheme, + Recorder: record.NewFakeRecorder(10), + Tracer: asmetrics.NewNoOp(), + } + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: claim.Name, Namespace: claim.Namespace}} + if _, err := reconciler.Reconcile(context.Background(), req); err != nil { + t.Fatalf("reconcile failed: %v", err) + } + + var adopted sandboxv1alpha1.Sandbox + if err := fakeClient.Get(context.Background(), types.NamespacedName{Name: "warm-sb", Namespace: "default"}, &adopted); err != nil { + t.Fatalf("failed to get adopted sandbox: %v", err) + } + + var workspace, sidecar *corev1.Container + for i := range adopted.Spec.PodTemplate.Spec.Containers { + container := &adopted.Spec.PodTemplate.Spec.Containers[i] + switch container.Name { + case "workspace": + workspace = container + case "codewire-sidecar": + sidecar = container + } + } + if workspace == nil { + t.Fatal("workspace container not found in adopted sandbox") + } + if sidecar == nil { + t.Fatal("sidecar container not found in adopted sandbox") + } + + if got := workspace.Resources.Requests[corev1.ResourceCPU]; got.Cmp(resource.MustParse("2000m")) != 0 { + t.Fatalf("expected adopted workspace CPU request 2000m, got %s", got.String()) + } + if got := workspace.Resources.Requests[corev1.ResourceMemory]; got.Cmp(resource.MustParse("4096Mi")) != 0 { + t.Fatalf("expected adopted workspace memory request 4096Mi, got %s", got.String()) + } + if got := workspace.Resources.Requests[corev1.ResourceEphemeralStorage]; got.Cmp(resource.MustParse("20Gi")) != 0 { + t.Fatalf("expected adopted workspace disk request 20Gi, got %s", got.String()) + } + if len(sidecar.Resources.Requests) != 0 || len(sidecar.Resources.Limits) != 0 { + t.Fatalf("expected adopted sidecar resources to remain untouched, got requests=%v limits=%v", sidecar.Resources.Requests, sidecar.Resources.Limits) + } +} + func TestRecordCreationLatencyMetric(t *testing.T) { ctx := context.Background() pastTime := metav1.Time{Time: time.Now().Add(-10 * time.Second)} diff --git a/k8s/crds/extensions.agents.x-k8s.io_sandboxclaims.yaml b/k8s/crds/extensions.agents.x-k8s.io_sandboxclaims.yaml index b7615afe0..20b1ed801 100644 --- a/k8s/crds/extensions.agents.x-k8s.io_sandboxclaims.yaml +++ b/k8s/crds/extensions.agents.x-k8s.io_sandboxclaims.yaml @@ -51,6 +51,18 @@ spec: warmpool: default: default type: string + workspaceResources: + properties: + cpuMillicores: + format: int32 + type: integer + diskGB: + format: int32 + type: integer + memoryMB: + format: int32 + type: integer + type: object required: - sandboxTemplateRef type: object From fa34c144f7329950c9edd4a5db48b2274e094f28 Mon Sep 17 00:00:00 2001 From: Noel Date: Mon, 23 Mar 2026 13:03:07 +0100 Subject: [PATCH 2/4] fix: preserve pod template annotations during warm adoption --- controllers/sandbox_controller.go | 9 ++++ controllers/sandbox_controller_test.go | 16 ++++++- .../controllers/sandboxclaim_controller.go | 43 ++++++++++++++++--- .../sandboxclaim_controller_test.go | 27 +++++++++--- 4 files changed, 84 insertions(+), 11 deletions(-) diff --git a/controllers/sandbox_controller.go b/controllers/sandbox_controller.go index f6d43d457..7f5626845 100644 --- a/controllers/sandbox_controller.go +++ b/controllers/sandbox_controller.go @@ -451,6 +451,9 @@ func (r *SandboxReconciler) reconcilePod(ctx context.Context, sandbox *sandboxv1 if pod.Labels == nil { pod.Labels = make(map[string]string) } + if pod.Annotations == nil { + pod.Annotations = make(map[string]string) + } changed := false if pod.Labels[sandboxLabel] != nameHash { pod.Labels[sandboxLabel] = nameHash @@ -463,6 +466,12 @@ func (r *SandboxReconciler) reconcilePod(ctx context.Context, sandbox *sandboxv1 changed = true } } + for k, v := range sandbox.Spec.PodTemplate.ObjectMeta.Annotations { + if pod.Annotations[k] != v { + pod.Annotations[k] = v + changed = true + } + } // Set controller reference if the pod is not controlled by anything. if controllerRef := metav1.GetControllerOf(pod); controllerRef == nil { diff --git a/controllers/sandbox_controller_test.go b/controllers/sandbox_controller_test.go index a9209707a..eac8780ae 100644 --- a/controllers/sandbox_controller_test.go +++ b/controllers/sandbox_controller_test.go @@ -620,6 +620,9 @@ func TestReconcilePod(t *testing.T) { "agents.x-k8s.io/sandbox-name-hash": nameHash, "custom-label": "label-val", }, + Annotations: map[string]string{ + "custom-annotation": "anno-val", + }, OwnerReferences: []metav1.OwnerReference{sandboxControllerRef(sandboxName)}, }, Spec: corev1.PodSpec{ @@ -699,7 +702,7 @@ func TestReconcilePod(t *testing.T) { wantPod: nil, }, { - name: "adopts existing pod via annotation - pod gets label and owner reference", + name: "adopts existing pod via annotation - pod gets metadata and owner reference", initialObjs: []runtime.Object{ &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ @@ -727,6 +730,11 @@ func TestReconcilePod(t *testing.T) { Spec: sandboxv1alpha1.SandboxSpec{ Replicas: ptr.To(int32(1)), PodTemplate: sandboxv1alpha1.PodTemplate{ + ObjectMeta: sandboxv1alpha1.PodMetadata{ + Annotations: map[string]string{ + "example.com/workspace": "true", + }, + }, Spec: corev1.PodSpec{ Containers: []corev1.Container{ { @@ -745,6 +753,9 @@ func TestReconcilePod(t *testing.T) { Labels: map[string]string{ sandboxLabel: nameHash, }, + Annotations: map[string]string{ + "example.com/workspace": "true", + }, OwnerReferences: []metav1.OwnerReference{sandboxControllerRef(sandboxName)}, }, Spec: corev1.PodSpec{ @@ -797,6 +808,9 @@ func TestReconcilePod(t *testing.T) { "agents.x-k8s.io/sandbox-name-hash": nameHash, "custom-label": "label-val", }, + Annotations: map[string]string{ + "custom-annotation": "anno-val", + }, // Should still have the original controller reference OwnerReferences: []metav1.OwnerReference{ { diff --git a/extensions/controllers/sandboxclaim_controller.go b/extensions/controllers/sandboxclaim_controller.go index c909b4c07..734f36b62 100644 --- a/extensions/controllers/sandboxclaim_controller.go +++ b/extensions/controllers/sandboxclaim_controller.go @@ -388,17 +388,17 @@ func applyWorkspaceResourceOverrides(container *corev1.Container, overrides *ext container.Resources.Limits = corev1.ResourceList{} } if overrides.CPUMillicores > 0 { - qty := resource.MustParse(fmt.Sprintf("%dm", overrides.CPUMillicores)) + qty := *resource.NewMilliQuantity(int64(overrides.CPUMillicores), resource.DecimalSI) container.Resources.Requests[corev1.ResourceCPU] = qty container.Resources.Limits[corev1.ResourceCPU] = qty } if overrides.MemoryMB > 0 { - qty := resource.MustParse(fmt.Sprintf("%dMi", overrides.MemoryMB)) + qty := *resource.NewQuantity(int64(overrides.MemoryMB)*1024*1024, resource.BinarySI) container.Resources.Requests[corev1.ResourceMemory] = qty container.Resources.Limits[corev1.ResourceMemory] = qty } if overrides.DiskGB > 0 { - qty := resource.MustParse(fmt.Sprintf("%dGi", overrides.DiskGB)) + qty := *resource.NewQuantity(int64(overrides.DiskGB)*1024*1024*1024, resource.BinarySI) container.Resources.Requests[corev1.ResourceEphemeralStorage] = qty container.Resources.Limits[corev1.ResourceEphemeralStorage] = qty } @@ -416,8 +416,32 @@ func applyClaimWorkspaceResourcesToPodSpec(spec *corev1.PodSpec, claim *extensio applyWorkspaceResourceOverrides(container, claim.Spec.WorkspaceResources) } } + +func mergeTemplatePodMetadata(target *v1alpha1.PodMetadata, template v1alpha1.PodMetadata) { + if len(template.Labels) > 0 { + if target.Labels == nil { + target.Labels = make(map[string]string, len(template.Labels)) + } + for k, v := range template.Labels { + if _, exists := target.Labels[k]; !exists { + target.Labels[k] = v + } + } + } + if len(template.Annotations) > 0 { + if target.Annotations == nil { + target.Annotations = make(map[string]string, len(template.Annotations)) + } + for k, v := range template.Annotations { + if _, exists := target.Annotations[k]; !exists { + target.Annotations[k] = v + } + } + } +} + // adoptSandboxFromCandidates picks the best candidate and transfers ownership to the claim. -func (r *SandboxClaimReconciler) adoptSandboxFromCandidates(ctx context.Context, claim *extensionsv1alpha1.SandboxClaim, candidates []*v1alpha1.Sandbox) (*v1alpha1.Sandbox, error) { +func (r *SandboxClaimReconciler) adoptSandboxFromCandidates(ctx context.Context, claim *extensionsv1alpha1.SandboxClaim, template *extensionsv1alpha1.SandboxTemplate, candidates []*v1alpha1.Sandbox) (*v1alpha1.Sandbox, error) { logger := log.FromContext(ctx) // Sort: ready sandboxes first, then by creation time (oldest first) @@ -481,6 +505,10 @@ func (r *SandboxClaimReconciler) adoptSandboxFromCandidates(ctx context.Context, adopted.Annotations[asmetrics.TraceContextAnnotation] = tc } + if template != nil { + mergeTemplatePodMetadata(&adopted.Spec.PodTemplate.ObjectMeta, template.Spec.PodTemplate.ObjectMeta) + } + // Add sandbox ID label to pod template for NetworkPolicy targeting if adopted.Spec.PodTemplate.ObjectMeta.Labels == nil { adopted.Spec.PodTemplate.ObjectMeta.Labels = make(map[string]string) @@ -715,7 +743,12 @@ func (r *SandboxClaimReconciler) getOrCreateSandbox(ctx context.Context, claim * // Try to adopt from warm pool if len(adoptionCandidates) > 0 { logger.V(1).Info("Found warm pool adoption candidates", "count", len(adoptionCandidates), "claim", claim.Name, "warmpool", policy) - adopted, err := r.adoptSandboxFromCandidates(ctx, claim, adoptionCandidates) + var template *extensionsv1alpha1.SandboxTemplate + template, err := r.getTemplate(ctx, claim) + if err != nil && !k8errors.IsNotFound(err) { + return nil, err + } + adopted, err := r.adoptSandboxFromCandidates(ctx, claim, template, adoptionCandidates) if err != nil { return nil, err } diff --git a/extensions/controllers/sandboxclaim_controller_test.go b/extensions/controllers/sandboxclaim_controller_test.go index 4a4c13603..0792ca29f 100644 --- a/extensions/controllers/sandboxclaim_controller_test.go +++ b/extensions/controllers/sandboxclaim_controller_test.go @@ -1143,7 +1143,7 @@ func TestSandboxClaimCreateAppliesWorkspaceResources(t *testing.T) { Spec: corev1.PodSpec{ Containers: []corev1.Container{ {Name: "workspace", Image: "workspace:latest"}, - {Name: "codewire-sidecar", Image: "sidecar:latest"}, + {Name: "extra-sidecar", Image: "sidecar:latest"}, }, }, }, @@ -1191,7 +1191,7 @@ func TestSandboxClaimCreateAppliesWorkspaceResources(t *testing.T) { switch container.Name { case "workspace": workspace = container - case "codewire-sidecar": + case "extra-sidecar": sidecar = container } } @@ -1295,10 +1295,16 @@ func TestSandboxClaimAdoptionAppliesWorkspaceResources(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "test-template", Namespace: "default"}, Spec: extensionsv1alpha1.SandboxTemplateSpec{ PodTemplate: sandboxv1alpha1.PodTemplate{ + ObjectMeta: sandboxv1alpha1.PodMetadata{ + Annotations: map[string]string{ + "example.com/workspace": "true", + "test-annotation": "template", + }, + }, Spec: corev1.PodSpec{ Containers: []corev1.Container{ {Name: "workspace", Image: "workspace:latest"}, - {Name: "codewire-sidecar", Image: "sidecar:latest"}, + {Name: "extra-sidecar", Image: "sidecar:latest"}, }, }, }, @@ -1338,10 +1344,15 @@ func TestSandboxClaimAdoptionAppliesWorkspaceResources(t *testing.T) { Spec: sandboxv1alpha1.SandboxSpec{ Replicas: ptr.To(int32(1)), PodTemplate: sandboxv1alpha1.PodTemplate{ + ObjectMeta: sandboxv1alpha1.PodMetadata{ + Annotations: map[string]string{ + "example.com/workspace": "true", + }, + }, Spec: corev1.PodSpec{ Containers: []corev1.Container{ {Name: "workspace", Image: "workspace:latest"}, - {Name: "codewire-sidecar", Image: "sidecar:latest"}, + {Name: "extra-sidecar", Image: "sidecar:latest"}, }, }, }, @@ -1384,7 +1395,7 @@ func TestSandboxClaimAdoptionAppliesWorkspaceResources(t *testing.T) { switch container.Name { case "workspace": workspace = container - case "codewire-sidecar": + case "extra-sidecar": sidecar = container } } @@ -1407,6 +1418,12 @@ func TestSandboxClaimAdoptionAppliesWorkspaceResources(t *testing.T) { if len(sidecar.Resources.Requests) != 0 || len(sidecar.Resources.Limits) != 0 { t.Fatalf("expected adopted sidecar resources to remain untouched, got requests=%v limits=%v", sidecar.Resources.Requests, sidecar.Resources.Limits) } + if got := adopted.Spec.PodTemplate.ObjectMeta.Annotations["example.com/workspace"]; got != "true" { + t.Fatalf("expected adopted workspace annotation to survive, got %q", got) + } + if got := adopted.Spec.PodTemplate.ObjectMeta.Annotations["test-annotation"]; got != "template" { + t.Fatalf("expected template annotation to be restored on adoption, got %q", got) + } } func TestRecordCreationLatencyMetric(t *testing.T) { From c4e38c975101998a7a986ef3fa51111a03a8e509 Mon Sep 17 00:00:00 2001 From: Noel Date: Sat, 28 Mar 2026 22:31:53 +0100 Subject: [PATCH 3/4] feat: in-place resize of workspace resources on running sandboxes When SandboxClaim.spec.workspaceResources is updated on a claim with an existing running sandbox, the controller now patches the pod's container resources in-place. On Kubernetes 1.27+ with InPlacePodVerticalScaling, this triggers the kubelet to call UpdateContainerResources on the container runtime, resizing the workload without restart. Previously, resource changes on existing claims were silently ignored ('sandbox already exists, skipping update'). --- .../controllers/sandboxclaim_controller.go | 88 ++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/extensions/controllers/sandboxclaim_controller.go b/extensions/controllers/sandboxclaim_controller.go index 734f36b62..776ac7837 100644 --- a/extensions/controllers/sandboxclaim_controller.go +++ b/extensions/controllers/sandboxclaim_controller.go @@ -417,6 +417,89 @@ func applyClaimWorkspaceResourcesToPodSpec(spec *corev1.PodSpec, claim *extensio } } +// reconcileWorkspaceResources patches the pod's workspace container resources +// in-place if the claim's WorkspaceResources differ from the pod's current values. +// This triggers Kubernetes InPlacePodVerticalScaling (K8s 1.27+). +func (r *SandboxClaimReconciler) reconcileWorkspaceResources(ctx context.Context, sandbox *v1alpha1.Sandbox, claim *extensionsv1alpha1.SandboxClaim) error { + if claim.Spec.WorkspaceResources == nil { + return nil + } + logger := log.FromContext(ctx) + + // Find the pod owned by this sandbox. + pod := &corev1.Pod{} + if err := r.Get(ctx, client.ObjectKey{Namespace: sandbox.Namespace, Name: sandbox.Name}, pod); err != nil { + return nil // Pod may not exist yet (still starting). + } + if pod.Status.Phase != corev1.PodRunning { + return nil // Only resize running pods. + } + + for i, c := range pod.Spec.Containers { + if c.Name != "workspace" { + continue + } + patch := buildResizePatch(c.Resources, claim.Spec.WorkspaceResources) + if patch == nil { + return nil // No change needed. + } + + // Patch pod resources in-place. On K8s 1.27+ with InPlacePodVerticalScaling, + // the kubelet detects the resource change and calls UpdateContainerResources + // on the container runtime (e.g., isol8-runtime update). + podPatch := pod.DeepCopy() + podPatch.Spec.Containers[i].Resources = *patch + if err := r.Patch(ctx, podPatch, client.StrategicMergeFrom(pod)); err != nil { + return fmt.Errorf("patch pod resources: %w", err) + } + logger.Info("resized workspace container", "pod", pod.Name, + "cpuMillicores", claim.Spec.WorkspaceResources.CPUMillicores, + "memoryMB", claim.Spec.WorkspaceResources.MemoryMB) + return nil + } + return nil +} + +// buildResizePatch compares current container resources with the desired +// WorkspaceResources and returns updated ResourceRequirements if they differ. +// Returns nil if no change is needed. DiskGB is intentionally excluded +// because ephemeral storage cannot be resized in-place. +func buildResizePatch(current corev1.ResourceRequirements, desired *extensionsv1alpha1.WorkspaceResources) *corev1.ResourceRequirements { + target := corev1.ResourceRequirements{ + Requests: current.Requests.DeepCopy(), + Limits: current.Limits.DeepCopy(), + } + if target.Requests == nil { + target.Requests = corev1.ResourceList{} + } + if target.Limits == nil { + target.Limits = corev1.ResourceList{} + } + changed := false + + if desired.CPUMillicores > 0 { + qty := *resource.NewMilliQuantity(int64(desired.CPUMillicores), resource.DecimalSI) + if !current.Limits[corev1.ResourceCPU].Equal(qty) { + target.Requests[corev1.ResourceCPU] = qty + target.Limits[corev1.ResourceCPU] = qty + changed = true + } + } + if desired.MemoryMB > 0 { + qty := *resource.NewQuantity(int64(desired.MemoryMB)*1024*1024, resource.BinarySI) + if !current.Limits[corev1.ResourceMemory].Equal(qty) { + target.Requests[corev1.ResourceMemory] = qty + target.Limits[corev1.ResourceMemory] = qty + changed = true + } + } + + if !changed { + return nil + } + return &target +} + func mergeTemplatePodMetadata(target *v1alpha1.PodMetadata, template v1alpha1.PodMetadata) { if len(template.Labels) > 0 { if target.Labels == nil { @@ -674,12 +757,15 @@ func (r *SandboxClaimReconciler) getOrCreateSandbox(ctx context.Context, claim * } if sandbox != nil { - logger.Info("sandbox already exists, skipping update", "name", sandbox.Name) if !metav1.IsControlledBy(sandbox, claim) { err := fmt.Errorf("sandbox %q is not controlled by claim %q. Please use a different claim name or delete the sandbox manually", sandbox.Name, claim.Name) logger.Error(err, "Sandbox controller mismatch") return nil, err } + // Reconcile workspace resources on the existing pod (in-place resize). + if err := r.reconcileWorkspaceResources(ctx, sandbox, claim); err != nil { + logger.Error(err, "failed to reconcile workspace resources") + } return sandbox, nil } From 7dd1b106900a1cd1863292ec4222b94768908612 Mon Sep 17 00:00:00 2001 From: Noel Date: Fri, 3 Apr 2026 14:30:41 +0200 Subject: [PATCH 4/4] refactor: scope PR to extensions/ only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove Sandbox→Pod annotation propagation from sandbox_controller.go per maintainer feedback. The in-place annotation/resource propagation is a sig-apps design question and will be a separate follow-up. This PR now only touches extensions/ (SandboxClaim workspaceResources). --- controllers/sandbox_controller.go | 9 ------ controllers/sandbox_controller_test.go | 16 +--------- .../api/v1alpha1/zz_generated.deepcopy.go | 30 +++++++++---------- .../sandboxclaim_controller_test.go | 2 +- 4 files changed, 17 insertions(+), 40 deletions(-) diff --git a/controllers/sandbox_controller.go b/controllers/sandbox_controller.go index 7f5626845..f6d43d457 100644 --- a/controllers/sandbox_controller.go +++ b/controllers/sandbox_controller.go @@ -451,9 +451,6 @@ func (r *SandboxReconciler) reconcilePod(ctx context.Context, sandbox *sandboxv1 if pod.Labels == nil { pod.Labels = make(map[string]string) } - if pod.Annotations == nil { - pod.Annotations = make(map[string]string) - } changed := false if pod.Labels[sandboxLabel] != nameHash { pod.Labels[sandboxLabel] = nameHash @@ -466,12 +463,6 @@ func (r *SandboxReconciler) reconcilePod(ctx context.Context, sandbox *sandboxv1 changed = true } } - for k, v := range sandbox.Spec.PodTemplate.ObjectMeta.Annotations { - if pod.Annotations[k] != v { - pod.Annotations[k] = v - changed = true - } - } // Set controller reference if the pod is not controlled by anything. if controllerRef := metav1.GetControllerOf(pod); controllerRef == nil { diff --git a/controllers/sandbox_controller_test.go b/controllers/sandbox_controller_test.go index eac8780ae..a9209707a 100644 --- a/controllers/sandbox_controller_test.go +++ b/controllers/sandbox_controller_test.go @@ -620,9 +620,6 @@ func TestReconcilePod(t *testing.T) { "agents.x-k8s.io/sandbox-name-hash": nameHash, "custom-label": "label-val", }, - Annotations: map[string]string{ - "custom-annotation": "anno-val", - }, OwnerReferences: []metav1.OwnerReference{sandboxControllerRef(sandboxName)}, }, Spec: corev1.PodSpec{ @@ -702,7 +699,7 @@ func TestReconcilePod(t *testing.T) { wantPod: nil, }, { - name: "adopts existing pod via annotation - pod gets metadata and owner reference", + name: "adopts existing pod via annotation - pod gets label and owner reference", initialObjs: []runtime.Object{ &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ @@ -730,11 +727,6 @@ func TestReconcilePod(t *testing.T) { Spec: sandboxv1alpha1.SandboxSpec{ Replicas: ptr.To(int32(1)), PodTemplate: sandboxv1alpha1.PodTemplate{ - ObjectMeta: sandboxv1alpha1.PodMetadata{ - Annotations: map[string]string{ - "example.com/workspace": "true", - }, - }, Spec: corev1.PodSpec{ Containers: []corev1.Container{ { @@ -753,9 +745,6 @@ func TestReconcilePod(t *testing.T) { Labels: map[string]string{ sandboxLabel: nameHash, }, - Annotations: map[string]string{ - "example.com/workspace": "true", - }, OwnerReferences: []metav1.OwnerReference{sandboxControllerRef(sandboxName)}, }, Spec: corev1.PodSpec{ @@ -808,9 +797,6 @@ func TestReconcilePod(t *testing.T) { "agents.x-k8s.io/sandbox-name-hash": nameHash, "custom-label": "label-val", }, - Annotations: map[string]string{ - "custom-annotation": "anno-val", - }, // Should still have the original controller reference OwnerReferences: []metav1.OwnerReference{ { diff --git a/extensions/api/v1alpha1/zz_generated.deepcopy.go b/extensions/api/v1alpha1/zz_generated.deepcopy.go index ef782d7e0..8633e4bac 100644 --- a/extensions/api/v1alpha1/zz_generated.deepcopy.go +++ b/extensions/api/v1alpha1/zz_generated.deepcopy.go @@ -296,21 +296,6 @@ func (in *SandboxTemplateStatus) DeepCopy() *SandboxTemplateStatus { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *WorkspaceResources) DeepCopyInto(out *WorkspaceResources) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkspaceResources. -func (in *WorkspaceResources) DeepCopy() *WorkspaceResources { - if in == nil { - return nil - } - out := new(WorkspaceResources) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SandboxWarmPool) DeepCopyInto(out *SandboxWarmPool) { *out = *in @@ -400,3 +385,18 @@ func (in *SandboxWarmPoolStatus) DeepCopy() *SandboxWarmPoolStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkspaceResources) DeepCopyInto(out *WorkspaceResources) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkspaceResources. +func (in *WorkspaceResources) DeepCopy() *WorkspaceResources { + if in == nil { + return nil + } + out := new(WorkspaceResources) + in.DeepCopyInto(out) + return out +} diff --git a/extensions/controllers/sandboxclaim_controller_test.go b/extensions/controllers/sandboxclaim_controller_test.go index 0792ca29f..4a71dc826 100644 --- a/extensions/controllers/sandboxclaim_controller_test.go +++ b/extensions/controllers/sandboxclaim_controller_test.go @@ -1298,7 +1298,7 @@ func TestSandboxClaimAdoptionAppliesWorkspaceResources(t *testing.T) { ObjectMeta: sandboxv1alpha1.PodMetadata{ Annotations: map[string]string{ "example.com/workspace": "true", - "test-annotation": "template", + "test-annotation": "template", }, }, Spec: corev1.PodSpec{