Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions cmd/thv-operator/controllers/mcpremoteproxy_deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,22 @@ func (r *MCPRemoteProxyReconciler) buildEnvVarsForProxy(
} else {
env = append(env, bearerTokenEnvVars...)
}

// Add OBO secret environment variables. Dispatched through the
// registered OBO handler; inert (no env vars) in builds without one.
// This function feeds both the deployment builder and containerNeedsUpdate,
// so builder/drift symmetry is automatic.
oboEnvVars, err := ctrlutil.AddOBOSecretEnvVars(
ctx,
r.Client,
proxy.Namespace,
proxy.Spec.ExternalAuthConfigRef,
)
if err != nil {
logOBOSecretEnvVarError(ctx, err)
} else {
env = append(env, oboEnvVars...)
}
}

// Add header forward secret environment variables
Expand Down
75 changes: 75 additions & 0 deletions cmd/thv-operator/controllers/mcpremoteproxy_deployment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,81 @@ func TestMCPRemoteProxyDeploymentNeedsUpdate_TokenExchangeDoesNotDrift(t *testin
assert.False(t, reconciler.deploymentNeedsUpdate(t.Context(), deployment, proxy, "test-checksum"))
}

// TestMCPRemoteProxyDeployment_OBOSecretEnvVars verifies that an obo-typed
// MCPExternalAuthConfig referenced from an MCPRemoteProxy injects the registered
// OBOHandler.SecretEnvVars output into the proxy container, and that the
// deployment builder and drift check agree on it so a correctly-configured
// resource does not hot-loop. A stub OBO handler stands in for the out-of-tree
// enterprise handler.
//
//nolint:paralleltest // Mutates package-level oboHandler via RegisterOBOHandler.
func TestMCPRemoteProxyDeployment_OBOSecretEnvVars(t *testing.T) {
t.Cleanup(func() { ctrlutil.RegisterOBOHandler(defaultOBOHandlerStub()) })

oboEnvVar := corev1.EnvVar{
Name: "TOOLHIVE_OBO_CLIENT_SECRET",
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: "obo-secret"},
Key: "client-secret",
},
},
}
stub := defaultOBOHandlerStub()
stub.SecretEnvVars = func(*mcpv1beta1.MCPExternalAuthConfig) ([]corev1.EnvVar, error) {
return []corev1.EnvVar{oboEnvVar}, nil
}
ctrlutil.RegisterOBOHandler(stub)

scheme := createRunConfigTestScheme()

authConfig := &mcpv1beta1.MCPExternalAuthConfig{
ObjectMeta: metav1.ObjectMeta{
Name: "obo-config",
Namespace: "default",
},
Spec: mcpv1beta1.MCPExternalAuthConfigSpec{
Type: mcpv1beta1.ExternalAuthTypeOBO,
OBO: &mcpv1beta1.OBOConfig{},
},
}

proxy := &mcpv1beta1.MCPRemoteProxy{
ObjectMeta: metav1.ObjectMeta{
Name: "test-proxy",
Namespace: "default",
},
Spec: mcpv1beta1.MCPRemoteProxySpec{
RemoteURL: "https://mcp.example.com",
ProxyPort: 8080,
ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{
Name: authConfig.Name,
},
},
}

fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithRuntimeObjects(authConfig).
Build()

reconciler := &MCPRemoteProxyReconciler{
Client: fakeClient,
Scheme: scheme,
PlatformDetector: ctrlutil.NewSharedPlatformDetector(),
}

deployment := reconciler.deploymentForMCPRemoteProxy(t.Context(), proxy, "test-checksum")
require.NotNil(t, deployment)

container := deployment.Spec.Template.Spec.Containers[0]
assert.Contains(t, container.Env, oboEnvVar,
"OBO handler SecretEnvVars output must be injected into the proxy container")

assert.False(t, reconciler.deploymentNeedsUpdate(t.Context(), deployment, proxy, "test-checksum"),
"freshly built deployment with an OBO env var must not be seen as drifted")
}

func TestMCPRemoteProxyDeploymentNeedsUpdate_ImagePullSecretsDrift(t *testing.T) {
t.Parallel()

Expand Down
53 changes: 53 additions & 0 deletions cmd/thv-operator/controllers/mcpserver_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package controllers
import (
"context"
"encoding/json"
stderrors "errors"
"fmt"
"maps"
"os"
Expand Down Expand Up @@ -41,6 +42,7 @@ import (
"github.com/stacklok/toolhive/cmd/thv-operator/pkg/kubernetes/rbac"
"github.com/stacklok/toolhive/cmd/thv-operator/pkg/runconfig/configmap/checksum"
"github.com/stacklok/toolhive/cmd/thv-operator/pkg/validation"
"github.com/stacklok/toolhive/pkg/auth/obo"
"github.com/stacklok/toolhive/pkg/container/kubernetes"
"github.com/stacklok/toolhive/pkg/transport"
"github.com/stacklok/toolhive/pkg/transport/session"
Expand Down Expand Up @@ -1003,6 +1005,26 @@ func (r *MCPServerReconciler) imagePullSecretsForMCPServer(
return r.ImagePullSecretsDefaults.Merge(crLevel)
}

// logOBOSecretEnvVarError logs an error from OBO secret env var generation
// without leaking sensitive detail. The OBOHandler error contract only
// guarantees *obo.ValidationError.Message is safe to surface; the transient
// bucket (e.g. "secret obo-secret/client-secret not found", JWKS URLs) carries
// no such guarantee, so anything that is not a ValidationError is logged
// generically with a pointer to the MCPExternalAuthConfig status, which the
// MCPExternalAuthConfig reconciler populates with the triaged detail. Shared by
// the MCPServer and MCPRemoteProxy proxy-env builders.
func logOBOSecretEnvVarError(ctx context.Context, err error) {
logger := log.FromContext(ctx)
var validationErr *obo.ValidationError
if stderrors.As(err, &validationErr) {
logger.Error(err, "Failed to generate OBO secret environment variables")
return
}
logger.Error(nil,
"Failed to generate OBO secret environment variables; "+
"see the referenced MCPExternalAuthConfig status for details")
}

// deploymentForMCPServer returns a MCPServer Deployment object
//
//nolint:gocyclo
Expand Down Expand Up @@ -1100,6 +1122,18 @@ func (r *MCPServerReconciler) deploymentForMCPServer(
} else {
env = append(env, tokenExchangeEnvVars...)
}

// Add OBO secret environment variables. Dispatched through the
// registered OBO handler; inert (no env vars) in builds without one.
// Must mirror deploymentNeedsUpdate exactly to avoid reconcile drift.
oboEnvVars, err := ctrlutil.AddOBOSecretEnvVars(
ctx, r.Client, m.Namespace, m.Spec.ExternalAuthConfigRef,
)
if err != nil {
logOBOSecretEnvVarError(ctx, err)
} else {
env = append(env, oboEnvVars...)
}
}

// Validate webhook config and add mounted webhook secrets.
Expand Down Expand Up @@ -1741,6 +1775,25 @@ func (r *MCPServerReconciler) deploymentNeedsUpdate(
return true
}
expectedProxyEnv = append(expectedProxyEnv, tokenExchangeEnvVars...)

// Add OBO secret environment variables. Must mirror
// deploymentForMCPServer exactly (same call, same position) so a
// correctly-configured resource does not look perpetually drifted.
// In handler-less builds the dispatcher swallows ErrEnterpriseRequired
// to (nil, nil), keeping this path symmetric with the builder. A
// genuine handler error returns true here while the builder logs and
// continues, so as long as the handler keeps erroring the operator
// re-applies an identical (OBO-env-less) Deployment each reconcile; it
// stops only once the handler succeeds. This mirrors the token-exchange
// block above; unifying both (requeue on genuine error so neither side
// acts) is out of scope for this change.
oboEnvVars, err := ctrlutil.AddOBOSecretEnvVars(
ctx, r.Client, mcpServer.Namespace, mcpServer.Spec.ExternalAuthConfigRef,
)
if err != nil {
Comment thread
tgrunnagle marked this conversation as resolved.
return true
}
expectedProxyEnv = append(expectedProxyEnv, oboEnvVars...)
}

// Validate webhook config. Webhook secrets are mounted as files when the deployment is built.
Expand Down
127 changes: 127 additions & 0 deletions cmd/thv-operator/controllers/mcpserver_externalauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package controllers

import (
"context"
"errors"
"testing"
"time"

Expand All @@ -29,6 +30,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client/fake"

mcpv1beta1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1beta1"
ctrlutil "github.com/stacklok/toolhive/cmd/thv-operator/pkg/controllerutil"
"github.com/stacklok/toolhive/pkg/container/kubernetes"
)

Expand Down Expand Up @@ -716,3 +718,128 @@ func TestMCPServerReconciler_handleExternalAuthConfig_ClearsMirrorOnSourceNotFou
meta.FindStatusCondition(mcpServer.Status.Conditions, mcpv1beta1.ConditionTypeExternalAuthConfigValidated),
"stale mirror must be cleared when the referenced source is NotFound")
}

// TestMCPServerDeployment_OBOSecretEnvVars verifies that an obo-typed
// MCPExternalAuthConfig referenced from an MCPServer injects the registered
// OBOHandler.SecretEnvVars output into the proxy container, and that the
// deployment builder (deploymentForMCPServer) and the drift check
// (deploymentNeedsUpdate) agree on it. MCPServer assembles env in two separate
// functions, so this guards the builder/drift symmetry that prevents a reconcile
// hot-loop. A stub OBO handler stands in for the out-of-tree enterprise handler.
//
//nolint:paralleltest // Mutates package-level oboHandler via RegisterOBOHandler.
func TestMCPServerDeployment_OBOSecretEnvVars(t *testing.T) {
t.Cleanup(func() { ctrlutil.RegisterOBOHandler(defaultOBOHandlerStub()) })

oboEnvVar := corev1.EnvVar{
Name: "TOOLHIVE_OBO_CLIENT_SECRET",
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: "obo-secret"},
Key: "client-secret",
},
},
}
stub := defaultOBOHandlerStub()
stub.SecretEnvVars = func(*mcpv1beta1.MCPExternalAuthConfig) ([]corev1.EnvVar, error) {
return []corev1.EnvVar{oboEnvVar}, nil
}
ctrlutil.RegisterOBOHandler(stub)

scheme := runtime.NewScheme()
require.NoError(t, mcpv1beta1.AddToScheme(scheme))
require.NoError(t, corev1.AddToScheme(scheme))

authConfig := &mcpv1beta1.MCPExternalAuthConfig{
ObjectMeta: metav1.ObjectMeta{Name: "obo-config", Namespace: "default"},
Spec: mcpv1beta1.MCPExternalAuthConfigSpec{
Type: mcpv1beta1.ExternalAuthTypeOBO,
OBO: &mcpv1beta1.OBOConfig{},
},
}
mcpServer := &mcpv1beta1.MCPServer{
ObjectMeta: metav1.ObjectMeta{Name: "test-server", Namespace: "default"},
Spec: mcpv1beta1.MCPServerSpec{
Image: "test-image:latest",
Transport: "stdio",
ProxyPort: 8080,
ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{Name: authConfig.Name},
},
}

fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(authConfig).
Build()

reconciler := newTestMCPServerReconciler(fakeClient, scheme, kubernetes.PlatformKubernetes)

deployment, err := reconciler.deploymentForMCPServer(t.Context(), mcpServer, "test-checksum")
require.NoError(t, err)
require.NotNil(t, deployment)

container := deployment.Spec.Template.Spec.Containers[0]
assert.Contains(t, container.Env, oboEnvVar,
"OBO handler SecretEnvVars output must be injected into the proxy container")

assert.False(t, reconciler.deploymentNeedsUpdate(t.Context(), deployment, mcpServer, "test-checksum"),
"builder and drift check must agree on the OBO env var (no reconcile hot-loop)")
}

// TestMCPServerDeployment_OBOSecretEnvVars_GenuineErrorDivergence locks in the
// documented MCPServer builder/drift behavior when the registered OBO handler
// returns a genuine (non-ErrEnterpriseRequired) error: the builder logs and
// continues, producing an OBO-env-less Deployment without failing, while
// deploymentNeedsUpdate reports the Deployment needs an update. This divergence
// is described at the drift site and exercised nowhere else at the controller
// level. (The inert ErrEnterpriseRequired path stays symmetric — see the test
// above — because AddOBOSecretEnvVars swallows it.)
//
//nolint:paralleltest // Mutates package-level oboHandler via RegisterOBOHandler.
func TestMCPServerDeployment_OBOSecretEnvVars_GenuineErrorDivergence(t *testing.T) {
t.Cleanup(func() { ctrlutil.RegisterOBOHandler(defaultOBOHandlerStub()) })

stub := defaultOBOHandlerStub()
stub.SecretEnvVars = func(*mcpv1beta1.MCPExternalAuthConfig) ([]corev1.EnvVar, error) {
return nil, errors.New("secret not yet available")
}
ctrlutil.RegisterOBOHandler(stub)

scheme := runtime.NewScheme()
require.NoError(t, mcpv1beta1.AddToScheme(scheme))
require.NoError(t, corev1.AddToScheme(scheme))

authConfig := &mcpv1beta1.MCPExternalAuthConfig{
ObjectMeta: metav1.ObjectMeta{Name: "obo-config", Namespace: "default"},
Spec: mcpv1beta1.MCPExternalAuthConfigSpec{
Type: mcpv1beta1.ExternalAuthTypeOBO,
OBO: &mcpv1beta1.OBOConfig{},
},
}
mcpServer := &mcpv1beta1.MCPServer{
ObjectMeta: metav1.ObjectMeta{Name: "test-server", Namespace: "default"},
Spec: mcpv1beta1.MCPServerSpec{
Image: "test-image:latest",
Transport: "stdio",
ProxyPort: 8080,
ExternalAuthConfigRef: &mcpv1beta1.ExternalAuthConfigRef{Name: authConfig.Name},
},
}

fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(authConfig).
Build()

reconciler := newTestMCPServerReconciler(fakeClient, scheme, kubernetes.PlatformKubernetes)

// Builder logs-and-continues: a Deployment is still produced (no error, no
// panic), with no OBO env var injected.
deployment, err := reconciler.deploymentForMCPServer(t.Context(), mcpServer, "test-checksum")
require.NoError(t, err, "builder must log-and-continue on a genuine OBO handler error, not fail")
require.NotNil(t, deployment)

// Drift check returns true on the same error — the documented divergence.
assert.True(t, reconciler.deploymentNeedsUpdate(t.Context(), deployment, mcpServer, "test-checksum"),
"deploymentNeedsUpdate reports drift while the OBO handler errors")
}
Loading
Loading