Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/operator-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ jobs:
- name: Check for changes
id: git-check
run: |
git diff --exit-code deploy/charts/operator-crds/crds || echo "crd-changes=true" >> $GITHUB_OUTPUT
git diff --exit-code deploy/charts/operator-crds/crd-files || echo "crd-changes=true" >> $GITHUB_OUTPUT
git diff --exit-code deploy/charts/operator/templates || echo "operator-changes=true" >> $GITHUB_OUTPUT

- name: Fail if CRDs are not up to date
Expand Down
4 changes: 2 additions & 2 deletions cmd/thv-operator/Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,8 @@ tasks:
platforms: [windows]
ignore_error: true # Windows has no mkdir -p, so just ignore error if it exists
- go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.17.3
- $(go env GOPATH)/bin/controller-gen crd webhook paths="./cmd/thv-operator/..." output:crd:artifacts:config=deploy/charts/operator-crds/crds
- $(go env GOPATH)/bin/controller-gen rbac:roleName=toolhive-operator-manager-role paths="./cmd/thv-operator/..." output:rbac:artifacts:config=deploy/charts/operator/templates/clusterrole
- $(go env GOPATH)/bin/controller-gen crd webhook paths="./cmd/thv-operator/..." output:crd:artifacts:config=deploy/charts/operator-crds/crd-files
- $(go env GOPATH)/bin/controller-gen rbac:roleName=toolhive-operator-manager-role paths="./cmd/thv-operator/..." output:rbac:artifacts:config=deploy/charts/operator/generated-rbac

operator-test:
desc: Run tests for the operator
Expand Down
131 changes: 118 additions & 13 deletions cmd/thv-operator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import (
"flag"
"fmt"
"os"
"strconv"
"strings"

// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
// to ensure that exec-entrypoint and run can make use of them.
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
// to ensure that exec-entrypoint and run can make use of them.
_ "k8s.io/client-go/plugin/pkg/client/auth"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/cache"
Expand All @@ -36,6 +37,18 @@ var (
setupLog = log.Log.WithName("setup")
)

// Feature flags for controller groups
const (
featureServer = "ENABLE_SERVER"
featureRegistry = "ENABLE_REGISTRY"
featureVMCP = "ENABLE_VMCP"
)

// controllerDependencies maps each controller group to its required dependencies
var controllerDependencies = map[string][]string{
featureVMCP: {featureServer}, // Virtual MCP requires server controllers
}

func init() {
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(mcpv1alpha1.AddToScheme(scheme))
Expand Down Expand Up @@ -111,6 +124,69 @@ func main() {

// setupControllersAndWebhooks sets up all controllers and webhooks with the manager
func setupControllersAndWebhooks(mgr ctrl.Manager) error {
// Check feature flags
enableServer := isFeatureEnabled(featureServer, true)
enableRegistry := isFeatureEnabled(featureRegistry, true)
enableVMCP := isFeatureEnabled(featureVMCP, true)

// Track enabled features for dependency checking
enabledFeatures := map[string]bool{
featureServer: enableServer,
featureRegistry: enableRegistry,
featureVMCP: enableVMCP,
}

// Check dependencies and log warnings for missing dependencies
for feature, deps := range controllerDependencies {
if !enabledFeatures[feature] {
continue // Skip if feature itself is disabled
}
for _, dep := range deps {
if !enabledFeatures[dep] {
setupLog.Info(
fmt.Sprintf("%s requires %s to be enabled, skipping %s controllers", feature, dep, feature),
"feature", feature,
"required_dependency", dep,
)
enabledFeatures[feature] = false // Mark as effectively disabled
break
}
}
}

// Set up server-related controllers
if enabledFeatures[featureServer] {
if err := setupServerControllers(mgr, enableRegistry); err != nil {
return err
}
} else {
setupLog.Info("ENABLE_SERVER is disabled, skipping server-related controllers")
}

// Set up registry controller
if enabledFeatures[featureRegistry] {
if err := setupRegistryController(mgr); err != nil {
return err
}
} else {
setupLog.Info("ENABLE_REGISTRY is disabled, skipping MCPRegistry controller")
}

// Set up Virtual MCP controllers and webhooks
if enabledFeatures[featureVMCP] {
if err := setupAggregationControllers(mgr); err != nil {
return err
}
} else {
setupLog.Info("ENABLE_VMCP is disabled, skipping Virtual MCP controllers and webhooks")
}

//+kubebuilder:scaffold:builder
return nil
}

// setupServerControllers sets up server-related controllers (MCPServer, MCPExternalAuthConfig, MCPRemoteProxy, ToolConfig)
func setupServerControllers(mgr ctrl.Manager, enableRegistry bool) error {
// Set up field indexing for MCPServer.Spec.GroupRef
if err := mgr.GetFieldIndexer().IndexField(
context.Background(),
Expand All @@ -127,37 +203,43 @@ func setupControllersAndWebhooks(mgr ctrl.Manager) error {
return fmt.Errorf("unable to create field index for spec.groupRef: %w", err)
}

// Create a shared platform detector for all controllers
sharedPlatformDetector := ctrlutil.NewSharedPlatformDetector()
// Set image validation mode based on whether registry is enabled
// If ENABLE_REGISTRY is enabled, enforce registry-based image validation
// Otherwise, allow all images
imageValidation := validation.ImageValidationAlwaysAllow
if enableRegistry {
imageValidation = validation.ImageValidationRegistryEnforcing
}

// Set up MCPServer controller
rec := &controllers.MCPServerReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Recorder: mgr.GetEventRecorderFor("mcpserver-controller"),
PlatformDetector: sharedPlatformDetector,
ImageValidation: validation.ImageValidationAlwaysAllow,
PlatformDetector: ctrlutil.NewSharedPlatformDetector(),
ImageValidation: imageValidation,
}

if err := rec.SetupWithManager(mgr); err != nil {
return fmt.Errorf("unable to create controller MCPServer: %w", err)
}

// Register MCPToolConfig controller
// Set up MCPToolConfig controller
if err := (&controllers.ToolConfigReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
return fmt.Errorf("unable to create controller MCPToolConfig: %w", err)
}

// Register MCPExternalAuthConfig controller
// Set up MCPExternalAuthConfig controller
if err := (&controllers.MCPExternalAuthConfigReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
return fmt.Errorf("unable to create controller MCPExternalAuthConfig: %w", err)
}

// Register MCPRemoteProxy controller
// Set up MCPRemoteProxy controller
if err := (&controllers.MCPRemoteProxyReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Expand All @@ -166,13 +248,22 @@ func setupControllersAndWebhooks(mgr ctrl.Manager) error {
return fmt.Errorf("unable to create controller MCPRemoteProxy: %w", err)
}

// Only register MCPRegistry controller if feature flag is enabled
rec.ImageValidation = validation.ImageValidationRegistryEnforcing
return nil
}

// setupRegistryController sets up the MCPRegistry controller
func setupRegistryController(mgr ctrl.Manager) error {
if err := (controllers.NewMCPRegistryReconciler(mgr.GetClient(), mgr.GetScheme())).SetupWithManager(mgr); err != nil {
return fmt.Errorf("unable to create controller MCPRegistry: %w", err)
}
return nil
}

// setupAggregationControllers sets up Virtual MCP-related controllers and webhooks
// (MCPGroup, VirtualMCPServer, and their webhooks)
// Note: This function assumes server controllers are enabled (enforced by dependency check)
// The field index for MCPServer.Spec.GroupRef is created in setupServerControllers
func setupAggregationControllers(mgr ctrl.Manager) error {
// Set up MCPGroup controller
if err := (&controllers.MCPGroupReconciler{
Client: mgr.GetClient(),
Expand All @@ -199,11 +290,25 @@ func setupControllersAndWebhooks(mgr ctrl.Manager) error {
if err := (&mcpv1alpha1.VirtualMCPCompositeToolDefinition{}).SetupWebhookWithManager(mgr); err != nil {
return fmt.Errorf("unable to create webhook VirtualMCPCompositeToolDefinition: %w", err)
}
//+kubebuilder:scaffold:builder

return nil
}

// isFeatureEnabled checks if a feature flag environment variable is enabled.
// If the environment variable is not set, it returns the default value.
// The environment variable is considered enabled if it's set to "true" (case-insensitive).
func isFeatureEnabled(envVar string, defaultValue bool) bool {
value, found := os.LookupEnv(envVar)
if !found {
return defaultValue
}
enabled, err := strconv.ParseBool(value)
if err != nil {
return defaultValue
}
return enabled
}

// getDefaultNamespaces returns a map of namespaces to cache.Config for the operator to watch.
// if WATCH_NAMESPACE is not set, returns nil which is defaulted to a cluster scope.
func getDefaultNamespaces() map[string]cache.Config {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ var _ = BeforeSuite(func() {

By("bootstrapping test environment")
testEnv = &envtest.Environment{
CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "..", "deploy", "charts", "operator-crds", "crds")},
CRDDirectoryPaths: []string{
filepath.Join("..", "..", "..", "..", "deploy", "charts", "operator-crds", "crd-files"),
},
ErrorIfCRDPathMissing: true,
}

Expand Down
4 changes: 3 additions & 1 deletion cmd/thv-operator/test-integration/mcp-group/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ var _ = BeforeSuite(func() {

By("bootstrapping test environment")
testEnv = &envtest.Environment{
CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "..", "deploy", "charts", "operator-crds", "crds")},
CRDDirectoryPaths: []string{
filepath.Join("..", "..", "..", "..", "deploy", "charts", "operator-crds", "crd-files"),
},
ErrorIfCRDPathMissing: true,
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ var _ = BeforeSuite(func() {
testEnv = &envtest.Environment{
UseExistingCluster: &useExistingCluster,
CRDDirectoryPaths: []string{
filepath.Join("..", "..", "..", "..", "deploy", "charts", "operator-crds", "crds"),
filepath.Join("..", "..", "..", "..", "deploy", "charts", "operator-crds", "crd-files"),
},
ErrorIfCRDPathMissing: true,
BinaryAssetsDirectory: kubebuilderAssets,
Expand Down
4 changes: 3 additions & 1 deletion cmd/thv-operator/test-integration/mcp-server/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ var _ = BeforeSuite(func() {

By("bootstrapping test environment")
testEnv = &envtest.Environment{
CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "..", "deploy", "charts", "operator-crds", "crds")},
CRDDirectoryPaths: []string{
filepath.Join("..", "..", "..", "..", "deploy", "charts", "operator-crds", "crd-files"),
},
ErrorIfCRDPathMissing: true,
}

Expand Down
12 changes: 11 additions & 1 deletion cmd/thv-operator/test-integration/virtualmcp/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ var _ = BeforeSuite(func() {

By("bootstrapping test environment")
testEnv = &envtest.Environment{
CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "..", "deploy", "charts", "operator-crds", "crds")},
CRDDirectoryPaths: []string{
filepath.Join("..", "..", "..", "..", "deploy", "charts", "operator-crds", "crd-files"),
},
ErrorIfCRDPathMissing: true,
}

Expand Down Expand Up @@ -125,6 +127,14 @@ var _ = BeforeSuite(func() {
}).SetupWithManager(k8sManager)
Expect(err).ToNot(HaveOccurred())

// Set up VirtualMCPServer webhook
err = (&mcpv1alpha1.VirtualMCPServer{}).SetupWebhookWithManager(k8sManager)
Expect(err).ToNot(HaveOccurred())

// Set up VirtualMCPCompositeToolDefinition webhook
err = (&mcpv1alpha1.VirtualMCPCompositeToolDefinition{}).SetupWebhookWithManager(k8sManager)
Expect(err).ToNot(HaveOccurred())

// Start the manager in a goroutine
go func() {
defer GinkgoRecover()
Expand Down
4 changes: 4 additions & 0 deletions ct-install.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ validate-maintainers: false
remote: origin
target-branch: main

skip-clean-up: true

helm-extra-set-args: >
--set operator.testMode=true
2 changes: 1 addition & 1 deletion deploy/charts/operator-crds/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ apiVersion: v2
name: toolhive-operator-crds
description: A Helm chart for installing the ToolHive Operator CRDs into Kubernetes.
type: application
version: 0.0.69
version: 0.0.70
appVersion: "0.0.1"
70 changes: 69 additions & 1 deletion deploy/charts/operator-crds/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ToolHive Operator CRDs Helm Chart

![Version: 0.0.69](https://img.shields.io/badge/Version-0.0.69-informational?style=flat-square)
![Version: 0.0.70](https://img.shields.io/badge/Version-0.0.70-informational?style=flat-square)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square)

A Helm chart for installing the ToolHive Operator CRDs into Kubernetes.
Expand Down Expand Up @@ -40,3 +40,71 @@ To uninstall/delete the `toolhive-operator-crds` deployment:
helm uninstall <release_name>
```

### Skipping CRDs
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great but maybe a summary table could be more effective.
Can be fixed in another PR if we think it's needed.


By default, all CRDs are installed. You can selectively disable CRD groups based on your needs:

#### Skipping Server CRDs

To skip server-related CRDs (MCPServer, MCPExternalAuthConfig, MCPRemoteProxy, and ToolConfig):

```shell
helm upgrade -i toolhive-operator-crds oci://ghcr.io/stacklok/toolhive/toolhive-operator-crds \
--set crds.install.server=false
```

**Important:** When server CRDs are not installed, you should also disable the server controllers in the operator:

```shell
helm upgrade -i toolhive-operator oci://ghcr.io/stacklok/toolhive/toolhive-operator \
-n toolhive-system --create-namespace \
--set operator.features.server=false
```

#### Skipping Registry CRD

To skip the registry CRD (MCPRegistry):

```shell
helm upgrade -i toolhive-operator-crds oci://ghcr.io/stacklok/toolhive/toolhive-operator-crds \
--set crds.install.registry=false
```

**Important:** When registry CRD is not installed, you should also disable the registry controller in the operator:

```shell
helm upgrade -i toolhive-operator oci://ghcr.io/stacklok/toolhive/toolhive-operator \
-n toolhive-system --create-namespace \
--set operator.features.registry=false
```

#### Skipping Virtual MCP CRDs

To skip Virtual MCP CRDs (VirtualMCPServer, VirtualMCPCompositeToolDefinition, and MCPGroup):

```shell
helm upgrade -i toolhive-operator-crds oci://ghcr.io/stacklok/toolhive/toolhive-operator-crds \
--set crds.install.virtualMCP=false
```

You can also combine this with disabling the registry CRD (see [Skipping Registry CRD](#skipping-registry-crd) section above) if you don't need registry features.

**Important:** When Virtual MCP CRDs are not installed, you should also disable the Virtual MCP controllers in the operator:

```shell
helm upgrade -i toolhive-operator oci://ghcr.io/stacklok/toolhive/toolhive-operator \
-n toolhive-system --create-namespace \
--set operator.features.virtualMCP=false
```

If you also disabled the registry CRD, disable the registry controller as well (see the [Skipping Registry CRD](#skipping-registry-crd) section above).

This is useful for deployments that don't require Virtual MCP aggregation features. When `operator.features.virtualMCP=false`, the operator will skip setting up the VirtualMCPServer controller, MCPGroup controller, and their associated webhooks.

## Values

| Key | Type | Default | Description |
|-----|-------------|------|---------|
| crds.install.registry | bool | `true` | Install registry CRD (MCPRegistry). Users who only need server management without registry features can set this to false to skip installing the registry CRD. |
| crds.install.server | bool | `true` | Install server-related CRDs (MCPServer, MCPExternalAuthConfig, MCPRemoteProxy, and ToolConfig). Users who only need registry or aggregation features can set this to false to skip installing server management CRDs. |
| crds.install.virtualMCP | bool | `true` | Install Virtual MCP CRDs (VirtualMCPServer, VirtualMCPCompositeToolDefinition and MCPGroup). Users who only need core MCP server management can set this to false to skip installing Virtual MCP aggregation features. |
Loading
Loading