Skip to content

Commit 98ee559

Browse files
committed
Watch authz ConfigMaps from VirtualMCPServer
The VirtualMCPServer controller did not watch ConfigMaps referenced by spec.incomingAuth.authzConfig.configMap, so changes to a referenced authz ConfigMap were never observed: the converter was not re-run, the rendered vmcp config.yaml retained stale policies, and the running pod enforced them indefinitely. This matters in particular for the enterprise flow where an external controller rewrites the authz ConfigMap when Cedar policies change. Add a Watches entry on corev1.ConfigMap with: * a mapper that lists VirtualMCPServers in the ConfigMap's namespace and enqueues those whose AuthzConfig.Type is "configMap" and whose ConfigMap.Name matches the changed object * a predicate that admits Create and Delete events unconditionally and Update events only when .Data or .BinaryData actually changed, so metadata-only updates (labels, annotations, resourceVersion bumps) do not trigger reconciliation The watch is functionally meaningful only once #5208 lands and the converter actually resolves ConfigMap-sourced policies; until then the re-reconcile still produces an empty-policy config. Wiring it now unblocks that follow-up without an extra round trip. Closes #5270
1 parent 3dfed71 commit 98ee559

3 files changed

Lines changed: 327 additions & 0 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package controllers
5+
6+
import (
7+
"context"
8+
"reflect"
9+
10+
corev1 "k8s.io/api/core/v1"
11+
"k8s.io/apimachinery/pkg/types"
12+
"sigs.k8s.io/controller-runtime/pkg/client"
13+
"sigs.k8s.io/controller-runtime/pkg/event"
14+
"sigs.k8s.io/controller-runtime/pkg/log"
15+
"sigs.k8s.io/controller-runtime/pkg/predicate"
16+
"sigs.k8s.io/controller-runtime/pkg/reconcile"
17+
18+
mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1"
19+
)
20+
21+
// mapAuthzConfigMapToVirtualMCPServer maps ConfigMap changes to VirtualMCPServer reconciliation
22+
// requests. Used by SetupWithManager to trigger reconciliation when a ConfigMap referenced via
23+
// spec.incomingAuth.authzConfig.configMap is updated, so the converter can re-resolve policies
24+
// and roll out a pod with the new config.
25+
//
26+
// The mapper lists VirtualMCPServers in the ConfigMap's namespace and enqueues any that
27+
// reference this ConfigMap. ConfigMaps are cluster-wide objects but authz references are
28+
// namespace-scoped, so the lookup is bounded to a single namespace.
29+
func (r *VirtualMCPServerReconciler) mapAuthzConfigMapToVirtualMCPServer(
30+
ctx context.Context, obj client.Object,
31+
) []reconcile.Request {
32+
cm, ok := obj.(*corev1.ConfigMap)
33+
if !ok {
34+
return nil
35+
}
36+
37+
vmcpList := &mcpv1beta1.VirtualMCPServerList{}
38+
if err := r.List(ctx, vmcpList, client.InNamespace(cm.Namespace)); err != nil {
39+
log.FromContext(ctx).Error(err, "Failed to list VirtualMCPServers for authz ConfigMap watch")
40+
return nil
41+
}
42+
43+
var requests []reconcile.Request
44+
for _, vmcp := range vmcpList.Items {
45+
if !vmcpReferencesAuthzConfigMap(&vmcp, cm.Name) {
46+
continue
47+
}
48+
requests = append(requests, reconcile.Request{
49+
NamespacedName: types.NamespacedName{
50+
Name: vmcp.Name,
51+
Namespace: vmcp.Namespace,
52+
},
53+
})
54+
}
55+
56+
return requests
57+
}
58+
59+
// vmcpReferencesAuthzConfigMap reports whether the VirtualMCPServer references the named
60+
// ConfigMap via spec.incomingAuth.authzConfig.
61+
func vmcpReferencesAuthzConfigMap(vmcp *mcpv1beta1.VirtualMCPServer, configMapName string) bool {
62+
if vmcp.Spec.IncomingAuth == nil ||
63+
vmcp.Spec.IncomingAuth.AuthzConfig == nil ||
64+
vmcp.Spec.IncomingAuth.AuthzConfig.Type != mcpv1beta1.AuthzConfigTypeConfigMap ||
65+
vmcp.Spec.IncomingAuth.AuthzConfig.ConfigMap == nil {
66+
return false
67+
}
68+
return vmcp.Spec.IncomingAuth.AuthzConfig.ConfigMap.Name == configMapName
69+
}
70+
71+
// configMapDataChangedPredicate admits ConfigMap events that may affect a VirtualMCPServer's
72+
// resolved authz config. Update events are admitted only when .Data or .BinaryData actually
73+
// change, so metadata-only updates (labels, annotations, resourceVersion bumps) do not trigger
74+
// reconciliation. Create and Delete events are passed through so the controller can pick up a
75+
// newly-created ConfigMap or surface a deletion as a status error.
76+
func configMapDataChangedPredicate() predicate.Predicate {
77+
return predicate.Funcs{
78+
UpdateFunc: func(e event.UpdateEvent) bool {
79+
oldCM, ok := e.ObjectOld.(*corev1.ConfigMap)
80+
if !ok {
81+
return false
82+
}
83+
newCM, ok := e.ObjectNew.(*corev1.ConfigMap)
84+
if !ok {
85+
return false
86+
}
87+
return !reflect.DeepEqual(oldCM.Data, newCM.Data) ||
88+
!reflect.DeepEqual(oldCM.BinaryData, newCM.BinaryData)
89+
},
90+
CreateFunc: func(_ event.CreateEvent) bool { return true },
91+
DeleteFunc: func(_ event.DeleteEvent) bool { return true },
92+
GenericFunc: func(_ event.GenericEvent) bool { return false },
93+
}
94+
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
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+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
13+
"k8s.io/apimachinery/pkg/runtime"
14+
"k8s.io/apimachinery/pkg/types"
15+
"sigs.k8s.io/controller-runtime/pkg/client"
16+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
17+
"sigs.k8s.io/controller-runtime/pkg/event"
18+
19+
mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1"
20+
)
21+
22+
func TestMapAuthzConfigMapToVirtualMCPServer(t *testing.T) {
23+
t.Parallel()
24+
25+
const ns = "default"
26+
const cmName = "shared-authz"
27+
28+
// vmcpWithConfigMapRef references cmName via configMap.
29+
vmcpWithConfigMapRef := &mcpv1beta1.VirtualMCPServer{
30+
ObjectMeta: metav1.ObjectMeta{Name: "vmcp-cm", Namespace: ns},
31+
Spec: mcpv1beta1.VirtualMCPServerSpec{
32+
IncomingAuth: &mcpv1beta1.IncomingAuthConfig{
33+
Type: "oidc",
34+
AuthzConfig: &mcpv1beta1.AuthzConfigRef{
35+
Type: mcpv1beta1.AuthzConfigTypeConfigMap,
36+
ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{Name: cmName, Key: "authz.json"},
37+
},
38+
},
39+
},
40+
}
41+
// vmcpDifferentCM references a different ConfigMap by the same type.
42+
vmcpDifferentCM := &mcpv1beta1.VirtualMCPServer{
43+
ObjectMeta: metav1.ObjectMeta{Name: "vmcp-other-cm", Namespace: ns},
44+
Spec: mcpv1beta1.VirtualMCPServerSpec{
45+
IncomingAuth: &mcpv1beta1.IncomingAuthConfig{
46+
Type: "oidc",
47+
AuthzConfig: &mcpv1beta1.AuthzConfigRef{
48+
Type: mcpv1beta1.AuthzConfigTypeConfigMap,
49+
ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{Name: "other-authz"},
50+
},
51+
},
52+
},
53+
}
54+
// vmcpInline uses inline authz — same name on Inline.something should not match.
55+
vmcpInline := &mcpv1beta1.VirtualMCPServer{
56+
ObjectMeta: metav1.ObjectMeta{Name: "vmcp-inline", Namespace: ns},
57+
Spec: mcpv1beta1.VirtualMCPServerSpec{
58+
IncomingAuth: &mcpv1beta1.IncomingAuthConfig{
59+
Type: "oidc",
60+
AuthzConfig: &mcpv1beta1.AuthzConfigRef{
61+
Type: mcpv1beta1.AuthzConfigTypeInline,
62+
Inline: &mcpv1beta1.InlineAuthzConfig{Policies: []string{"permit(principal, action, resource);"}},
63+
},
64+
},
65+
},
66+
}
67+
// vmcpNoIncomingAuth has no incoming auth configured.
68+
vmcpNoIncomingAuth := &mcpv1beta1.VirtualMCPServer{
69+
ObjectMeta: metav1.ObjectMeta{Name: "vmcp-no-auth", Namespace: ns},
70+
Spec: mcpv1beta1.VirtualMCPServerSpec{},
71+
}
72+
// vmcpInOtherNamespace references the same name in a different namespace.
73+
vmcpInOtherNamespace := &mcpv1beta1.VirtualMCPServer{
74+
ObjectMeta: metav1.ObjectMeta{Name: "vmcp-other-ns", Namespace: "other"},
75+
Spec: mcpv1beta1.VirtualMCPServerSpec{
76+
IncomingAuth: &mcpv1beta1.IncomingAuthConfig{
77+
Type: "oidc",
78+
AuthzConfig: &mcpv1beta1.AuthzConfigRef{
79+
Type: mcpv1beta1.AuthzConfigTypeConfigMap,
80+
ConfigMap: &mcpv1beta1.ConfigMapAuthzRef{Name: cmName},
81+
},
82+
},
83+
},
84+
}
85+
86+
scheme := runtime.NewScheme()
87+
require.NoError(t, mcpv1beta1.AddToScheme(scheme))
88+
require.NoError(t, corev1.AddToScheme(scheme))
89+
90+
fakeClient := fake.NewClientBuilder().
91+
WithScheme(scheme).
92+
WithObjects(
93+
vmcpWithConfigMapRef,
94+
vmcpDifferentCM,
95+
vmcpInline,
96+
vmcpNoIncomingAuth,
97+
vmcpInOtherNamespace,
98+
).
99+
Build()
100+
101+
reconciler := &VirtualMCPServerReconciler{
102+
Client: fakeClient,
103+
Scheme: scheme,
104+
}
105+
106+
tests := []struct {
107+
name string
108+
obj client.Object
109+
expected []types.NamespacedName
110+
}{
111+
{
112+
name: "configMap matched by name in same namespace",
113+
obj: &corev1.ConfigMap{
114+
ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: ns},
115+
},
116+
expected: []types.NamespacedName{{Name: "vmcp-cm", Namespace: ns}},
117+
},
118+
{
119+
name: "configMap in other namespace only matches vmcp in that namespace",
120+
obj: &corev1.ConfigMap{
121+
ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: "other"},
122+
},
123+
expected: []types.NamespacedName{{Name: "vmcp-other-ns", Namespace: "other"}},
124+
},
125+
{
126+
name: "configMap not referenced by any vmcp",
127+
obj: &corev1.ConfigMap{
128+
ObjectMeta: metav1.ObjectMeta{Name: "unreferenced", Namespace: ns},
129+
},
130+
expected: nil,
131+
},
132+
{
133+
name: "non-configMap object returns nil",
134+
obj: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: ns}},
135+
expected: nil,
136+
},
137+
}
138+
139+
for _, tt := range tests {
140+
t.Run(tt.name, func(t *testing.T) {
141+
t.Parallel()
142+
requests := reconciler.mapAuthzConfigMapToVirtualMCPServer(t.Context(), tt.obj)
143+
require.Len(t, requests, len(tt.expected))
144+
got := make([]types.NamespacedName, 0, len(requests))
145+
for _, r := range requests {
146+
got = append(got, r.NamespacedName)
147+
}
148+
assert.ElementsMatch(t, tt.expected, got)
149+
})
150+
}
151+
}
152+
153+
func TestConfigMapDataChangedPredicate(t *testing.T) {
154+
t.Parallel()
155+
156+
p := configMapDataChangedPredicate()
157+
158+
baseCM := &corev1.ConfigMap{
159+
ObjectMeta: metav1.ObjectMeta{Name: "cm", Namespace: "default"},
160+
Data: map[string]string{"authz.json": `{"policies":[]}`},
161+
}
162+
163+
t.Run("create event is admitted", func(t *testing.T) {
164+
t.Parallel()
165+
assert.True(t, p.Create(event.CreateEvent{Object: baseCM}))
166+
})
167+
168+
t.Run("delete event is admitted", func(t *testing.T) {
169+
t.Parallel()
170+
assert.True(t, p.Delete(event.DeleteEvent{Object: baseCM}))
171+
})
172+
173+
t.Run("generic event is rejected", func(t *testing.T) {
174+
t.Parallel()
175+
assert.False(t, p.Generic(event.GenericEvent{Object: baseCM}))
176+
})
177+
178+
t.Run("update event with data change is admitted", func(t *testing.T) {
179+
t.Parallel()
180+
oldCM := baseCM.DeepCopy()
181+
newCM := baseCM.DeepCopy()
182+
newCM.Data = map[string]string{"authz.json": `{"policies":["permit(principal, action, resource);"]}`}
183+
assert.True(t, p.Update(event.UpdateEvent{ObjectOld: oldCM, ObjectNew: newCM}))
184+
})
185+
186+
t.Run("update event with binary data change is admitted", func(t *testing.T) {
187+
t.Parallel()
188+
oldCM := baseCM.DeepCopy()
189+
newCM := baseCM.DeepCopy()
190+
newCM.BinaryData = map[string][]byte{"blob": []byte("x")}
191+
assert.True(t, p.Update(event.UpdateEvent{ObjectOld: oldCM, ObjectNew: newCM}))
192+
})
193+
194+
t.Run("update event with label-only change is rejected", func(t *testing.T) {
195+
t.Parallel()
196+
oldCM := baseCM.DeepCopy()
197+
newCM := baseCM.DeepCopy()
198+
newCM.Labels = map[string]string{"updated": "true"}
199+
assert.False(t, p.Update(event.UpdateEvent{ObjectOld: oldCM, ObjectNew: newCM}))
200+
})
201+
202+
t.Run("update event with annotation-only change is rejected", func(t *testing.T) {
203+
t.Parallel()
204+
oldCM := baseCM.DeepCopy()
205+
newCM := baseCM.DeepCopy()
206+
newCM.Annotations = map[string]string{"updated": "true"}
207+
assert.False(t, p.Update(event.UpdateEvent{ObjectOld: oldCM, ObjectNew: newCM}))
208+
})
209+
210+
t.Run("update event with no changes is rejected", func(t *testing.T) {
211+
t.Parallel()
212+
oldCM := baseCM.DeepCopy()
213+
newCM := baseCM.DeepCopy()
214+
assert.False(t, p.Update(event.UpdateEvent{ObjectOld: oldCM, ObjectNew: newCM}))
215+
})
216+
217+
t.Run("update event on non-configMap object is rejected", func(t *testing.T) {
218+
t.Parallel()
219+
oldObj := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "s", Namespace: "default"}}
220+
newObj := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "s", Namespace: "default"}}
221+
assert.False(t, p.Update(event.UpdateEvent{ObjectOld: oldObj, ObjectNew: newObj}))
222+
})
223+
}

cmd/thv-operator/controllers/virtualmcpserver_controller.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"k8s.io/apimachinery/pkg/types"
2828
"k8s.io/client-go/tools/events"
2929
ctrl "sigs.k8s.io/controller-runtime"
30+
"sigs.k8s.io/controller-runtime/pkg/builder"
3031
"sigs.k8s.io/controller-runtime/pkg/client"
3132
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
3233
"sigs.k8s.io/controller-runtime/pkg/handler"
@@ -2594,6 +2595,15 @@ func (r *VirtualMCPServerReconciler) SetupWithManager(mgr ctrl.Manager) error {
25942595
&mcpv1beta1.MCPTelemetryConfig{},
25952596
handler.EnqueueRequestsFromMapFunc(r.mapTelemetryConfigToVirtualMCPServer),
25962597
).
2598+
// Watch ConfigMaps referenced via spec.incomingAuth.authzConfig.configMap so that
2599+
// policy changes trigger reconciliation. The predicate filters out metadata-only
2600+
// updates; the mapper narrows to VirtualMCPServers that actually reference the
2601+
// changed ConfigMap. See #5270.
2602+
Watches(
2603+
&corev1.ConfigMap{},
2604+
handler.EnqueueRequestsFromMapFunc(r.mapAuthzConfigMapToVirtualMCPServer),
2605+
builder.WithPredicates(configMapDataChangedPredicate()),
2606+
).
25972607
Complete(r)
25982608
}
25992609

0 commit comments

Comments
 (0)