Skip to content

Commit 7cff3cd

Browse files
jhrozekclaude
andauthored
Wire embedded auth server into VirtualMCPServer converter and deployment (#4383)
* Wire embedded auth server into VirtualMCPServer converter and deployment The VirtualMCPServer operator lacked the ability to convert an inline EmbeddedAuthServerConfig into an authserver.RunConfig and mount the necessary secrets onto the vMCP pod. Export BuildAuthServerRunConfig from controllerutil (previously the unexported buildEmbeddedAuthServerRunnerConfig used only by MCPServer) and call it from the VirtualMCPServer converter. This avoids duplicating ~450 lines of conversion logic (signing keys, HMAC secrets, upstream providers, Redis storage with Sentinel) and ensures both controllers use identical mount paths, env var naming, and Redis key prefix format. Operator side: serialize the RunConfig as a separate ConfigMap key (authserver-config.yaml) alongside config.yaml. Mount auth server volumes and env vars onto the deployment via GenerateAuthServerVolumes and GenerateAuthServerEnvVars. Cross-validate auth server config against backend strategies via ValidateAuthServerIntegration. Binary side: load sibling authserver-config.yaml if present, construct EmbeddedAuthServer, and pass it to the server config. Fail closed on read errors other than file-not-found. Fixes #4284 * Fix E2E test to use OIDC incoming auth with auth server config ValidateAuthServerIntegration (added in 0810bda) requires OIDC incoming auth when AuthServerConfig is present. The pre-existing E2E test used anonymous auth, causing the condition to be overwritten to False and the test to time out. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Extract convertSessionStorage to reduce Convert cyclomatic complexity The Convert function exceeded the gocyclo threshold (17 > 15) after the rebase added both SessionStorage and AuthServerConfig conversion. Extract the SessionStorage block into a standalone helper. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 70845b5 commit 7cff3cd

11 files changed

Lines changed: 679 additions & 92 deletions

File tree

cmd/thv-operator/controllers/virtualmcpserver_controller.go

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"context"
1010
"crypto/rand"
1111
"encoding/base64"
12+
stderrors "errors"
1213
"fmt"
1314
"maps"
1415
"reflect"
@@ -71,6 +72,17 @@ type AuthConfigError struct {
7172
Error error
7273
}
7374

75+
// SpecValidationError represents a spec validation failure that the user must fix.
76+
// Unlike transient errors, these should NOT trigger requeue — the controller sets
77+
// a status condition and waits for the user to update the spec.
78+
type SpecValidationError struct {
79+
Message string
80+
}
81+
82+
func (e *SpecValidationError) Error() string {
83+
return e.Message
84+
}
85+
7486
// VirtualMCPServerReconciler reconciles a VirtualMCPServer object
7587
//
7688
// Resource Cleanup Strategy:
@@ -419,6 +431,26 @@ func (*VirtualMCPServerReconciler) validateAuthServerConfig(
419431
return nil
420432
}
421433

434+
// handleSpecValidationError checks whether err is a SpecValidationError (user must fix the spec).
435+
// If so, it applies the already-set status conditions and returns nil (no requeue).
436+
// Otherwise it returns the original error unchanged for normal requeue handling.
437+
func (r *VirtualMCPServerReconciler) handleSpecValidationError(
438+
ctx context.Context,
439+
vmcp *mcpv1alpha1.VirtualMCPServer,
440+
statusManager virtualmcpserverstatus.StatusManager,
441+
err error,
442+
) error {
443+
var specErr *SpecValidationError
444+
if !stderrors.As(err, &specErr) {
445+
return err
446+
}
447+
ctxLogger := log.FromContext(ctx)
448+
if applyErr := r.applyStatusUpdates(ctx, vmcp, statusManager); applyErr != nil {
449+
ctxLogger.Error(applyErr, "Failed to apply status updates after spec validation error")
450+
}
451+
return nil
452+
}
453+
422454
// validateGroupRef validates that the referenced MCPGroup exists and is ready
423455
func (r *VirtualMCPServerReconciler) validateGroupRef(
424456
ctx context.Context,
@@ -696,8 +728,11 @@ func (r *VirtualMCPServerReconciler) ensureAllResources(
696728
return ctrl.Result{}, err
697729
}
698730

699-
// Ensure vmcp Config ConfigMap
700-
if err := r.ensureVmcpConfigConfigMap(ctx, vmcp, workloadNames, statusManager); err != nil {
731+
// Ensure vmcp Config ConfigMap.
732+
// handleSpecValidationError converts SpecValidationError to nil (no requeue)
733+
// after applying status conditions, while passing through transient errors.
734+
if err := r.handleSpecValidationError(ctx, vmcp, statusManager,
735+
r.ensureVmcpConfigConfigMap(ctx, vmcp, workloadNames, statusManager)); err != nil {
701736
ctxLogger.Error(err, "Failed to ensure vmcp Config ConfigMap")
702737
return ctrl.Result{}, err
703738
}

cmd/thv-operator/controllers/virtualmcpserver_deployment.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,15 @@ func (r *VirtualMCPServerReconciler) deploymentForVirtualMCPServer(
126126
args := r.buildContainerArgsForVmcp(vmcp)
127127
volumeMounts, volumes := r.buildVolumesForVmcp(vmcp)
128128
env := r.buildEnvVarsForVmcp(ctx, vmcp, typedWorkloads)
129+
130+
// Add embedded auth server volumes and env vars if configured (inline config)
131+
if vmcp.Spec.AuthServerConfig != nil {
132+
authServerVolumes, authServerMounts := ctrlutil.GenerateAuthServerVolumes(vmcp.Spec.AuthServerConfig)
133+
authServerEnvVars := ctrlutil.GenerateAuthServerEnvVars(vmcp.Spec.AuthServerConfig)
134+
volumes = append(volumes, authServerVolumes...)
135+
volumeMounts = append(volumeMounts, authServerMounts...)
136+
env = append(env, authServerEnvVars...)
137+
}
129138
deploymentLabels, deploymentAnnotations := r.buildDeploymentMetadataForVmcp(ls, vmcp)
130139
deploymentTemplateLabels, deploymentTemplateAnnotations := r.buildPodTemplateMetadata(ls, vmcp, vmcpConfigChecksum)
131140
podSecurityContext, containerSecurityContext := r.buildSecurityContextsForVmcp(ctx, vmcp)

cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig.go

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ func (r *VirtualMCPServerReconciler) ensureVmcpConfigConfigMap(
4141
if err != nil {
4242
return fmt.Errorf("failed to create vmcp converter: %w", err)
4343
}
44-
config, err := converter.Convert(ctx, vmcp)
44+
config, authServerRC, err := converter.Convert(ctx, vmcp)
4545
if err != nil {
4646
return fmt.Errorf("failed to create vmcp Config from VirtualMCPServer: %w", err)
4747
}
@@ -62,7 +62,23 @@ func (r *VirtualMCPServerReconciler) ensureVmcpConfigConfigMap(
6262
return fmt.Errorf("invalid vmcp Config: %w", err)
6363
}
6464

65-
// Marshal to YAML for storage in ConfigMap
65+
// Cross-validate auth server RunConfig against backend strategies.
66+
// TODO: Move this into the operator's vmcpconfig.Validator wrapper so callers
67+
// don't need to know about the two-step validation sequence.
68+
if err := vmcpconfig.ValidateAuthServerIntegration(config, authServerRC); err != nil {
69+
message := fmt.Sprintf("invalid auth server integration: %v", err)
70+
statusManager.SetPhase(mcpv1alpha1.VirtualMCPServerPhaseFailed)
71+
statusManager.SetMessage(message)
72+
statusManager.SetAuthServerConfigValidatedCondition(
73+
mcpv1alpha1.ConditionReasonAuthServerConfigInvalid,
74+
message,
75+
metav1.ConditionFalse,
76+
)
77+
statusManager.SetObservedGeneration(vmcp.Generation)
78+
return &SpecValidationError{Message: message}
79+
}
80+
81+
// Marshal the serializable Config to YAML for storage in ConfigMap.
6682
// Note: gopkg.in/yaml.v3 produces deterministic output by sorting map keys alphabetically.
6783
// This ensures stable checksums for triggering pod rollouts only when content actually changes.
6884
vmcpConfigYAML, err := yaml.Marshal(config)
@@ -71,15 +87,28 @@ func (r *VirtualMCPServerReconciler) ensureVmcpConfigConfigMap(
7187
}
7288

7389
configMapName := vmcpConfigMapName(vmcp.Name)
90+
configMapData := map[string]string{
91+
"config.yaml": string(vmcpConfigYAML),
92+
}
93+
94+
// If an embedded auth server is configured, serialize its RunConfig as a separate key.
95+
// RunConfig contains only references (file paths, env var names) — never actual secrets —
96+
// so it is safe for ConfigMap storage. The vMCP binary loads this alongside config.yaml.
97+
if authServerRC != nil {
98+
authServerYAML, marshalErr := yaml.Marshal(authServerRC)
99+
if marshalErr != nil {
100+
return fmt.Errorf("failed to marshal auth server config: %w", marshalErr)
101+
}
102+
configMapData["authserver-config.yaml"] = string(authServerYAML)
103+
}
104+
74105
configMap := &corev1.ConfigMap{
75106
ObjectMeta: metav1.ObjectMeta{
76107
Name: configMapName,
77108
Namespace: vmcp.Namespace,
78109
Labels: labelsForVmcpConfig(vmcp.Name),
79110
},
80-
Data: map[string]string{
81-
"config.yaml": string(vmcpConfigYAML),
82-
},
111+
Data: configMapData,
83112
}
84113

85114
// Compute and add content checksum annotation using robust SHA256-based checksum

cmd/thv-operator/controllers/virtualmcpserver_vmcpconfig_test.go

Lines changed: 120 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package controllers
55

66
import (
77
"context"
8+
stderrors "errors"
89
"fmt"
910
"testing"
1011
"time"
@@ -83,7 +84,7 @@ func TestCreateVmcpConfigFromVirtualMCPServer(t *testing.T) {
8384
t.Parallel()
8485

8586
converter := newTestConverter(t, newNoOpMockResolver(t))
86-
config, err := converter.Convert(context.Background(), tt.vmcp)
87+
config, _, err := converter.Convert(context.Background(), tt.vmcp)
8788

8889
require.NoError(t, err)
8990
assert.NotNil(t, config)
@@ -154,7 +155,7 @@ func TestConvertOutgoingAuth(t *testing.T) {
154155
}
155156

156157
converter := newTestConverter(t, newNoOpMockResolver(t))
157-
config, err := converter.Convert(context.Background(), vmcpServer)
158+
config, _, err := converter.Convert(context.Background(), vmcpServer)
158159
require.NoError(t, err)
159160

160161
require.NotNil(t, config.OutgoingAuth)
@@ -245,7 +246,7 @@ func TestConvertBackendAuthConfig(t *testing.T) {
245246
converter = newTestConverter(t, newNoOpMockResolver(t))
246247
}
247248

248-
config, err := converter.Convert(context.Background(), vmcpServer)
249+
config, _, err := converter.Convert(context.Background(), vmcpServer)
249250
require.NoError(t, err)
250251

251252
require.NotNil(t, config.OutgoingAuth)
@@ -339,7 +340,7 @@ func TestConvertAggregation(t *testing.T) {
339340
}
340341

341342
converter := newTestConverter(t, newNoOpMockResolver(t))
342-
config, err := converter.Convert(context.Background(), vmcpServer)
343+
config, _, err := converter.Convert(context.Background(), vmcpServer)
343344
require.NoError(t, err)
344345

345346
require.NotNil(t, config.Aggregation)
@@ -432,7 +433,7 @@ func TestConvertCompositeTools(t *testing.T) {
432433
}
433434

434435
converter := newTestConverter(t, newNoOpMockResolver(t))
435-
config, err := converter.Convert(context.Background(), vmcpServer)
436+
config, _, err := converter.Convert(context.Background(), vmcpServer)
436437
require.NoError(t, err)
437438

438439
tools := config.CompositeTools
@@ -1072,10 +1073,11 @@ func TestYAMLMarshalingDeterminism(t *testing.T) {
10721073
results := make([]string, iterations)
10731074

10741075
for i := 0; i < iterations; i++ {
1075-
config, err := converter.Convert(context.Background(), testVmcp)
1076+
cfg, _, err := converter.Convert(context.Background(), testVmcp)
10761077
require.NoError(t, err)
10771078

1078-
yamlBytes, err := yaml.Marshal(config)
1079+
// Marshal the Config to YAML.
1080+
yamlBytes, err := yaml.Marshal(cfg)
10791081
require.NoError(t, err)
10801082

10811083
results[i] = string(yamlBytes)
@@ -2074,3 +2076,114 @@ func TestConfigMapContent_SessionStorage(t *testing.T) {
20742076
})
20752077
}
20762078
}
2079+
2080+
// TestEnsureVmcpConfigConfigMap_AuthServerIntegrationValidationError verifies that
2081+
// ensureVmcpConfigConfigMap returns a SpecValidationError and sets the correct status
2082+
// conditions when ValidateAuthServerIntegration fails.
2083+
//
2084+
// The test triggers the issuer-mismatch path: AuthServerConfig.Issuer differs from
2085+
// IncomingAuth.OIDC.Issuer, causing validateAuthServerIncomingAuthConsistency to fail.
2086+
func TestEnsureVmcpConfigConfigMap_AuthServerIntegrationValidationError(t *testing.T) {
2087+
t.Parallel()
2088+
2089+
const (
2090+
incomingIssuer = "https://incoming-auth.example.com"
2091+
authServerIssuer = "https://different-auth-server.example.com"
2092+
audience = "https://api.example.com"
2093+
clientID = "test-client-id"
2094+
upstreamIssuerURL = "https://upstream-idp.example.com"
2095+
)
2096+
2097+
testVmcp := &mcpv1alpha1.VirtualMCPServer{
2098+
ObjectMeta: metav1.ObjectMeta{
2099+
Name: "test-vmcp",
2100+
Namespace: "default",
2101+
Generation: 3,
2102+
},
2103+
Spec: mcpv1alpha1.VirtualMCPServerSpec{
2104+
Config: vmcpconfig.Config{Group: "test-group"},
2105+
IncomingAuth: &mcpv1alpha1.IncomingAuthConfig{
2106+
Type: "oidc",
2107+
OIDCConfig: &mcpv1alpha1.OIDCConfigRef{
2108+
Type: mcpv1alpha1.OIDCConfigTypeInline,
2109+
Inline: &mcpv1alpha1.InlineOIDCConfig{
2110+
Issuer: incomingIssuer,
2111+
Audience: audience,
2112+
ClientID: clientID,
2113+
},
2114+
},
2115+
},
2116+
AuthServerConfig: &mcpv1alpha1.EmbeddedAuthServerConfig{
2117+
Issuer: authServerIssuer,
2118+
SigningKeySecretRefs: []mcpv1alpha1.SecretKeyRef{
2119+
{Name: "signing-key-secret", Key: "key.pem"},
2120+
},
2121+
UpstreamProviders: []mcpv1alpha1.UpstreamProviderConfig{
2122+
{
2123+
Name: "corporate-idp",
2124+
Type: mcpv1alpha1.UpstreamProviderTypeOIDC,
2125+
OIDCConfig: &mcpv1alpha1.OIDCUpstreamConfig{
2126+
IssuerURL: upstreamIssuerURL,
2127+
ClientID: "upstream-client-id",
2128+
},
2129+
},
2130+
},
2131+
},
2132+
},
2133+
}
2134+
2135+
mcpGroup := &mcpv1alpha1.MCPGroup{
2136+
ObjectMeta: metav1.ObjectMeta{
2137+
Name: "test-group",
2138+
Namespace: "default",
2139+
},
2140+
Spec: mcpv1alpha1.MCPGroupSpec{},
2141+
}
2142+
2143+
scheme := runtime.NewScheme()
2144+
require.NoError(t, mcpv1alpha1.AddToScheme(scheme))
2145+
require.NoError(t, corev1.AddToScheme(scheme))
2146+
2147+
fakeClient := fake.NewClientBuilder().
2148+
WithScheme(scheme).
2149+
WithObjects(testVmcp, mcpGroup).
2150+
Build()
2151+
2152+
r := &VirtualMCPServerReconciler{
2153+
Client: fakeClient,
2154+
Scheme: scheme,
2155+
}
2156+
2157+
ctx := context.Background()
2158+
workloadDiscoverer := workloads.NewK8SDiscovererWithClient(fakeClient, testVmcp.Namespace)
2159+
workloadNames, err := workloadDiscoverer.ListWorkloadsInGroup(ctx, testVmcp.Spec.Config.Group)
2160+
require.NoError(t, err)
2161+
2162+
// Use a mock StatusManager so we can verify the exact conditions set on failure.
2163+
mockCtrl := gomock.NewController(t)
2164+
mockStatus := statusmocks.NewMockStatusManager(mockCtrl)
2165+
2166+
// processOutgoingAuth (discovered mode, no OutgoingAuth on CRD) cleans up stale conditions.
2167+
mockStatus.EXPECT().RemoveConditionsWithPrefix("DefaultAuthConfig", []string{}).Times(1)
2168+
mockStatus.EXPECT().RemoveConditionsWithPrefix("DiscoveredAuthConfig-", []string{}).Times(1)
2169+
mockStatus.EXPECT().RemoveConditionsWithPrefix("BackendAuthConfig-", []string{}).Times(1)
2170+
2171+
// ValidateAuthServerIntegration failure: issuer mismatch sets Failed phase and condition.
2172+
mockStatus.EXPECT().SetPhase(mcpv1alpha1.VirtualMCPServerPhaseFailed).Times(1)
2173+
mockStatus.EXPECT().SetMessage(gomock.Any()).Times(1).Do(func(message string) {
2174+
assert.Contains(t, message, "invalid auth server integration")
2175+
})
2176+
mockStatus.EXPECT().SetAuthServerConfigValidatedCondition(
2177+
mcpv1alpha1.ConditionReasonAuthServerConfigInvalid,
2178+
gomock.Any(),
2179+
metav1.ConditionFalse,
2180+
).Times(1)
2181+
mockStatus.EXPECT().SetObservedGeneration(testVmcp.Generation).Times(1)
2182+
2183+
err = r.ensureVmcpConfigConfigMap(ctx, testVmcp, workloadNames, mockStatus)
2184+
2185+
// Verify the error is a SpecValidationError with the expected message.
2186+
var specErr *SpecValidationError
2187+
require.True(t, stderrors.As(err, &specErr), "expected a *SpecValidationError, got %T: %v", err, err)
2188+
assert.Contains(t, specErr.Message, "invalid auth server integration")
2189+
}

cmd/thv-operator/pkg/controllerutil/authserver.go

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,10 @@ func AddEmbeddedAuthServerConfigOptions(
396396
}
397397

398398
// Build the embedded auth server config for runner
399-
embeddedConfig, err := buildEmbeddedAuthServerRunnerConfig(namespace, mcpServerName, authServerConfig, oidcConfig)
399+
embeddedConfig, err := BuildAuthServerRunConfig(
400+
namespace, mcpServerName, authServerConfig,
401+
[]string{oidcConfig.ResourceURL}, oidcConfig.Scopes,
402+
)
400403
if err != nil {
401404
return fmt.Errorf("failed to build embedded auth server config: %w", err)
402405
}
@@ -407,24 +410,25 @@ func AddEmbeddedAuthServerConfigOptions(
407410
return nil
408411
}
409412

410-
// buildEmbeddedAuthServerRunnerConfig converts CRD EmbeddedAuthServerConfig to authserver.RunConfig.
413+
// BuildAuthServerRunConfig converts CRD EmbeddedAuthServerConfig to authserver.RunConfig.
411414
// The RunConfig is serializable and contains file paths for secrets (not the secrets themselves).
412415
//
413-
// The oidcConfig parameter provides:
414-
// - AllowedAudiences: from oidcConfig.ResourceURL (required, validated in AddEmbeddedAuthServerConfigOptions)
415-
// - ScopesSupported: from oidcConfig.Scopes (optional, nil uses auth server defaults)
416-
func buildEmbeddedAuthServerRunnerConfig(
416+
// AllowedAudiences and ScopesSupported are caller-provided because different controllers
417+
// derive them from different sources (MCPServer uses oidcConfig.ResourceURL/Scopes;
418+
// VirtualMCPServer derives from the resolved vmcp Config).
419+
func BuildAuthServerRunConfig(
417420
namespace string,
418-
mcpServerName string,
421+
name string,
419422
authConfig *mcpv1alpha1.EmbeddedAuthServerConfig,
420-
oidcConfig *oidc.OIDCConfig,
423+
allowedAudiences []string,
424+
scopesSupported []string,
421425
) (*authserver.RunConfig, error) {
422426
config := &authserver.RunConfig{
423427
SchemaVersion: authserver.CurrentSchemaVersion,
424428
Issuer: authConfig.Issuer,
425429
AuthorizationEndpointBaseURL: authConfig.AuthorizationEndpointBaseURL,
426-
AllowedAudiences: []string{oidcConfig.ResourceURL},
427-
ScopesSupported: oidcConfig.Scopes,
430+
AllowedAudiences: allowedAudiences,
431+
ScopesSupported: scopesSupported,
428432
}
429433

430434
// Build signing key configuration
@@ -466,7 +470,7 @@ func buildEmbeddedAuthServerRunnerConfig(
466470
}
467471

468472
// Build storage configuration
469-
storageCfg, err := buildStorageRunConfig(namespace, mcpServerName, authConfig)
473+
storageCfg, err := buildStorageRunConfig(namespace, name, authConfig)
470474
if err != nil {
471475
return nil, fmt.Errorf("failed to build storage config: %w", err)
472476
}

0 commit comments

Comments
 (0)