feat(operator): allow pod scheduling on the proxy Deployment via resourceOverrides - #6183
Conversation
…urceOverrides Adds nodeSelector, tolerations and affinity to resourceOverrides.proxyDeployment and applies them to the proxy pod spec, matching the scheduling control spec.podTemplateSpec already gives the MCP server pod. Without this the proxy is repelled by a dedicated pre-warmed pool's taint and lands on a cold node, so the server is warm but the proxy is not and the cold-start cost is paid anyway. The operator sets no proxy scheduling of its own, so the overrides are authoritative rather than merged. Refs stacklok#5879
…ling drift The helper had been inserted between deploymentForMCPServer's doc comment and the function, hijacking both the docstring and the nolint:gocyclo directive. deploymentNeedsUpdate now compares the proxy pod's scheduling fields against the shared proxyDeploymentScheduling helper (equality.Semantic, matching the ImagePullSecrets precedent) — without this, editing the overrides on an existing MCPServer silently changed nothing. Adds a drift test mirroring TestMCPServerDeploymentNeedsUpdate_ImagePullSecretsDrift.
MCPServer and MCPRemoteProxy share the ResourceOverrides type, so the three scheduling fields already appeared on both CRDs. Wiring only MCPServer left MCPRemoteProxy accepting them and silently ignoring them, so this honours them there as well (issue stacklok#5879, option 2). proxyDeploymentScheduling now takes ResourceOverrides directly and a shared proxySchedulingNeedsUpdate predicate serves both controllers' drift checks. MCPRemoteProxy's podTemplateSpec is applied to the proxy Deployment itself, so in that branch scheduling is compared against the rebuilt Deployment rather than the overrides alone — comparing against overrides reported false drift on a podTemplateSpec-supplied nodeSelector. MCPServer needs no equivalent: its podTemplateSpec becomes a runtime patch for the MCP pod and never touches the proxy PodSpec.
There was a problem hiding this comment.
Thanks for this — it's a well-put-together change, and I appreciate how thoroughly you traced the plumbing on the issue before writing code. I went through the hand-written portion carefully (the drift detection especially) and it holds up. A few notes, plus one scoping point I want to sync with @ChrisJBurns on since he'd framed the original scope.
Verified as correct
- Drift detection handles the asymmetry properly. On
MCPRemoteProxy, scheduling can come from eitherpodTemplateSpecor the overrides, and the two comparisons are in mutually-exclusive branches ofpodSpecNeedsUpdate—podTemplateSpecpresent compares against the rebuiltexpectedDeployment(so apodTemplateSpec-suppliednodeSelectorisn't read as drift against empty overrides), absent usesproxySchedulingNeedsUpdateagainst the overrides alone.MCPServeruses the unconditional override comparison, which is right because itspodTemplateSpecbecomes a runtime patch for the MCP pod and never reaches the proxy PodSpec. That's the subtle part and it's correct. - API compatibility: the three fields are all optional, so this is v1beta1-safe.
- Generated artifacts are complete: exactly the two CRDs that embed the shared
ResourceOverrides(mcpservers+mcpremoteproxies), andequality.Semantic.DeepEqualgives you nil ≡ empty so an unset override doesn't read as perpetual drift — good call following theImagePullSecretsprecedent.
One required change
docs/operator/crd-api.mdneeds regenerating. It still lists only the pre-existing fields forProxyDeploymentOverrides; the three new ones aren't there. Runningtask crdref-genfrom the repo root will pick them up (it's a separate step fromoperator-manifests, which is why the CRD YAMLs updated but the reference doc didn't).
Scope — @ChrisJBurns let's sync on this
Chris, you'd originally scoped this to MCPServer with MCPRemoteProxy as a follow-up. @talshechanovitz found that isn't cleanly separable — ProxyDeploymentOverrides is reached through the shared ResourceOverrides, which MCPRemoteProxy also embeds, so the fields show up on its CRD schema either way — and wired both (option 2), with a +1 from @oryanomer1 who runs MCPRemoteProxy and would hit the inert-field case directly.
My read is that option 2 is the better of the two: shipping an inert surface (a field that validates then silently does nothing) is the kind of thing that quietly burns someone an afternoon. But since you set the original scope I didn't want to just wave it through — figured it's worth a quick sync on whether you're happy with wiring both, or would rather keep the boundary clean with option 1 (a dedicated MCPServer overrides type so scheduling never lands on MCPRemoteProxy — bigger diff, cleaner separation).
One input for that call: MCPRemoteProxy can already schedule its proxy via spec.podTemplateSpec, so wiring the overrides there gives it two ways to do the same thing, with podTemplateSpec silently winning when both are set. It's documented in the PR but unguarded — if we keep option 2, a godoc note on the fields calling out the precedence (or a validation warning when both are set) would save the head-scratch.
Non-blocking
- Our operator guidance has a "CRD vs PodTemplateSpec" rule of thumb that puts
nodeSelector/affinity/tolerationsin PodTemplateSpec rather than CRD attributes. Explicit fields here are a reasonable deviation given the proxy has no PodTemplateSpec of its own onMCPServerand the explicit-fields choice was deliberate — just noting it so it's on the record. - Tiny test gap: the "both
podTemplateSpecand overrides set,podTemplateSpecwins" case onMCPRemoteProxyis asserted in the PR description but not in a test. Low value, optional.
Net: the implementation is solid. Once crd-api.md is regenerated and Chris and I have settled the MCPRemoteProxy scope, I'm happy to approve.
|
Thanks both — and thanks @talshechanovitz for digging into the shared-struct problem before writing code rather than after. Happy with option 2. My original "MCPServer only" scoping assumed the two resources could be separated cleanly, and they can't: Option 1 would give cleaner separation, but two new API types plus retyping ~24 One thing I do want before this goes in: Otherwise: agree with @amirejaz that
|
Closes #5879.
What
Adds
nodeSelector,tolerationsandaffinitytoresourceOverrides.proxyDeploymentand applies them to the proxy pod, so the proxy can be steered onto the same nodes as the MCP server pod.Without it the proxy is repelled by a dedicated pre-warmed pool's taint, lands on a cold general node, and the ~40s node boot is paid anyway — the server is warm but the proxy isn't.
Diff shape: +335 hand-written, +7,827 generated. All the generated volume is
corev1.Affinity's schema expanding into four CRD files; regenerating on a clean tree produces no churn, so it's all attributable here.Scope — please sanity-check this call
@ChrisJBurns asked to keep this to
MCPServer. That turns out not to be cleanly possible:ProxyDeploymentOverridesis reached viaResourceOverrides, whichMCPRemoteProxyalso embeds (mcpremoteproxy_types.go:153), so the fields land on both CRDs regardless.This PR wires both, rather than leaving
MCPRemoteProxyaccepting fields it ignores.One wrinkle worth knowing before you weigh in — the two resources aren't symmetric:
MCPServerpodTemplateSpecis marshalled into a runtime patch for the MCP pod and never reaches the proxy PodSpecMCPRemoteProxypodTemplateSpecis applied to its proxy Deployment directlyAnd where both are set on
MCPRemoteProxy,podTemplateSpecwins silently (verified:nodeSelectorfrompodTemplateSpecoverwrites the override).So there's a case for splitting the type instead, giving
MCPServerits own overrides type so scheduling never appears onMCPRemoteProxy. I didn't, for one reason: it needs two new API types plus retyping ~24ResourceOverridesliterals across ~10 files, which makes the review considerably larger than the alternative you were trying to avoid. Happy to do it if you'd rather have the clean separation — just say and I'll push it.How
ProxyDeploymentOverrides(mcpserver_types.go)proxyDeploymentScheduling(*ResourceOverrides)— shared helper, so both controllers behave identically from one implementationdeploymentForMCPServeranddeploymentForMCPRemoteProxyproxySchedulingNeedsUpdate— shared drift predicate, used by bothdeploymentNeedsUpdatepathstask operator-generate+task operator-manifestsSimpler than I sketched on the issue: both operators build their proxy PodSpec directly, so no runtime or
applyPodTemplatePatchchange is needed.The overrides are authoritative, not merged — neither controller sets proxy scheduling of its own today (every
NodeSelector/Tolerations/Affinitymatch undercmd/thv-operatorisSessionAffinity, a Service field).Drift detection
Worth calling out because the first version of this PR got it wrong:
deploymentNeedsUpdatecompared no scheduling fields, so editing the overrides on an existing CR silently did nothing until an unrelated field changed. Create worked, update didn't.Both controllers now compare, following the
ImagePullSecretsprecedent (equality.Semantic.DeepEqual, so nil ≡ empty and an unset override isn't perpetual drift).The comparison has to differ per resource, which
TestMCPRemoteProxyPodTemplateSpecDriftDetectioncaught immediately: forMCPRemoteProxyscheduling may come frompodTemplateSpec, so it compares against the rebuilt Deployment; comparing against the overrides alone reported false drift — an infinite reconcile loop.MCPServerneeds no equivalent, since itspodTemplateSpecnever touches the proxy PodSpec.Tests
TestDeploymentForMCPServer_ProxyDeploymentScheduling— each field alone, all three together, and no overrides asserting scheduling stays unset so the default path is provably unchangedTestMCPServerDeploymentNeedsUpdate_ProxySchedulingDrift— spec-gains-value, spec-cleared-with-stale-deployment, nil-vs-empty-is-not-driftTestMCPRemoteProxySchedulingOverridesAndPodTemplateSpec— overrides applied and stable, clearing them detected, andpodTemplateSpec-supplied scheduling not mistaken for driftgo test ./cmd/thv-operator/...— 21 packages pass. Thetest-integration/...suites fail locally on missing envtest binaries (kubebuilder/bin/etcd): 23 failures both with this change and on a cleanmain, so environmental here. I couldn't rungolangci-lintlocally either — the installed build targets go1.23 and the repo is on 1.26 — so CI will be the first real lint pass;gofmtis clean.Follow-up
VirtualMCPServer, as agreed on the issue.