Skip to content

fix(operator): honor kubectl rollout restart on ToolHive workloads - #6378

Merged
reyortiz3 merged 5 commits into
stacklok:mainfrom
RaviTharuma:fix/honor-kubectl-rollout-restart
Aug 21, 2026
Merged

reyortiz3 merged 5 commits into
stacklok:mainfrom
RaviTharuma:fix/honor-kubectl-rollout-restart

Conversation

@RaviTharuma

@RaviTharuma RaviTharuma commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Cross-links

Summary

kubectl rollout restart on ToolHive Deployments/StatefulSets was reverted on
the next reconcile because kubectl.kubernetes.io/restartedAt was treated as
drift. There is already a CR bounce annotation; this honors the standard
kubectl path instead of adding another API.

  • Preserve kubectl.kubernetes.io/restartedAt when computing the desired
    pod template (MCPServer, MCPRemoteProxy, VirtualMCPServer)
  • Treat extra live annotation keys as non-drift (MapIsSubset)
  • Re-assert the annotation on runner StatefulSet server-side apply (Force: true would otherwise wipe it)
  • Document kubectl rollout restart next to the existing CR annotation

Fixes #6344

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • go test -ldflags=-extldflags=-Wl,-w ./cmd/thv-operator/pkg/controllerutil/ ./cmd/thv-operator/controllers/ -run 'TestPreserveKubectlRestartedAt|TestMapIsSubset|TestVirtualMCPServerPodTemplateMetadataNeedsUpdate'
  • go test -ldflags=-extldflags=-Wl,-w ./pkg/container/kubernetes/
  • CI unit / operator tests
  • After merge: kubectl rollout restart deploy/<mcpserver> should keep
    kubectl.kubernetes.io/restartedAt and actually bounce

API Compatibility

  • This PR does not break the v1beta1 API, OR the api-break-allowed label is applied and the migration guidance is described above.

No CRD schema change. The GitOps CR annotation
(mcpserver.toolhive.stacklok.dev/restarted-at) is unchanged.

Does this introduce a user-facing change?

Yes. kubectl rollout restart on ToolHive proxy Deployments and MCPServer
workload StatefulSets is honored instead of being reverted.

Special notes for reviewers

This is the approach from #6344: preserve kubectl's annotation rather than
invent a new bounce API. The CR annotation remains the GitOps-friendly path.

The operator treated kubectl.kubernetes.io/restartedAt as drift and
reverted kubectl rollout restart. Preserve that annotation on desired
pod templates and on runner SSA so a bounce sticks.

Fixes stacklok#6344
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@RaviTharuma

Copy link
Copy Markdown
Contributor Author

@ChrisJBurns ChrisJBurns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-Agent Consensus Review

Agents consulted: kubernetes-operator-specialist, go-idioms-and-test-coverage, architecture-and-design, error-handling-and-resilience, general-code-quality

Consensus Summary

# Finding Consensus Severity Action
1 Pod-template labels drift-tolerance loosened beyond the annotation this PR is about 9/10 HIGH Fix
2 New Get in applyStatefulSet silently swallows non-NotFound errors 9/10 HIGH Fix
3 Test coverage inconsistent: only one of three controllers (and no part of client.go) got a regression test for this fix 9/10 HIGH Fix
4 New Get in applyStatefulSet duplicates a fetch shouldSkipStatefulSetApply already made 8/10 MEDIUM Fix
5 kubectl.kubernetes.io/restartedAt defined twice with inconsistent visibility/naming 7/10 MEDIUM Fix
6 PreserveKubectlRestartedAt mutates the caller's desired map in place instead of cloning 7/10 LOW-MEDIUM Fix
7 TOCTOU: the Get-then-Force-Apply sequence can revert a concurrent, newer rollout-restart to the stale value it just read 6/10 MEDIUM Discuss

Overall

This fixes a real bug (#6344) the way the maintainer asked for: preserving kubectl.kubernetes.io/restartedAt instead of inventing a new bounce API. PreserveKubectlRestartedAt itself is small, nil-safe, and well tested, and it's wired into all three controllers' ensureDeployment in the right place (before the template gets overwritten).

The drift-detection change went further than that scoped fix, though. Pod-template labels (which kubectl rollout restart never touches) were also switched from exact-equality to a subset check in two of the three controllers, inconsistent with the third — that quietly removes the operator's ability to ever clean up a stale label it stops desiring. The runner-side StatefulSet path (pkg/container/kubernetes/client.go) reimplements the same idea with a duplicated API call, a swallowed non-NotFound error, and a small window where a concurrent kubectl rollout restart could get reverted by this very code path. None of that is a fundamental problem with the approach — each is a contained, cheap fix — but a few of them sit on the exact controllers/paths this bug report names, so worth addressing before merge.

Test coverage gap (not tied to one line)

Only virtualmcpserver_controller_test.go got a new "kubectl rollout restart annotation is not drift" case. mcpserver_controller.go and mcpremoteproxy_controller.go received the identical maps.EqualMapIsSubset change with no equivalent test, and pkg/container/kubernetes/client.go's new Get-and-preserve logic in applyStatefulSet has no test at all. Given mcpserver_controller.go is the controller issue #6344 was filed against, that's the one most worth covering first.


Generated with Claude Code

Comment thread cmd/thv-operator/controllers/mcpremoteproxy_controller.go Outdated
Comment thread cmd/thv-operator/controllers/virtualmcpserver_controller.go Outdated
Comment thread pkg/container/kubernetes/client.go Outdated
Comment thread pkg/container/kubernetes/client.go Outdated
Comment thread cmd/thv-operator/pkg/controllerutil/restart.go
Restore exact pod-template label equality, fail Get errors other than
NotFound, reuse one StatefulSet read, clone the desired annotation map,
and share the kubectl.kubernetes.io/restartedAt constant.
@ChrisJBurns

Copy link
Copy Markdown
Collaborator

@RaviTharuma Thanks for the quick fixes, I think you have some lint errors though

RaviTharuma and others added 2 commits August 20, 2026 23:43
skipStatefulSetApply is already called from DeployWorkload.
The Client.shouldSkipStatefulSetApply method was unused and
failed golangci unused.
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.00000% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.70%. Comparing base (d278b33) to head (4ca5efb).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
pkg/container/kubernetes/client.go 90.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6378      +/-   ##
==========================================
- Coverage   77.75%   77.70%   -0.05%     
==========================================
  Files         750      751       +1     
  Lines       72601    72623      +22     
==========================================
- Hits        56448    56433      -15     
- Misses      16148    16185      +37     
  Partials        5        5              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@reyortiz3
reyortiz3 merged commit 2962bf6 into stacklok:main Aug 21, 2026
44 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 26, 2026
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(operator): kubectl rollout restart of ToolHive workloads is reverted; no supported bounce API

3 participants