Skip to content

Commit 0a52a43

Browse files
RoddieKieleyCursor o3ChrisJBurns
authored
Deploy ToolHive Operator into OpenShift (#1063) (#1253)
Signed-off-by: Roddie Kieley <[email protected]> Co-authored-by: Cursor o3 <[email protected]> Co-authored-by: Chris Burns <[email protected]>
1 parent 7ef6075 commit 0a52a43

File tree

9 files changed

+600
-31
lines changed

9 files changed

+600
-31
lines changed

cmd/thv-operator/controllers/mcpserver_controller.go

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,8 @@ func (r *MCPServerReconciler) deploymentForMCPServer(m *mcpv1alpha1.MCPServer) *
577577
ReadOnlyRootFilesystem: ptr.To(true),
578578
}
579579

580+
env = ensureRequiredEnvVars(env)
581+
580582
dep := &appsv1.Deployment{
581583
ObjectMeta: metav1.ObjectMeta{
582584
Name: m.Name,
@@ -648,6 +650,36 @@ func (r *MCPServerReconciler) deploymentForMCPServer(m *mcpv1alpha1.MCPServer) *
648650
return dep
649651
}
650652

653+
func ensureRequiredEnvVars(env []corev1.EnvVar) []corev1.EnvVar {
654+
// Check for the existence of the XDG_CONFIG_HOME and HOME environment variables
655+
// and set them to /tmp if they don't exist
656+
xdgConfigHomeFound := false
657+
homeFound := false
658+
for _, envVar := range env {
659+
if envVar.Name == "XDG_CONFIG_HOME" {
660+
xdgConfigHomeFound = true
661+
}
662+
if envVar.Name == "HOME" {
663+
homeFound = true
664+
}
665+
}
666+
if !xdgConfigHomeFound {
667+
logger.Debugf("XDG_CONFIG_HOME not found, setting to /tmp")
668+
env = append(env, corev1.EnvVar{
669+
Name: "XDG_CONFIG_HOME",
670+
Value: "/tmp",
671+
})
672+
}
673+
if !homeFound {
674+
logger.Debugf("HOME not found, setting to /tmp")
675+
env = append(env, corev1.EnvVar{
676+
Name: "HOME",
677+
Value: "/tmp",
678+
})
679+
}
680+
return env
681+
}
682+
651683
func createServiceName(mcpServerName string) string {
652684
return fmt.Sprintf("mcp-%s-proxy", mcpServerName)
653685
}
@@ -845,20 +877,9 @@ func deploymentNeedsUpdate(deployment *appsv1.Deployment, mcpServer *mcpv1alpha1
845877
return true
846878
}
847879

848-
// Check if the tools filter has changed
849-
if mcpServer.Spec.ToolsFilter == nil {
850-
for _, arg := range container.Args {
851-
if strings.HasPrefix(arg, "--tools=") {
852-
return true
853-
}
854-
}
855-
} else {
856-
slices.Sort(mcpServer.Spec.ToolsFilter)
857-
toolsFilterArg := fmt.Sprintf("--tools=%s", strings.Join(mcpServer.Spec.ToolsFilter, ","))
858-
found = slices.Contains(container.Args, toolsFilterArg)
859-
if !found {
860-
return true
861-
}
880+
// Check if the tools filter has changed (order-independent)
881+
if !equalToolsFilter(mcpServer.Spec.ToolsFilter, container.Args) {
882+
return true
862883
}
863884

864885
// Check if the pod template spec has changed
@@ -913,6 +934,8 @@ func deploymentNeedsUpdate(deployment *appsv1.Deployment, mcpServer *mcpv1alpha1
913934
})
914935
}
915936
}
937+
// Add default environment variables that are always injected
938+
expectedProxyEnv = ensureRequiredEnvVars(expectedProxyEnv)
916939
if !reflect.DeepEqual(container.Env, expectedProxyEnv) {
917940
return true
918941
}
@@ -975,8 +998,10 @@ func deploymentNeedsUpdate(deployment *appsv1.Deployment, mcpServer *mcpv1alpha1
975998
}
976999

9771000
// Check if the service account name has changed
1001+
// ServiceAccountName: treat empty (not yet set) as equal to the expected default
9781002
expectedServiceAccountName := proxyRunnerServiceAccountName(mcpServer.Name)
979-
if deployment.Spec.Template.Spec.ServiceAccountName != expectedServiceAccountName {
1003+
currentServiceAccountName := deployment.Spec.Template.Spec.ServiceAccountName
1004+
if currentServiceAccountName != "" && currentServiceAccountName != expectedServiceAccountName {
9801005
return true
9811006
}
9821007

@@ -1591,3 +1616,38 @@ func (r *MCPServerReconciler) SetupWithManager(mgr ctrl.Manager) error {
15911616
Owns(&corev1.Service{}).
15921617
Complete(r)
15931618
}
1619+
1620+
// equalToolsFilter returns true when the desired toolsFilter slice and the
1621+
// currently-applied `--tools=` argument in the container args represent the
1622+
// same unordered set of tools.
1623+
func equalToolsFilter(spec []string, args []string) bool {
1624+
// Build canonical form for spec
1625+
specCanon := canonicalToolsList(spec)
1626+
1627+
// Extract current tools argument (if any) from args
1628+
var currentArg string
1629+
for _, a := range args {
1630+
if strings.HasPrefix(a, "--tools=") {
1631+
currentArg = strings.TrimPrefix(a, "--tools=")
1632+
break
1633+
}
1634+
}
1635+
1636+
if specCanon == "" && currentArg == "" {
1637+
return true // both unset/empty
1638+
}
1639+
1640+
// Canonicalise current list
1641+
currentCanon := canonicalToolsList(strings.Split(strings.TrimSpace(currentArg), ","))
1642+
return specCanon == currentCanon
1643+
}
1644+
1645+
// canonicalToolsList sorts a slice and joins it with commas; empty slice yields "".
1646+
func canonicalToolsList(list []string) string {
1647+
if len(list) == 0 || (len(list) == 1 && list[0] == "") {
1648+
return ""
1649+
}
1650+
cp := slices.Clone(list)
1651+
slices.Sort(cp)
1652+
return strings.Join(cp, ",")
1653+
}

cmd/thv-operator/controllers/mcpserver_resource_overrides_test.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -288,14 +288,18 @@ func TestResourceOverrides(t *testing.T) {
288288
var expectedEnvVars map[string]string
289289
if tt.name == "with proxy environment variables" {
290290
expectedEnvVars = map[string]string{
291-
"HTTP_PROXY": "http://proxy.example.com:8080",
292-
"NO_PROXY": "localhost,127.0.0.1",
293-
"CUSTOM_ENV": "custom-value",
291+
"HTTP_PROXY": "http://proxy.example.com:8080",
292+
"NO_PROXY": "localhost,127.0.0.1",
293+
"CUSTOM_ENV": "custom-value",
294+
"XDG_CONFIG_HOME": "/tmp",
295+
"HOME": "/tmp",
294296
}
295297
} else {
296298
expectedEnvVars = map[string]string{
297299
"LOG_LEVEL": "debug",
298300
"METRICS_ENABLED": "true",
301+
"XDG_CONFIG_HOME": "/tmp",
302+
"HOME": "/tmp",
299303
}
300304
}
301305

deploy/charts/operator/Chart.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ apiVersion: v2
22
name: toolhive-operator
33
description: A Helm chart for deploying the ToolHive Operator into Kubernetes.
44
type: application
5-
version: 0.2.1
6-
appVersion: "0.2.1"
5+
version: 0.2.2
6+
appVersion: "0.2.2"

deploy/charts/operator/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
# ToolHive Operator Helm Chart
33

4-
![Version: 0.2.1](https://img.shields.io/badge/Version-0.2.1-informational?style=flat-square)
4+
![Version: 0.2.2](https://img.shields.io/badge/Version-0.2.2-informational?style=flat-square)
55
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square)
66

77
A Helm chart for deploying the ToolHive Operator into Kubernetes.
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# -- Override the name of the chart
2+
nameOverride: ""
3+
# -- Provide a fully-qualified name override for resources
4+
fullnameOverride: "toolhive-operator"
5+
6+
# -- All values for the operator deployment and associated resources
7+
operator:
8+
9+
# -- Number of replicas for the operator deployment
10+
replicaCount: 1
11+
12+
# -- List of image pull secrets to use
13+
imagePullSecrets: []
14+
# -- Container image for the operator
15+
image: ghcr.io/stacklok/toolhive/operator:v0.2.0
16+
# -- Image pull policy for the operator container
17+
imagePullPolicy: IfNotPresent
18+
19+
# -- Image to use for Toolhive runners
20+
toolhiveRunnerImage: ghcr.io/stacklok/toolhive/proxyrunner:v0.2.0
21+
22+
# -- Host for the proxy deployed by the operator
23+
proxyHost: 0.0.0.0
24+
25+
# -- Environment variables to set in the operator container
26+
env: {}
27+
28+
# -- List of ports to expose from the operator container
29+
ports:
30+
- containerPort: 8080
31+
name: metrics
32+
protocol: TCP
33+
- containerPort: 8081
34+
name: health
35+
protocol: TCP
36+
37+
# -- Annotations to add to the operator pod
38+
podAnnotations: {}
39+
# -- Labels to add to the operator pod
40+
podLabels: {}
41+
42+
# -- Pod security context settings
43+
podSecurityContext:
44+
runAsNonRoot: true
45+
seccompProfile:
46+
type: RuntimeDefault
47+
48+
# -- Container security context settings for the operator
49+
containerSecurityContext:
50+
allowPrivilegeEscalation: false
51+
readOnlyRootFilesystem: true
52+
runAsNonRoot: true
53+
runAsUser:
54+
capabilities:
55+
drop:
56+
- ALL
57+
58+
# -- Liveness probe configuration for the operator
59+
livenessProbe:
60+
httpGet:
61+
path: /healthz
62+
port: health
63+
initialDelaySeconds: 15
64+
periodSeconds: 20
65+
# -- Readiness probe configuration for the operator
66+
readinessProbe:
67+
httpGet:
68+
path: /readyz
69+
port: health
70+
initialDelaySeconds: 5
71+
periodSeconds: 10
72+
73+
# -- Configuration for horizontal pod autoscaling
74+
autoscaling:
75+
# -- Enable autoscaling for the operator
76+
enabled: false
77+
# -- Minimum number of replicas
78+
minReplicas: 1
79+
# -- Maximum number of replicas
80+
maxReplicas: 100
81+
# -- Target CPU utilization percentage for autoscaling
82+
targetCPUUtilizationPercentage: 80
83+
# -- Target memory utilization percentage for autoscaling (uncomment to enable)
84+
# targetMemoryUtilizationPercentage: 80
85+
86+
# -- Resource requests and limits for the operator container
87+
resources:
88+
limits:
89+
cpu: 500m
90+
memory: 384Mi
91+
requests:
92+
cpu: 10m
93+
memory: 192Mi
94+
95+
# -- RBAC configuration for the operator
96+
rbac:
97+
# -- Scope of the RBAC configuration.
98+
# - cluster: The operator will have cluster-wide permissions via ClusterRole and ClusterRoleBinding.
99+
# - namespace: The operator will have permissions to manage resources in the namespaces specified in `allowedNamespaces`.
100+
# The operator will have a ClusterRole and RoleBinding for each namespace in `allowedNamespaces`.
101+
scope: cluster
102+
# -- List of namespaces that the operator is allowed to have permissions to manage.
103+
# Only used if scope is set to "namespace".
104+
allowedNamespaces: []
105+
106+
# -- Service account configuration for the operator
107+
serviceAccount:
108+
# -- Specifies whether a service account should be created
109+
create: true
110+
# -- Automatically mount a ServiceAccount's API credentials
111+
automountServiceAccountToken: true
112+
# -- Annotations to add to the service account
113+
annotations: {}
114+
# -- Labels to add to the service account
115+
labels: {}
116+
# -- The name of the service account to use. If not set and create is true, a name is generated.
117+
name: "toolhive-operator"
118+
119+
# -- Leader election role configuration
120+
leaderElectionRole:
121+
# -- Name of the role for leader election
122+
name: toolhive-operator-leader-election-role
123+
binding:
124+
# -- Name of the role binding for leader election
125+
name: toolhive-operator-leader-election-rolebinding
126+
# -- Rules for the leader election role
127+
rules:
128+
- apiGroups:
129+
- ""
130+
resources:
131+
- configmaps
132+
verbs:
133+
- get
134+
- list
135+
- watch
136+
- create
137+
- update
138+
- patch
139+
- delete
140+
- apiGroups:
141+
- coordination.k8s.io
142+
resources:
143+
- leases
144+
verbs:
145+
- get
146+
- list
147+
- watch
148+
- create
149+
- update
150+
- patch
151+
- delete
152+
- apiGroups:
153+
- ""
154+
resources:
155+
- events
156+
verbs:
157+
- create
158+
- patch
159+
160+
# -- Additional volumes to mount on the operator pod
161+
volumes: []
162+
# - name: foo
163+
# secret:
164+
# secretName: mysecret
165+
# optional: false
166+
167+
# -- Additional volume mounts on the operator container
168+
volumeMounts: []
169+
# - name: foo
170+
# mountPath: "/etc/foo"
171+
# readOnly: true
172+
173+
# -- Node selector for the operator pod
174+
nodeSelector: {}
175+
176+
# -- Tolerations for the operator pod
177+
tolerations: []
178+
179+
# -- Affinity settings for the operator pod
180+
affinity: {}

0 commit comments

Comments
 (0)