-
Notifications
You must be signed in to change notification settings - Fork 265
Add default resource limits for MCPRemoteProxy #5998
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -24,6 +24,7 @@ | |||
| appsv1 "k8s.io/api/apps/v1" | ||||
| corev1 "k8s.io/api/core/v1" | ||||
| rbacv1 "k8s.io/api/rbac/v1" | ||||
| "k8s.io/apimachinery/pkg/api/resource" | ||||
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||||
| "k8s.io/apimachinery/pkg/runtime" | ||||
| "k8s.io/apimachinery/pkg/types" | ||||
|
|
@@ -79,6 +80,12 @@ | |||
| // Verify service account | ||||
| assert.Equal(t, proxyRunnerServiceAccountNameForRemoteProxy("basic-proxy"), | ||||
| dep.Spec.Template.Spec.ServiceAccountName) | ||||
|
|
||||
| // Verify default resource requirements when Spec.Resources is empty | ||||
| assert.Equal(t, resource.MustParse("50m"), container.Resources.Requests[corev1.ResourceCPU]) | ||||
| assert.Equal(t, resource.MustParse("64Mi"), container.Resources.Requests[corev1.ResourceMemory]) | ||||
| assert.Equal(t, resource.MustParse("200m"), container.Resources.Limits[corev1.ResourceCPU]) | ||||
| assert.Equal(t, resource.MustParse("256Mi"), container.Resources.Limits[corev1.ResourceMemory]) | ||||
| }, | ||||
| }, | ||||
| { | ||||
|
|
@@ -106,6 +113,30 @@ | |||
| assert.Equal(t, "128Mi", container.Resources.Requests.Memory().String()) | ||||
| }, | ||||
| }, | ||||
| { | ||||
| name: "with partial resource overrides", | ||||
| proxy: v1beta1test.NewMCPRemoteProxy("partial-resources-proxy", "default", | ||||
| v1beta1test.MutateRemoteProxy(func(p *mcpv1beta1.MCPRemoteProxy) { | ||||
| p.Spec.Resources = mcpv1beta1.ResourceRequirements{ | ||||
| Limits: mcpv1beta1.ResourceList{ | ||||
| CPU: "1", | ||||
| }, | ||||
| Requests: mcpv1beta1.ResourceList{ | ||||
| Memory: "128Mi", | ||||
| }, | ||||
| } | ||||
| }), | ||||
| ), | ||||
| validate: func(t *testing.T, dep *appsv1.Deployment) { | ||||
| t.Helper() | ||||
| container := dep.Spec.Template.Spec.Containers[0] | ||||
| // User-provided fields take precedence; missing fields keep defaults. | ||||
| assert.Equal(t, resource.MustParse("50m"), container.Resources.Requests[corev1.ResourceCPU]) | ||||
| assert.Equal(t, resource.MustParse("128Mi"), container.Resources.Requests[corev1.ResourceMemory]) | ||||
| assert.Equal(t, resource.MustParse("1"), container.Resources.Limits[corev1.ResourceCPU]) | ||||
| assert.Equal(t, resource.MustParse("256Mi"), container.Resources.Limits[corev1.ResourceMemory]) | ||||
| }, | ||||
| }, | ||||
| { | ||||
| name: "with resource overrides", | ||||
| proxy: v1beta1test.NewMCPRemoteProxy("override-proxy", "default", | ||||
|
|
@@ -333,6 +364,84 @@ | |||
| } | ||||
| } | ||||
|
|
||||
|
|
||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MEDIUM · consensus 9/10]
Suggested change
|
||||
| // TestResourceRequirementsForRemoteProxy tests default injection and merge behavior | ||||
| func TestResourceRequirementsForRemoteProxy(t *testing.T) { | ||||
| t.Parallel() | ||||
|
|
||||
| tests := []struct { | ||||
| name string | ||||
| proxy *mcpv1beta1.MCPRemoteProxy | ||||
| validate func(*testing.T, corev1.ResourceRequirements) | ||||
| }{ | ||||
| { | ||||
| name: "defaults when resources omitted", | ||||
| proxy: v1beta1test.NewMCPRemoteProxy("defaults-proxy", "default"), | ||||
| validate: func(t *testing.T, res corev1.ResourceRequirements) { | ||||
| t.Helper() | ||||
| assert.Equal(t, resource.MustParse("50m"), res.Requests[corev1.ResourceCPU]) | ||||
| assert.Equal(t, resource.MustParse("64Mi"), res.Requests[corev1.ResourceMemory]) | ||||
| assert.Equal(t, resource.MustParse("200m"), res.Limits[corev1.ResourceCPU]) | ||||
| assert.Equal(t, resource.MustParse("256Mi"), res.Limits[corev1.ResourceMemory]) | ||||
| }, | ||||
| }, | ||||
| { | ||||
| name: "user overrides win over defaults", | ||||
| proxy: v1beta1test.NewMCPRemoteProxy("override-resources-proxy", "default", | ||||
| v1beta1test.MutateRemoteProxy(func(p *mcpv1beta1.MCPRemoteProxy) { | ||||
| p.Spec.Resources = mcpv1beta1.ResourceRequirements{ | ||||
| Limits: mcpv1beta1.ResourceList{ | ||||
| CPU: "1", | ||||
| Memory: "512Mi", | ||||
| }, | ||||
| Requests: mcpv1beta1.ResourceList{ | ||||
| CPU: "100m", | ||||
| Memory: "128Mi", | ||||
| }, | ||||
| } | ||||
| }), | ||||
| ), | ||||
| validate: func(t *testing.T, res corev1.ResourceRequirements) { | ||||
| t.Helper() | ||||
| assert.Equal(t, resource.MustParse("100m"), res.Requests[corev1.ResourceCPU]) | ||||
| assert.Equal(t, resource.MustParse("128Mi"), res.Requests[corev1.ResourceMemory]) | ||||
| assert.Equal(t, resource.MustParse("1"), res.Limits[corev1.ResourceCPU]) | ||||
| assert.Equal(t, resource.MustParse("512Mi"), res.Limits[corev1.ResourceMemory]) | ||||
| }, | ||||
| }, | ||||
| } | ||||
|
|
||||
| for _, tt := range tests { | ||||
| t.Run(tt.name, func(t *testing.T) { | ||||
| t.Parallel() | ||||
| result := resourceRequirementsForRemoteProxy(tt.proxy) | ||||
| if tt.validate != nil { | ||||
| tt.validate(t, result) | ||||
| } | ||||
| }) | ||||
| } | ||||
| } | ||||
|
|
||||
| // TestMCPRemoteProxyDeploymentNeedsUpdate_Resources detects resource drift with defaults | ||||
| func TestMCPRemoteProxyDeploymentNeedsUpdate_Resources(t *testing.T) { | ||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MEDIUM · consensus 7/10] Idempotency test doesn't exercise the path that can break. This test passes the just-built deployment straight into Add a subtest with a non-canonical override (e.g. |
||||
| t.Parallel() | ||||
|
|
||||
| scheme := testutil.NewScheme(t) | ||||
| proxy := v1beta1test.NewMCPRemoteProxy("resources-drift-proxy", "default") | ||||
| reconciler := &MCPRemoteProxyReconciler{ | ||||
| Scheme: scheme, | ||||
| PlatformDetector: ctrlutil.NewSharedPlatformDetector(), | ||||
| } | ||||
|
|
||||
| deployment := reconciler.deploymentForMCPRemoteProxy(t.Context(), proxy, "test-checksum") | ||||
| require.NotNil(t, deployment) | ||||
| assert.False(t, reconciler.deploymentNeedsUpdate(t.Context(), deployment, proxy, "test-checksum")) | ||||
|
|
||||
| // Simulate a deployment created before defaults existed. | ||||
| deployment.Spec.Template.Spec.Containers[0].Resources = corev1.ResourceRequirements{} | ||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MEDIUM · consensus 8/10] Undocumented user-facing change: existing proxies roll and gain a hard memory cap on upgrade. This case (empty Please call this out in the PR's "Does this introduce a user-facing change?" section / release notes, and consider advising operators to audit heavy proxies' memory usage (and pre-set |
||||
| assert.True(t, reconciler.deploymentNeedsUpdate(t.Context(), deployment, proxy, "test-checksum")) | ||||
| } | ||||
|
|
||||
| // TestBuildHeaderForwardSecretEnvVars tests the buildHeaderForwardSecretEnvVars function | ||||
| func TestBuildHeaderForwardSecretEnvVars(t *testing.T) { | ||||
| t.Parallel() | ||||
|
|
||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,15 @@ | |
| "github.com/stacklok/toolhive/pkg/secrets" | ||
| ) | ||
|
|
||
| // Default proxy-runner resource values used when a CR omits resource requests/limits. | ||
| // These keep remote proxy containers bounded while remaining overrideable per CR. | ||
| const ( | ||
| DefaultProxyRunnerCPURequest = "50m" | ||
| DefaultProxyRunnerCPULimit = "200m" | ||
| DefaultProxyRunnerMemoryRequest = "64Mi" | ||
| DefaultProxyRunnerMemoryLimit = "256Mi" | ||
| ) | ||
|
|
||
| // BuildResourceRequirements builds Kubernetes resource requirements from CRD spec | ||
| // Shared between MCPServer and MCPRemoteProxy | ||
| func BuildResourceRequirements(resourceSpec mcpv1beta1.ResourceRequirements) corev1.ResourceRequirements { | ||
|
|
@@ -45,6 +54,52 @@ | |
| return resources | ||
| } | ||
|
|
||
| // BuildDefaultProxyRunnerResourceRequirements returns the default resource | ||
| // requirements for proxy-runner containers (MCPRemoteProxy). | ||
| func BuildDefaultProxyRunnerResourceRequirements() corev1.ResourceRequirements { | ||
| return corev1.ResourceRequirements{ | ||
| Requests: corev1.ResourceList{ | ||
| corev1.ResourceCPU: resource.MustParse(DefaultProxyRunnerCPURequest), | ||
| corev1.ResourceMemory: resource.MustParse(DefaultProxyRunnerMemoryRequest), | ||
| }, | ||
| Limits: corev1.ResourceList{ | ||
| corev1.ResourceCPU: resource.MustParse(DefaultProxyRunnerCPULimit), | ||
| corev1.ResourceMemory: resource.MustParse(DefaultProxyRunnerMemoryLimit), | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| // MergeResourceRequirements merges user-provided resource requirements on top of | ||
| // defaults. User values take precedence for any field that is explicitly set. | ||
| func MergeResourceRequirements(defaults, user corev1.ResourceRequirements) corev1.ResourceRequirements { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MEDIUM · consensus 7/10] Partial override can synthesize Because each side is filled independently from defaults, a user who sets only Per |
||
| merged := corev1.ResourceRequirements{ | ||
| Requests: corev1.ResourceList{}, | ||
| Limits: corev1.ResourceList{}, | ||
| } | ||
|
|
||
| for resourceName, quantity := range defaults.Requests { | ||
| merged.Requests[resourceName] = quantity.DeepCopy() | ||
| } | ||
| for resourceName, quantity := range defaults.Limits { | ||
| merged.Limits[resourceName] = quantity.DeepCopy() | ||
| } | ||
| for resourceName, quantity := range user.Requests { | ||
| merged.Requests[resourceName] = quantity.DeepCopy() | ||
| } | ||
| for resourceName, quantity := range user.Limits { | ||
| merged.Limits[resourceName] = quantity.DeepCopy() | ||
| } | ||
|
|
||
| if len(merged.Requests) == 0 { | ||
| merged.Requests = nil | ||
| } | ||
| if len(merged.Limits) == 0 { | ||
| merged.Limits = nil | ||
| } | ||
|
|
||
| return merged | ||
| } | ||
|
|
||
| // BuildHealthProbe builds a Kubernetes health probe configuration | ||
| // Shared between MCPServer and MCPRemoteProxy | ||
| func BuildHealthProbe( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[MEDIUM · consensus 8/10] Drift comparison uses
reflect.DeepEqualonresource.Quantity.Semantically-equal quantities built from different source strings are not
reflect.DeepEqual—resource.Quantitycarries an unexported cached-string field. A valid user override likelimits.cpu: "0.2"(canonicalized by the apiserver to200m), or1024Mi↔1Gi,1000m↔1, makesgeneratedContainerNeedsUpdatereport perpetual drift → a reconcile/patch loop that hammers the apiserver. The default values themselves survive a round-trip, so the empty-spec path this PR adds is safe; the loop triggers on non-canonical user overrides.The sibling
remoteProxyContainerFieldsNeedUpdate(this file) andmcpserver_controller.goboth already useequality.Semantic.DeepEqual. Since this PR's own comment says drift detection should "stay consistent," switching here is in-scope (equalityis already imported).