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 cmd/thv-operator/Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ tasks:
- chainsaw test --test-dir test/e2e/chainsaw/operator/single-tenancy/setup
- chainsaw test --test-dir test/e2e/chainsaw/operator/single-tenancy/test-scenarios
- chainsaw test --test-dir test/e2e/chainsaw/operator/single-tenancy/cleanup

operator-run:
desc: Run the operator controller locally
cmds:
Expand Down
86 changes: 86 additions & 0 deletions cmd/thv-operator/api/v1alpha1/mcpgroup_types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package v1alpha1

import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// MCPGroupSpec defines the desired state of MCPGroup
type MCPGroupSpec struct {
// Description provides human-readable context
// +optional
Description string `json:"description,omitempty"`
}

// MCPGroupStatus defines observed state
type MCPGroupStatus struct {
// Phase indicates current state
// +optional
// +kubebuilder:default=Pending
Phase MCPGroupPhase `json:"phase,omitempty"`

// Servers lists server names in this group
// +optional
Servers []string `json:"servers"`

// ServerCount is the number of servers
// +optional
ServerCount int `json:"serverCount"`

// Conditions represent observations
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
}

// MCPGroupPhase represents the lifecycle phase of an MCPGroup
// +kubebuilder:validation:Enum=Ready;Pending;Failed
type MCPGroupPhase string

const (
// MCPGroupPhaseReady indicates the MCPGroup is ready
MCPGroupPhaseReady MCPGroupPhase = "Ready"

// MCPGroupPhasePending indicates the MCPGroup is pending
MCPGroupPhasePending MCPGroupPhase = "Pending"

// MCPGroupPhaseFailed indicates the MCPGroup has failed
MCPGroupPhaseFailed MCPGroupPhase = "Failed"
)

// Condition types for MCPGroup
const (
ConditionTypeMCPServersChecked = "MCPServersChecked"
)

// MCPGroupConditionReason represents the reason for a condition's last transition
const (
ConditionReasonListMCPServersFailed = "ListMCPServersFailed"
ConditionReasonListMCPServersSucceeded = "ListMCPServersSucceeded"
)

//+kubebuilder:object:root=true
//+kubebuilder:subresource:status
//+kubebuilder:printerColumn:name="Servers",type="integer",JSONPath=".status.serverCount",description="The number of MCPServers in this group"
//+kubebuilder:printerColumn:name="Phase",type="string",JSONPath=".status.phase",description="The phase of the MCPGroup"
//+kubebuilder:printerColumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="The age of the MCPGroup"

// MCPGroup is the Schema for the mcpgroups API
type MCPGroup struct {
metav1.TypeMeta `json:",inline"` // nolint:revive
metav1.ObjectMeta `json:"metadata,omitempty"`

Spec MCPGroupSpec `json:"spec,omitempty"`
Status MCPGroupStatus `json:"status,omitempty"`
}

//+kubebuilder:object:root=true

// MCPGroupList contains a list of MCPGroup
type MCPGroupList struct {
metav1.TypeMeta `json:",inline"` // nolint:revive
metav1.ListMeta `json:"metadata,omitempty"`
Items []MCPGroup `json:"items"`
}

func init() {
SchemeBuilder.Register(&MCPGroup{}, &MCPGroupList{})
}
19 changes: 19 additions & 0 deletions cmd/thv-operator/api/v1alpha1/mcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import (
const (
// ConditionImageValidated indicates whether this image is fine to be used
ConditionImageValidated = "ImageValidated"

// ConditionGroupRefValidated indicates whether the GroupRef is valid
ConditionGroupRefValidated = "GroupRefValidated"
)

const (
Expand All @@ -22,6 +25,17 @@ const (
ConditionReasonImageValidationSkipped = "ImageValidationSkipped"
)

const (
// ConditionReasonGroupRefValidated indicates the GroupRef is valid
ConditionReasonGroupRefValidated = "GroupRefIsValid"

// ConditionReasonGroupRefInvalid indicates the GroupRef is invalid
ConditionReasonGroupRefNotFound = "GroupRefNotFound"

// ConditionReasonGroupRefError indicates the referenced MCPGroup is not in the Ready state
ConditionReasonGroupRefNotReady = "GroupRefNotReady"
)

// MCPServerSpec defines the desired state of MCPServer
type MCPServerSpec struct {
// Image is the container image for the MCP server
Expand Down Expand Up @@ -126,6 +140,11 @@ type MCPServerSpec struct {
// +kubebuilder:default=false
// +optional
TrustProxyHeaders bool `json:"trustProxyHeaders,omitempty"`

// GroupRef is the name of the MCPGroup this server belongs to
// Must reference an existing MCPGroup in the same namespace
// +optional
GroupRef string `json:"groupRef,omitempty"`
}

// ResourceOverrides defines overrides for annotations and labels on created resources
Expand Down
101 changes: 101 additions & 0 deletions cmd/thv-operator/api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

145 changes: 145 additions & 0 deletions cmd/thv-operator/controllers/mcpgroup_controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package controllers

import (
"context"

mcpv1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
)

type MCPGroupReconciler struct {
client.Client
}

// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpgroups,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpgroups/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpgroups/finalizers,verbs=update
// +kubebuilder:rbac:groups=toolhive.stacklok.dev,resources=mcpservers,verbs=get;list;watch

// Reconcile is part of the main kubernetes reconciliation loop
// which aims to move the current state of the cluster closer to the desired state.
func (r *MCPGroupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
ctxLogger := log.FromContext(ctx)
ctxLogger.Info("Reconciling MCPGroup", "mcpgroup", req.NamespacedName)

// Fetch the MCPGroup instance
mcpGroup := &mcpv1alpha1.MCPGroup{}
err := r.Get(ctx, req.NamespacedName, mcpGroup)
if err != nil {
if errors.IsNotFound(err) {
// Request object not found, could have been deleted after reconcile request.
// Return and don't requeue
ctxLogger.Info("MCPGroup resource not found. Ignoring since object must be deleted.")
return ctrl.Result{}, nil
}
// Error reading the object - requeue the request.
ctxLogger.Error(err, "Failed to get MCPGroup", "mcpgroup", req.NamespacedName)
return ctrl.Result{}, err
}

// List all MCPServers in the same namespace
mcpServerList := &mcpv1alpha1.MCPServerList{}
if err := r.List(ctx, mcpServerList, client.InNamespace(req.Namespace)); err != nil {
ctxLogger.Error(err, "Failed to list MCPServers")
mcpGroup.Status.Phase = mcpv1alpha1.MCPGroupPhaseFailed
meta.SetStatusCondition(&mcpGroup.Status.Conditions, metav1.Condition{
Type: mcpv1alpha1.ConditionTypeMCPServersChecked,
Status: metav1.ConditionFalse,
Reason: mcpv1alpha1.ConditionReasonListMCPServersFailed,
Message: "Failed to list MCPServers in namespace",
})
mcpGroup.Status.ServerCount = 0
mcpGroup.Status.Servers = nil
// Update the MCPGroup status to reflect the failure
if updateErr := r.Status().Update(ctx, mcpGroup); updateErr != nil {
ctxLogger.Error(updateErr, "Failed to update MCPGroup status after list failure")
}
return ctrl.Result{}, nil
} else {
meta.SetStatusCondition(&mcpGroup.Status.Conditions, metav1.Condition{
Type: mcpv1alpha1.ConditionTypeMCPServersChecked,
Status: metav1.ConditionTrue,
Reason: mcpv1alpha1.ConditionReasonListMCPServersSucceeded,
Message: "Successfully listed MCPServers in namespace",
})
}

// Filter servers that belong to this group
filteredServers := []mcpv1alpha1.MCPServer{}
for _, server := range mcpServerList.Items {
if server.Spec.GroupRef == mcpGroup.Name {
filteredServers = append(filteredServers, server)
}
}

// Set server count and names
mcpGroup.Status.ServerCount = len(filteredServers)
if len(filteredServers) == 0 {
// Ensure servers is an empty slice, not nil
mcpGroup.Status.Servers = []string{}
} else {
mcpGroup.Status.Servers = make([]string, len(filteredServers))
for i, server := range filteredServers {
mcpGroup.Status.Servers[i] = server.Name
}
}

// Set status conditions
mcpGroup.Status.Phase = mcpv1alpha1.MCPGroupPhaseReady

// Update the MCPGroup status
if err := r.Status().Update(ctx, mcpGroup); err != nil {
ctxLogger.Error(err, "Failed to update MCPGroup status")
return ctrl.Result{}, err
}

ctxLogger.Info("Successfully reconciled MCPGroup", "serverCount", mcpGroup.Status.ServerCount)
return ctrl.Result{}, nil
}

func (r *MCPGroupReconciler) findMCPGroupForMCPServer(ctx context.Context, obj client.Object) []ctrl.Request {
ctxLogger := log.FromContext(ctx)

// Get the MCPServer object
mcpServer, ok := obj.(*mcpv1alpha1.MCPServer)
if !ok {
ctxLogger.Error(nil, "Object is not an MCPServer", "object", obj.GetName())
return []ctrl.Request{}
}
if mcpServer.Spec.GroupRef == "" {
// No MCPGroup reference, nothing to do
return []ctrl.Request{}
}

// Find which MCPGroup this MCPServer belongs to
ctxLogger.Info("Finding MCPGroup for MCPServer", "namespace", obj.GetNamespace(), "mcpserver", obj.GetName(), "groupRef", mcpServer.Spec.GroupRef)
group := &mcpv1alpha1.MCPGroup{}
if err := r.Get(ctx, types.NamespacedName{Namespace: obj.GetNamespace(), Name: mcpServer.Spec.GroupRef}, group); err != nil {
ctxLogger.Error(err, "Failed to get MCPGroup for MCPServer", "namespace", obj.GetNamespace(), "name", mcpServer.Spec.GroupRef)
return []ctrl.Request{}
}
return []ctrl.Request{
{
NamespacedName: types.NamespacedName{
Namespace: obj.GetNamespace(),
Name: group.Name,
},
},
}
}

func (r *MCPGroupReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&mcpv1alpha1.MCPGroup{}).
Watches(
&mcpv1alpha1.MCPServer{}, handler.EnqueueRequestsFromMapFunc(r.findMCPGroupForMCPServer),
).
Complete(r)
}
Loading