Skip to content

Commit cb30b33

Browse files
yroblataskbot
andauthored
add new configmap flag in proxy runner to read config from there (#1789)
Initially it will read the configmap and validate that it exists Related-to: #1638 Co-authored-by: taskbot <[email protected]>
1 parent 7e74099 commit cb30b33

File tree

15 files changed

+777
-46
lines changed

15 files changed

+777
-46
lines changed

cmd/thv-operator/controllers/mcpserver_controller.go

Lines changed: 66 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,21 @@ var defaultRBACRules = []rbacv1.PolicyRule{
7171
Resources: []string{"pods/attach"},
7272
Verbs: []string{"create", "get"},
7373
},
74+
{
75+
APIGroups: []string{""},
76+
Resources: []string{"configmaps"},
77+
Verbs: []string{"get", "list", "watch"},
78+
},
7479
}
7580

7681
var ctxLogger = log.FromContext(context.Background())
7782

7883
// mcpContainerName is the name of the mcp container used in pod templates
7984
const mcpContainerName = "mcp"
8085

86+
// trueValue is the string value "true" used for environment variable comparisons
87+
const trueValue = "true"
88+
8189
// Authorization ConfigMap label constants
8290
const (
8391
// authzLabelKey is the label key for authorization configuration type
@@ -463,38 +471,51 @@ func (r *MCPServerReconciler) deploymentForMCPServer(ctx context.Context, m *mcp
463471

464472
// Prepare container args
465473
args := []string{"run", "--foreground=true"}
466-
args = append(args, fmt.Sprintf("--proxy-port=%d", m.Spec.Port))
467-
args = append(args, fmt.Sprintf("--name=%s", m.Name))
468-
args = append(args, fmt.Sprintf("--transport=%s", m.Spec.Transport))
469-
args = append(args, fmt.Sprintf("--host=%s", getProxyHost()))
470474

471-
if m.Spec.TargetPort != 0 {
472-
args = append(args, fmt.Sprintf("--target-port=%d", m.Spec.TargetPort))
473-
}
474-
475-
// Generate pod template patch for secrets and merge with user-provided patch
476-
477-
finalPodTemplateSpec := NewMCPServerPodTemplateSpecBuilder(m.Spec.PodTemplateSpec).
478-
WithServiceAccount(m.Spec.ServiceAccount).
479-
WithSecrets(m.Spec.Secrets).
480-
Build()
481-
// Add pod template patch if we have one
482-
if finalPodTemplateSpec != nil {
483-
podTemplatePatch, err := json.Marshal(finalPodTemplateSpec)
484-
if err != nil {
485-
logger.Errorf("Failed to marshal pod template spec: %v", err)
486-
} else {
487-
args = append(args, fmt.Sprintf("--k8s-pod-patch=%s", string(podTemplatePatch)))
475+
// Check if global ConfigMap mode is enabled via environment variable
476+
useConfigMap := os.Getenv("TOOLHIVE_USE_CONFIGMAP") == trueValue
477+
if useConfigMap {
478+
// Use the operator-created ConfigMap (format: {name}-runconfig)
479+
configMapName := fmt.Sprintf("%s-runconfig", m.Name)
480+
configMapRef := fmt.Sprintf("%s/%s", m.Namespace, configMapName)
481+
args = append(args, fmt.Sprintf("--from-configmap=%s", configMapRef))
482+
} else {
483+
// Use individual configuration flags (existing behavior)
484+
args = append(args, fmt.Sprintf("--proxy-port=%d", m.Spec.Port))
485+
args = append(args, fmt.Sprintf("--name=%s", m.Name))
486+
args = append(args, fmt.Sprintf("--transport=%s", m.Spec.Transport))
487+
args = append(args, fmt.Sprintf("--host=%s", getProxyHost()))
488+
if m.Spec.TargetPort != 0 {
489+
args = append(args, fmt.Sprintf("--target-port=%d", m.Spec.TargetPort))
490+
}
491+
}
492+
493+
// Add pod template patch and permission profile only if not using ConfigMap
494+
// When using ConfigMap, these are included in the runconfig.json
495+
if !useConfigMap {
496+
// Generate pod template patch for secrets and merge with user-provided patch
497+
finalPodTemplateSpec := NewMCPServerPodTemplateSpecBuilder(m.Spec.PodTemplateSpec).
498+
WithServiceAccount(m.Spec.ServiceAccount).
499+
WithSecrets(m.Spec.Secrets).
500+
Build()
501+
// Add pod template patch if we have one
502+
if finalPodTemplateSpec != nil {
503+
podTemplatePatch, err := json.Marshal(finalPodTemplateSpec)
504+
if err != nil {
505+
logger.Errorf("Failed to marshal pod template spec: %v", err)
506+
} else {
507+
args = append(args, fmt.Sprintf("--k8s-pod-patch=%s", string(podTemplatePatch)))
508+
}
488509
}
489-
}
490510

491-
// Add permission profile args
492-
if m.Spec.PermissionProfile != nil {
493-
switch m.Spec.PermissionProfile.Type {
494-
case mcpv1alpha1.PermissionProfileTypeBuiltin:
495-
args = append(args, fmt.Sprintf("--permission-profile=%s", m.Spec.PermissionProfile.Name))
496-
case mcpv1alpha1.PermissionProfileTypeConfigMap:
497-
args = append(args, fmt.Sprintf("--permission-profile-path=/etc/toolhive/profiles/%s", m.Spec.PermissionProfile.Key))
511+
// Add permission profile args
512+
if m.Spec.PermissionProfile != nil {
513+
switch m.Spec.PermissionProfile.Type {
514+
case mcpv1alpha1.PermissionProfileTypeBuiltin:
515+
args = append(args, fmt.Sprintf("--permission-profile=%s", m.Spec.PermissionProfile.Name))
516+
case mcpv1alpha1.PermissionProfileTypeConfigMap:
517+
args = append(args, fmt.Sprintf("--permission-profile-path=/etc/toolhive/profiles/%s", m.Spec.PermissionProfile.Key))
518+
}
498519
}
499520
}
500521

@@ -526,15 +547,18 @@ func (r *MCPServerReconciler) deploymentForMCPServer(ctx context.Context, m *mcp
526547
args = append(args, "--enable-audit")
527548
}
528549

529-
// Add environment variables as --env flags for the MCP server
530-
for _, e := range m.Spec.Env {
531-
args = append(args, fmt.Sprintf("--env=%s=%s", e.Name, e.Value))
532-
}
550+
// Add environment variables and tools filter only if not using ConfigMap
551+
if !useConfigMap {
552+
// Add environment variables as --env flags for the MCP server
553+
for _, e := range m.Spec.Env {
554+
args = append(args, fmt.Sprintf("--env=%s=%s", e.Name, e.Value))
555+
}
533556

534-
// Add tools filter args
535-
if len(m.Spec.ToolsFilter) > 0 {
536-
slices.Sort(m.Spec.ToolsFilter)
537-
args = append(args, fmt.Sprintf("--tools=%s", strings.Join(m.Spec.ToolsFilter, ",")))
557+
// Add tools filter args
558+
if len(m.Spec.ToolsFilter) > 0 {
559+
slices.Sort(m.Spec.ToolsFilter)
560+
args = append(args, fmt.Sprintf("--tools=%s", strings.Join(m.Spec.ToolsFilter, ",")))
561+
}
538562
}
539563

540564
// Add OpenTelemetry configuration args
@@ -550,11 +574,13 @@ func (r *MCPServerReconciler) deploymentForMCPServer(ctx context.Context, m *mcp
550574
}
551575
}
552576

553-
// Add the image
577+
// Always add the image as it's required by proxy runner command signature
578+
// When using ConfigMap, the image from ConfigMap takes precedence, but we still need
579+
// to provide this as a positional argument to satisfy the command requirements
554580
args = append(args, m.Spec.Image)
555581

556-
// Add additional args
557-
if len(m.Spec.Args) > 0 {
582+
// Add additional args only if not using ConfigMap
583+
if !useConfigMap && len(m.Spec.Args) > 0 {
558584
args = append(args, "--")
559585
args = append(args, m.Spec.Args...)
560586
}

cmd/thv-operator/controllers/mcpserver_runconfig.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,8 +216,7 @@ func (*MCPServerReconciler) createRunConfigFromMCPServer(m *mcpv1alpha1.MCPServe
216216
}
217217
}
218218

219-
// Use the RunConfigBuilder for operator context with full builder pattern
220-
config, err := runner.NewOperatorRunConfigBuilder().
219+
builder := runner.NewOperatorRunConfigBuilder().
221220
WithName(m.Name).
222221
WithImage(m.Spec.Image).
223222
WithCmdArgs(m.Spec.Args).
@@ -227,8 +226,21 @@ func (*MCPServerReconciler) createRunConfigFromMCPServer(m *mcpv1alpha1.MCPServe
227226
WithEnvVars(envVars).
228227
WithVolumes(volumes).
229228
WithSecrets(secrets).
230-
WithK8sPodPatch(k8sPodPatch).
231-
BuildForOperator()
229+
WithK8sPodPatch(k8sPodPatch)
230+
231+
// Add permission profile if specified
232+
if m.Spec.PermissionProfile != nil {
233+
switch m.Spec.PermissionProfile.Type {
234+
case mcpv1alpha1.PermissionProfileTypeBuiltin:
235+
builder = builder.WithPermissionProfileNameOrPath(m.Spec.PermissionProfile.Name)
236+
case mcpv1alpha1.PermissionProfileTypeConfigMap:
237+
// For ConfigMap-based permission profiles, we store the path
238+
builder = builder.WithPermissionProfileNameOrPath(fmt.Sprintf("/etc/toolhive/profiles/%s", m.Spec.PermissionProfile.Key))
239+
}
240+
}
241+
242+
// Use the RunConfigBuilder for operator context with full builder pattern
243+
config, err := builder.BuildForOperator()
232244

233245
if err != nil {
234246
return nil, err

cmd/thv-proxyrunner/app/run.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
package app
22

33
import (
4+
"context"
5+
"encoding/json"
46
"fmt"
57
"net"
68
"os"
9+
"strings"
710

811
"github.com/spf13/cobra"
12+
"github.com/spf13/pflag"
13+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
14+
"k8s.io/client-go/kubernetes"
15+
"k8s.io/client-go/rest"
916

1017
"github.com/stacklok/toolhive/pkg/container"
1118
"github.com/stacklok/toolhive/pkg/environment"
@@ -17,6 +24,11 @@ import (
1724
"github.com/stacklok/toolhive/pkg/workloads"
1825
)
1926

27+
// NewRunCmd creates a new run command for testing
28+
func NewRunCmd() *cobra.Command {
29+
return runCmd
30+
}
31+
2032
var runCmd = &cobra.Command{
2133
Use: "run [flags] SERVER_OR_IMAGE_OR_PROTOCOL [-- ARGS...]",
2234
Short: "Run an MCP server",
@@ -90,6 +102,9 @@ var (
90102

91103
// Environment file processing
92104
runEnvFileDir string
105+
106+
// ConfigMap reference flag (for identification only)
107+
runFromConfigMap string
93108
)
94109

95110
func init() {
@@ -219,11 +234,76 @@ func init() {
219234
"",
220235
"Load environment variables from all files in a directory",
221236
)
237+
runCmd.Flags().StringVar(
238+
&runFromConfigMap,
239+
"from-configmap",
240+
"",
241+
"[Experimental] Load configuration from a Kubernetes ConfigMap (format: namespace/configmap-name). "+
242+
"This flag is mutually exclusive with other configuration flags.",
243+
)
244+
}
245+
246+
// validateConfigMapOnlyMode validates that no conflicting flags are used with --from-configmap
247+
// It dynamically finds all flags that could conflict by checking which ones are changed
248+
func validateConfigMapOnlyMode(cmd *cobra.Command) error {
249+
// If --from-configmap is not set, no validation needed
250+
fromConfigMapFlag := cmd.Flag("from-configmap")
251+
if fromConfigMapFlag == nil || !fromConfigMapFlag.Changed {
252+
return nil
253+
}
254+
255+
var conflictingFlags []string
256+
257+
// Check all flags that are explicitly set by the user
258+
cmd.Flags().VisitAll(func(flag *pflag.Flag) {
259+
// Skip the from-configmap flag itself and flags that weren't changed
260+
if flag.Name == "from-configmap" || !flag.Changed {
261+
return
262+
}
263+
264+
// Skip flags that are safe to use with ConfigMap (like help, debug, etc.)
265+
if isSafeFlagWithConfigMap(flag.Name) {
266+
return
267+
}
268+
269+
// All other changed flags are considered conflicting
270+
conflictingFlags = append(conflictingFlags, "--"+flag.Name)
271+
})
272+
273+
if len(conflictingFlags) > 0 {
274+
return fmt.Errorf("cannot use --from-configmap with the following flags (configuration should be provided by ConfigMap): %s",
275+
strings.Join(conflictingFlags, ", "))
276+
}
277+
278+
return nil
279+
}
280+
281+
// isSafeFlagWithConfigMap returns true for flags that are safe to use with --from-configmap
282+
// These are typically operational flags that don't affect the RunConfig
283+
func isSafeFlagWithConfigMap(flagName string) bool {
284+
safeFlagsWithConfigMap := map[string]bool{
285+
"help": true,
286+
"debug": true,
287+
// Add other safe flags here if needed
288+
}
289+
return safeFlagsWithConfigMap[flagName]
222290
}
223291

224292
func runCmdFunc(cmd *cobra.Command, args []string) error {
225293
ctx := cmd.Context()
226294

295+
// Handle ConfigMap identification if specified
296+
if runFromConfigMap != "" {
297+
// Validate that conflicting flags are not used with --from-configmap first
298+
if err := validateConfigMapOnlyMode(cmd); err != nil {
299+
return err
300+
}
301+
302+
if err := identifyAndReadConfigMap(ctx, runFromConfigMap); err != nil {
303+
return fmt.Errorf("failed to identify ConfigMap: %w", err)
304+
}
305+
}
306+
227307
// Validate the host flag and default resolving to IP in case hostname is provided
228308
validatedHost, err := ValidateAndNormaliseHostFlag(runHost)
229309
if err != nil {
@@ -353,3 +433,69 @@ func ValidateAndNormaliseHostFlag(host string) (string, error) {
353433

354434
return "", fmt.Errorf("could not resolve host: %s", host)
355435
}
436+
437+
// parseConfigMapRef parses a ConfigMap reference in the format "namespace/configmap-name"
438+
func parseConfigMapRef(ref string) (namespace, name string, err error) {
439+
parts := strings.SplitN(ref, "/", 2)
440+
if len(parts) != 2 {
441+
return "", "", fmt.Errorf("expected format 'namespace/configmap-name', got '%s'", ref)
442+
}
443+
444+
namespace = strings.TrimSpace(parts[0])
445+
name = strings.TrimSpace(parts[1])
446+
447+
if namespace == "" {
448+
return "", "", fmt.Errorf("namespace cannot be empty")
449+
}
450+
if name == "" {
451+
return "", "", fmt.Errorf("configmap name cannot be empty")
452+
}
453+
454+
return namespace, name, nil
455+
}
456+
457+
// identifyAndReadConfigMap identifies and reads a ConfigMap to validate its existence and contents
458+
// This function only validates the ConfigMap but does not use it for configuration
459+
func identifyAndReadConfigMap(ctx context.Context, configMapRef string) error {
460+
// Parse namespace and ConfigMap name
461+
namespace, configMapName, err := parseConfigMapRef(configMapRef)
462+
if err != nil {
463+
return fmt.Errorf("invalid --from-configmap format: %w", err)
464+
}
465+
466+
logger.Infof("Identifying ConfigMap '%s/%s'", namespace, configMapName)
467+
468+
// Create in-cluster Kubernetes client
469+
config, err := rest.InClusterConfig()
470+
if err != nil {
471+
return fmt.Errorf("failed to create in-cluster config: %w", err)
472+
}
473+
474+
clientset, err := kubernetes.NewForConfig(config)
475+
if err != nil {
476+
return fmt.Errorf("failed to create Kubernetes client: %w", err)
477+
}
478+
479+
// Get the ConfigMap
480+
configMap, err := clientset.CoreV1().ConfigMaps(namespace).Get(ctx, configMapName, metav1.GetOptions{})
481+
if err != nil {
482+
return fmt.Errorf("failed to get ConfigMap '%s/%s': %w", namespace, configMapName, err)
483+
}
484+
485+
// Validate that runconfig.json exists in the ConfigMap
486+
runConfigJSON, ok := configMap.Data["runconfig.json"]
487+
if !ok {
488+
return fmt.Errorf("ConfigMap '%s/%s' does not contain 'runconfig.json' key", namespace, configMapName)
489+
}
490+
491+
// Validate that the JSON is parseable (but don't use it)
492+
var runConfig runner.RunConfig
493+
if err := json.Unmarshal([]byte(runConfigJSON), &runConfig); err != nil {
494+
return fmt.Errorf("ConfigMap '%s/%s' contains invalid runconfig.json: %w", namespace, configMapName, err)
495+
}
496+
497+
logger.Infof("Successfully identified and validated ConfigMap '%s/%s' (contains %d bytes of runconfig.json)",
498+
namespace, configMapName, len(runConfigJSON))
499+
500+
return nil
501+
}

0 commit comments

Comments
 (0)