Skip to content

Commit 8ba812e

Browse files
authored
Preserve external annotations on operator Services (#5731)
A Service co-owned by an external controller (e.g. GKE NEG/Gateway, which writes `cloud.google.com/*` annotations) made `serviceNeedsUpdate` fire on every reconcile and the update wholesale-replace those annotations, hot-looping `Update` against the concurrent writer with `resourceVersion` conflicts. * Compare operator-owned labels/annotations with `MapIsSubset` instead of `maps.Equal`, so externally-managed keys are not treated as drift * Merge (not replace) labels/annotations on write via `MergeLabels`/ `MergeAnnotations`, preserving external keys * Apply the fix to the VirtualMCPServer, MCPServer, MCPRemoteProxy, and EmbeddingServer Service reconcilers * Add detection and write-path regression tests for all four Fixes #5730
1 parent 609c0ad commit 8ba812e

10 files changed

Lines changed: 413 additions & 25 deletions

cmd/thv-operator/controllers/embeddingserver_controller.go

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -287,8 +287,11 @@ func (r *EmbeddingServerReconciler) ensureService(
287287
if r.serviceNeedsUpdate(service, embedding) {
288288
desiredService := r.serviceForEmbedding(ctx, embedding)
289289
service.Spec.Ports = desiredService.Spec.Ports
290-
service.Labels = desiredService.Labels
291-
service.Annotations = desiredService.Annotations
290+
// Merge (not replace) Labels/Annotations so keys written by external controllers
291+
// (e.g. GKE NEG's cloud.google.com/* annotations) are preserved while the
292+
// operator-owned values are applied.
293+
service.Labels = ctrlutil.MergeLabels(desiredService.Labels, service.Labels)
294+
service.Annotations = ctrlutil.MergeAnnotations(desiredService.Annotations, service.Annotations)
292295
// Preserve ClusterIP as it's immutable
293296
if err := r.Update(ctx, service); err != nil {
294297
ctxLogger.Error(err, "Failed to update Service",
@@ -330,18 +333,16 @@ func (*EmbeddingServerReconciler) serviceNeedsUpdate(
330333
}
331334
}
332335

333-
// Check if expected annotations are present in service
334-
for key, value := range expectedAnnotations {
335-
if service.Annotations[key] != value {
336-
return true
337-
}
336+
// Subset check rather than exact equality: the Service is co-owned by external
337+
// controllers (e.g. GKE NEG/Gateway writes cloud.google.com/* annotations), so only
338+
// the operator-owned keys must match. Exact equality would treat those external
339+
// annotations as drift and hot-loop Update against the concurrent writer.
340+
if !ctrlutil.MapIsSubset(expectedAnnotations, service.Annotations) {
341+
return true
338342
}
339343

340-
// Check if expected labels are present in service
341-
for key, value := range expectedLabels {
342-
if service.Labels[key] != value {
343-
return true
344-
}
344+
if !ctrlutil.MapIsSubset(expectedLabels, service.Labels) {
345+
return true
345346
}
346347

347348
return false

cmd/thv-operator/controllers/embeddingserver_controller_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1161,3 +1161,79 @@ func TestEmbeddingServer_PodTemplateSpec_EmptyObjectIsNoOp(t *testing.T) {
11611161
assert.Contains(t, c.Args, "--model-id")
11621162
assert.Contains(t, c.Args, "test-model")
11631163
}
1164+
1165+
// TestEmbeddingServerServiceNeedsUpdate_ExternalAnnotationsIgnored is a regression test
1166+
// for #5730: external controllers (e.g. GKE NEG/Gateway) write cloud.google.com/*
1167+
// annotations on the Service; these are not operator-owned and must not be treated as
1168+
// drift. A genuine operator-owned change (port) must still be detected.
1169+
func TestEmbeddingServerServiceNeedsUpdate_ExternalAnnotationsIgnored(t *testing.T) {
1170+
t.Parallel()
1171+
1172+
embedding := v1beta1test.NewEmbeddingServer("embed", testNamespaceDefault,
1173+
v1beta1test.WithEmbeddingPort(9000))
1174+
r := &EmbeddingServerReconciler{}
1175+
1176+
svc := &corev1.Service{
1177+
ObjectMeta: metav1.ObjectMeta{
1178+
Annotations: map[string]string{
1179+
"cloud.google.com/neg-status": `{"network_endpoint_groups":{"9000":"k8s1-abc"}}`,
1180+
},
1181+
},
1182+
Spec: corev1.ServiceSpec{
1183+
Ports: []corev1.ServicePort{{Name: "http", Port: 9000}},
1184+
},
1185+
}
1186+
assert.False(t, r.serviceNeedsUpdate(svc, embedding),
1187+
"external annotation must not be treated as drift")
1188+
1189+
svc.Spec.Ports[0].Port = 1234
1190+
assert.True(t, r.serviceNeedsUpdate(svc, embedding),
1191+
"operator-owned port drift must still be detected")
1192+
}
1193+
1194+
// TestEmbeddingServerEnsureService_PreservesExternalAnnotations is a regression test for
1195+
// #5730: when a genuine operator-owned change triggers a Service update, annotations
1196+
// written by an external controller (e.g. GKE NEG) must be merged, not stripped.
1197+
func TestEmbeddingServerEnsureService_PreservesExternalAnnotations(t *testing.T) {
1198+
t.Parallel()
1199+
1200+
embedding := v1beta1test.NewEmbeddingServer("embed-ext", testNamespaceDefault,
1201+
v1beta1test.WithEmbeddingPort(9000))
1202+
1203+
// Existing Service co-owned by GKE (external annotation) with a drifted operator-owned
1204+
// field (wrong port) so serviceNeedsUpdate fires.
1205+
existing := &corev1.Service{
1206+
ObjectMeta: metav1.ObjectMeta{
1207+
Name: embedding.Name,
1208+
Namespace: embedding.Namespace,
1209+
Annotations: map[string]string{
1210+
"cloud.google.com/neg-status": `{"network_endpoint_groups":{"9000":"k8s1-abc"}}`,
1211+
},
1212+
},
1213+
Spec: corev1.ServiceSpec{
1214+
Ports: []corev1.ServicePort{{Name: "http", Port: 1234}},
1215+
},
1216+
}
1217+
1218+
scheme := testutil.NewScheme(t)
1219+
fakeClient := fake.NewClientBuilder().
1220+
WithScheme(scheme).
1221+
WithObjects(embedding, existing).
1222+
Build()
1223+
r := &EmbeddingServerReconciler{Client: fakeClient, Scheme: scheme}
1224+
1225+
_, err := r.ensureService(context.TODO(), embedding)
1226+
require.NoError(t, err)
1227+
1228+
updated := &corev1.Service{}
1229+
require.NoError(t, fakeClient.Get(context.TODO(), types.NamespacedName{
1230+
Name: embedding.Name,
1231+
Namespace: embedding.Namespace,
1232+
}, updated))
1233+
// External annotation preserved...
1234+
assert.Equal(t, `{"network_endpoint_groups":{"9000":"k8s1-abc"}}`,
1235+
updated.Annotations["cloud.google.com/neg-status"])
1236+
// ...and the operator-owned port corrected.
1237+
require.Len(t, updated.Spec.Ports, 1)
1238+
assert.Equal(t, int32(9000), updated.Spec.Ports[0].Port)
1239+
}

cmd/thv-operator/controllers/mcpremoteproxy_controller.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -376,8 +376,11 @@ func (r *MCPRemoteProxyReconciler) ensureService(
376376
}
377377
service.Spec.Ports = newService.Spec.Ports
378378
service.Spec.SessionAffinity = newService.Spec.SessionAffinity
379-
service.Labels = newService.Labels
380-
service.Annotations = newService.Annotations
379+
// Merge (not replace) Labels/Annotations so keys written by external controllers
380+
// (e.g. GKE NEG's cloud.google.com/* annotations) are preserved while the
381+
// operator-owned values are applied.
382+
service.Labels = ctrlutil.MergeLabels(newService.Labels, service.Labels)
383+
service.Annotations = ctrlutil.MergeAnnotations(newService.Annotations, service.Annotations)
381384

382385
ctxLogger.Info("Updating Service", "Service.Namespace", service.Namespace, "Service.Name", service.Name)
383386
if err := r.Update(ctx, service); err != nil {
@@ -1642,11 +1645,15 @@ func (*MCPRemoteProxyReconciler) serviceNeedsUpdate(service *corev1.Service, pro
16421645
}
16431646
}
16441647

1645-
if !maps.Equal(service.Labels, expectedLabels) {
1648+
// Subset check rather than exact equality: the Service is co-owned by external
1649+
// controllers (e.g. GKE NEG/Gateway writes cloud.google.com/* annotations), so only
1650+
// the operator-owned keys must match. maps.Equal would treat those external
1651+
// annotations as drift and hot-loop Update against the concurrent writer.
1652+
if !ctrlutil.MapIsSubset(expectedLabels, service.Labels) {
16461653
return true
16471654
}
16481655

1649-
if !maps.Equal(service.Annotations, expectedAnnotations) {
1656+
if !ctrlutil.MapIsSubset(expectedAnnotations, service.Annotations) {
16501657
return true
16511658
}
16521659

cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
rbacv1 "k8s.io/api/rbac/v1"
2727
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2828
"k8s.io/apimachinery/pkg/runtime"
29+
"k8s.io/apimachinery/pkg/types"
2930
"sigs.k8s.io/controller-runtime/pkg/client/fake"
3031

3132
mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1"
@@ -605,6 +606,55 @@ func TestEnsureService(t *testing.T) {
605606
}
606607
}
607608

609+
// TestMCPRemoteProxyEnsureService_PreservesExternalAnnotations is a regression test for
610+
// #5730: when a genuine operator-owned change triggers a Service update, annotations
611+
// written by an external controller (e.g. GKE NEG) must be merged, not stripped.
612+
func TestMCPRemoteProxyEnsureService_PreservesExternalAnnotations(t *testing.T) {
613+
t.Parallel()
614+
615+
proxy := v1beta1test.NewMCPRemoteProxy("ext-annot-proxy", "default")
616+
617+
// Existing Service co-owned by GKE (external annotation) with an operator-owned field
618+
// drifted (empty session affinity) so serviceNeedsUpdate fires.
619+
existing := &corev1.Service{
620+
ObjectMeta: metav1.ObjectMeta{
621+
Name: createProxyServiceName(proxy.Name),
622+
Namespace: proxy.Namespace,
623+
Labels: labelsForMCPRemoteProxy(proxy.Name),
624+
Annotations: map[string]string{
625+
"cloud.google.com/neg-status": `{"network_endpoint_groups":{"8080":"k8s1-abc"}}`,
626+
},
627+
},
628+
Spec: corev1.ServiceSpec{
629+
SessionAffinity: "",
630+
Ports: []corev1.ServicePort{{Port: 8080}},
631+
},
632+
}
633+
634+
scheme := testutil.NewScheme(t)
635+
_ = rbacv1.AddToScheme(scheme)
636+
_ = appsv1.AddToScheme(scheme)
637+
fakeClient := fake.NewClientBuilder().
638+
WithScheme(scheme).
639+
WithObjects(proxy, existing).
640+
Build()
641+
reconciler := &MCPRemoteProxyReconciler{Client: fakeClient, Scheme: scheme}
642+
643+
_, err := reconciler.ensureService(context.TODO(), proxy)
644+
require.NoError(t, err)
645+
646+
updated := &corev1.Service{}
647+
require.NoError(t, fakeClient.Get(context.TODO(), types.NamespacedName{
648+
Name: createProxyServiceName(proxy.Name),
649+
Namespace: proxy.Namespace,
650+
}, updated))
651+
// External annotation preserved...
652+
assert.Equal(t, `{"network_endpoint_groups":{"8080":"k8s1-abc"}}`,
653+
updated.Annotations["cloud.google.com/neg-status"])
654+
// ...and the operator-owned field corrected.
655+
assert.Equal(t, corev1.ServiceAffinityClientIP, updated.Spec.SessionAffinity)
656+
}
657+
608658
func TestMCPRemoteProxyDeploymentNeedsUpdate_EmbeddedAuthLegacyEnvStable(t *testing.T) {
609659
t.Parallel()
610660

@@ -1182,6 +1232,19 @@ func TestMCPRemoteProxyServiceNeedsUpdate(t *testing.T) {
11821232
}(),
11831233
needsUpdate: false,
11841234
},
1235+
{
1236+
// Regression for #5730: external controllers (e.g. GKE NEG/Gateway) write
1237+
// cloud.google.com/* annotations on the Service. These are not operator-owned
1238+
// and must not be treated as drift, or the operator hot-loops Update.
1239+
name: "external cloud annotations ignored",
1240+
service: func() *corev1.Service {
1241+
s := baseService.DeepCopy()
1242+
s.Annotations["cloud.google.com/neg-status"] = `{"network_endpoint_groups":{"8080":"k8s1-abc"}}`
1243+
return s
1244+
}(),
1245+
proxy: baseProxy.DeepCopy(),
1246+
needsUpdate: false,
1247+
},
11851248
}
11861249

11871250
for _, tt := range tests {

cmd/thv-operator/controllers/mcpserver_controller.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -526,8 +526,11 @@ func (r *MCPServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
526526
newService := r.serviceForMCPServer(ctx, mcpServer)
527527
service.Spec.Ports = newService.Spec.Ports
528528
service.Spec.SessionAffinity = newService.Spec.SessionAffinity
529-
service.Labels = newService.Labels
530-
service.Annotations = newService.Annotations
529+
// Merge (not replace) Labels/Annotations so keys written by external controllers
530+
// (e.g. GKE NEG's cloud.google.com/* annotations) are preserved while the
531+
// operator-owned values are applied.
532+
service.Labels = ctrlutil.MergeLabels(newService.Labels, service.Labels)
533+
service.Annotations = ctrlutil.MergeAnnotations(newService.Annotations, service.Annotations)
531534
err = r.Update(ctx, service)
532535
if err != nil {
533536
ctxLogger.Error(err, "Failed to update Service", "Service.Namespace", service.Namespace, "Service.Name", service.Name)
@@ -2062,11 +2065,15 @@ func serviceNeedsUpdate(service *corev1.Service, mcpServer *mcpv1beta1.MCPServer
20622065
}
20632066
}
20642067

2065-
if !maps.Equal(service.Labels, expectedLabels) {
2068+
// Subset check rather than exact equality: the Service is co-owned by external
2069+
// controllers (e.g. GKE NEG/Gateway writes cloud.google.com/* annotations), so only
2070+
// the operator-owned keys must match. maps.Equal would treat those external
2071+
// annotations as drift and hot-loop Update against the concurrent writer.
2072+
if !ctrlutil.MapIsSubset(expectedLabels, service.Labels) {
20662073
return true
20672074
}
20682075

2069-
if !maps.Equal(service.Annotations, expectedAnnotations) {
2076+
if !ctrlutil.MapIsSubset(expectedAnnotations, service.Annotations) {
20702077
return true
20712078
}
20722079

cmd/thv-operator/controllers/mcpserver_resource_overrides_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,6 +1135,19 @@ func TestMCPServerServiceNeedsUpdate(t *testing.T) {
11351135
}(),
11361136
needsUpdate: false,
11371137
},
1138+
{
1139+
// Regression for #5730: external controllers (e.g. GKE NEG/Gateway) write
1140+
// cloud.google.com/* annotations on the Service. These are not operator-owned
1141+
// and must not be treated as drift, or the operator hot-loops Update.
1142+
name: "external cloud annotations ignored",
1143+
service: func() *corev1.Service {
1144+
s := baseService.DeepCopy()
1145+
s.Annotations["cloud.google.com/neg-status"] = `{"network_endpoint_groups":{"8080":"k8s1-abc"}}`
1146+
return s
1147+
}(),
1148+
mcpServer: baseMCPServer.DeepCopy(),
1149+
needsUpdate: false,
1150+
},
11381151
}
11391152

11401153
for _, tt := range tests {
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package controllers
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
corev1 "k8s.io/api/core/v1"
12+
"k8s.io/apimachinery/pkg/types"
13+
ctrl "sigs.k8s.io/controller-runtime"
14+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
15+
16+
mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1"
17+
"github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1/v1beta1test"
18+
"github.com/stacklok/toolhive/cmd/thv-operator/internal/testutil"
19+
ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil"
20+
"github.com/stacklok/toolhive/pkg/container/kubernetes"
21+
)
22+
23+
// TestMCPServerReconcile_PreservesExternalServiceAnnotations is a regression test for
24+
// #5730: when a genuine operator-owned change triggers the inline Service update in
25+
// Reconcile, annotations written by an external controller (e.g. GKE NEG) must be
26+
// merged, not stripped, otherwise the operator hot-loops Update against the concurrent
27+
// writer.
28+
func TestMCPServerReconcile_PreservesExternalServiceAnnotations(t *testing.T) {
29+
t.Parallel()
30+
31+
const name = "ext-annot-server"
32+
namespace := testNamespaceDefault
33+
34+
mcpServer := v1beta1test.NewMCPServer(name, namespace, v1beta1test.WithTransport("sse"))
35+
36+
scheme := testutil.NewScheme(t)
37+
fakeClient := fake.NewClientBuilder().
38+
WithScheme(scheme).
39+
WithObjects(mcpServer).
40+
WithStatusSubresource(&mcpv1beta1.MCPServer{}).
41+
Build()
42+
r := newTestMCPServerReconciler(fakeClient, scheme, kubernetes.PlatformKubernetes)
43+
44+
req := ctrl.Request{NamespacedName: types.NamespacedName{Name: name, Namespace: namespace}}
45+
// Drive to steady state; successive passes create the Deployment and Service.
46+
for range 6 {
47+
_, err := r.Reconcile(t.Context(), req)
48+
require.NoError(t, err)
49+
}
50+
51+
svcName := ctrlutil.CreateProxyServiceName(name)
52+
svcKey := types.NamespacedName{Name: svcName, Namespace: namespace}
53+
54+
// An external controller (GKE NEG) writes an annotation, and an operator-owned field
55+
// drifts (empty session affinity) so serviceNeedsUpdate fires on the next reconcile.
56+
svc := &corev1.Service{}
57+
require.NoError(t, fakeClient.Get(t.Context(), svcKey, svc))
58+
if svc.Annotations == nil {
59+
svc.Annotations = map[string]string{}
60+
}
61+
svc.Annotations["cloud.google.com/neg-status"] = `{"network_endpoint_groups":{"8080":"k8s1-abc"}}`
62+
svc.Spec.SessionAffinity = ""
63+
require.NoError(t, fakeClient.Update(t.Context(), svc))
64+
65+
// Reconcile again; the inline Service update path merges rather than replaces.
66+
for range 3 {
67+
_, err := r.Reconcile(t.Context(), req)
68+
require.NoError(t, err)
69+
}
70+
71+
updated := &corev1.Service{}
72+
require.NoError(t, fakeClient.Get(t.Context(), svcKey, updated))
73+
// External annotation preserved...
74+
assert.Equal(t, `{"network_endpoint_groups":{"8080":"k8s1-abc"}}`,
75+
updated.Annotations["cloud.google.com/neg-status"],
76+
"external annotation must survive the operator Service update")
77+
// ...and the operator-owned field corrected.
78+
assert.Equal(t, corev1.ServiceAffinityClientIP, updated.Spec.SessionAffinity,
79+
"operator-owned session affinity should be reconciled back to the default")
80+
}

0 commit comments

Comments
 (0)