Skip to content

Commit 38b9a3f

Browse files
JAORMXclaude
andauthored
Add Kubernetes export format to thv export command (#2072)
* Add Kubernetes export format to thv export command Add support for exporting MCP server configurations as Kubernetes MCPServer resources with the --format flag. Users can now export their running servers to K8s manifests for deployment in Kubernetes. Changes: - Add pkg/export package with k8s.go for converting RunConfig to MCPServer CRD - Enhance thv export command with --format flag (json/k8s) - Convert all RunConfig fields to appropriate MCPServer spec fields - Handle environment variables, volumes, OIDC, authz, audit, telemetry - Sanitize names to comply with Kubernetes naming requirements - Add comprehensive unit tests for all export functionality - Add e2e tests for export command with both formats 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> Signed-off-by: Juan Antonio Osorio <[email protected]> * Address PR feedback for k8s export format Fix issues identified in PR review: 1. Fix APIVersion from toolhive.stacklok.com to toolhive.stacklok.dev - Update pkg/export/k8s.go to use correct API group - Update test assertions in pkg/export/k8s_test.go and test/e2e/export_test.go 2. Add validation for remote MCP servers - Remote servers are not supported in Kubernetes deployments - Return error if RemoteURL is set or Image is empty - Add unit tests for both error cases 3. Add warning for servers with secrets - Detect when secrets are present in RunConfig - Output warning to stderr explaining secrets must be created separately - Display list of secrets that need to be handled 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --------- Signed-off-by: Juan Antonio Osorio <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent e498507 commit 38b9a3f

File tree

6 files changed

+1049
-9
lines changed

6 files changed

+1049
-9
lines changed

cmd/thv/app/export.go

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,35 +7,54 @@ import (
77

88
"github.com/spf13/cobra"
99

10+
"github.com/stacklok/toolhive/pkg/export"
1011
"github.com/stacklok/toolhive/pkg/runner"
1112
)
1213

14+
var exportFormat string
15+
1316
func newExportCmd() *cobra.Command {
14-
return &cobra.Command{
17+
cmd := &cobra.Command{
1518
Use: "export <workload name> <path>",
1619
Short: "Export a workload's run configuration to a file",
1720
Long: `Export a workload's run configuration to a file for sharing or backup.
1821
1922
The exported configuration can be used with 'thv run --from-config <path>' to recreate
2023
the same workload with identical settings.
2124
25+
You can export in different formats:
26+
- json: Export as RunConfig JSON (default, can be used with 'thv run --from-config')
27+
- k8s: Export as Kubernetes MCPServer resource YAML
28+
2229
Examples:
2330
24-
# Export a workload configuration to a file
31+
# Export a workload configuration to a JSON file
2532
thv export my-server ./my-server-config.json
2633
34+
# Export as Kubernetes MCPServer resource
35+
thv export my-server ./my-server.yaml --format k8s
36+
2737
# Export to a specific directory
2838
thv export github-mcp /tmp/configs/github-config.json`,
2939
Args: cobra.ExactArgs(2),
3040
RunE: exportCmdFunc,
3141
}
42+
43+
cmd.Flags().StringVar(&exportFormat, "format", "json", "Export format: json or k8s")
44+
45+
return cmd
3246
}
3347

3448
func exportCmdFunc(cmd *cobra.Command, args []string) error {
3549
ctx := cmd.Context()
3650
workloadName := args[0]
3751
outputPath := args[1]
3852

53+
// Validate format
54+
if exportFormat != "json" && exportFormat != "k8s" {
55+
return fmt.Errorf("invalid format '%s': must be 'json' or 'k8s'", exportFormat)
56+
}
57+
3958
// Load the saved run configuration
4059
runConfig, err := runner.LoadState(ctx, workloadName)
4160
if err != nil {
@@ -56,11 +75,26 @@ func exportCmdFunc(cmd *cobra.Command, args []string) error {
5675
}
5776
defer outputFile.Close()
5877

59-
// Write the configuration to the file
60-
if err := runConfig.WriteJSON(outputFile); err != nil {
61-
return fmt.Errorf("failed to write configuration to file: %w", err)
78+
// Write the configuration based on format
79+
switch exportFormat {
80+
case "json":
81+
if err := runConfig.WriteJSON(outputFile); err != nil {
82+
return fmt.Errorf("failed to write configuration to file: %w", err)
83+
}
84+
fmt.Printf("Successfully exported run configuration for '%s' to '%s'\n", workloadName, outputPath)
85+
case "k8s":
86+
// Check for secrets and warn the user
87+
if len(runConfig.Secrets) > 0 {
88+
fmt.Fprintf(os.Stderr, "Warning: This server uses secrets that cannot be exported to Kubernetes manifests.\n")
89+
fmt.Fprintf(os.Stderr, "You will need to create Kubernetes secrets separately before applying this manifest.\n")
90+
fmt.Fprintf(os.Stderr, "Secrets used: %v\n", runConfig.Secrets)
91+
}
92+
93+
if err := export.WriteK8sManifest(runConfig, outputFile); err != nil {
94+
return fmt.Errorf("failed to write Kubernetes manifest: %w", err)
95+
}
96+
fmt.Printf("Successfully exported Kubernetes MCPServer resource for '%s' to '%s'\n", workloadName, outputPath)
6297
}
6398

64-
fmt.Printf("Successfully exported run configuration for '%s' to '%s'\n", workloadName, outputPath)
6599
return nil
66100
}

cmd/thv/app/version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func newVersionCmd() *cobra.Command {
3838
// If --json is set, override the format
3939
cmd.PreRun = func(_ *cobra.Command, _ []string) {
4040
if jsonOutput {
41-
outputFormat = "json"
41+
outputFormat = FormatJSON
4242
}
4343
}
4444

docs/cli/thv_export.md

Lines changed: 10 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/export/k8s.go

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
// Package export provides functionality for exporting ToolHive configurations to various formats.
2+
package export
3+
4+
import (
5+
"fmt"
6+
"io"
7+
"strings"
8+
9+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
10+
"sigs.k8s.io/yaml"
11+
12+
v1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
13+
"github.com/stacklok/toolhive/pkg/runner"
14+
"github.com/stacklok/toolhive/pkg/transport/types"
15+
)
16+
17+
// WriteK8sManifest converts a RunConfig to a Kubernetes MCPServer resource and writes it as YAML
18+
func WriteK8sManifest(config *runner.RunConfig, w io.Writer) error {
19+
mcpServer, err := runConfigToMCPServer(config)
20+
if err != nil {
21+
return fmt.Errorf("failed to convert RunConfig to MCPServer: %w", err)
22+
}
23+
24+
yamlBytes, err := yaml.Marshal(mcpServer)
25+
if err != nil {
26+
return fmt.Errorf("failed to marshal MCPServer to YAML: %w", err)
27+
}
28+
29+
_, err = w.Write(yamlBytes)
30+
return err
31+
}
32+
33+
// runConfigToMCPServer converts a RunConfig to a Kubernetes MCPServer resource
34+
// nolint:gocyclo // Complexity due to mapping multiple config fields to K8s resource
35+
func runConfigToMCPServer(config *runner.RunConfig) (*v1alpha1.MCPServer, error) {
36+
// Check if this is a remote server - not supported in Kubernetes
37+
if config.RemoteURL != "" {
38+
return nil, fmt.Errorf("remote MCP servers are not supported in Kubernetes deployments")
39+
}
40+
41+
// Verify we have an image - required for Kubernetes
42+
if config.Image == "" {
43+
return nil, fmt.Errorf("image is required for Kubernetes export")
44+
}
45+
46+
// Use the base name or container name for the Kubernetes resource name
47+
name := config.BaseName
48+
if name == "" {
49+
name = config.ContainerName
50+
}
51+
if name == "" {
52+
name = config.Name
53+
}
54+
55+
// Sanitize the name to be a valid Kubernetes resource name
56+
name = sanitizeK8sName(name)
57+
58+
mcpServer := &v1alpha1.MCPServer{
59+
TypeMeta: metav1.TypeMeta{
60+
APIVersion: "toolhive.stacklok.dev/v1alpha1",
61+
Kind: "MCPServer",
62+
},
63+
ObjectMeta: metav1.ObjectMeta{
64+
Name: name,
65+
},
66+
Spec: v1alpha1.MCPServerSpec{
67+
Image: config.Image,
68+
Transport: string(config.Transport),
69+
Args: config.CmdArgs,
70+
},
71+
}
72+
73+
// Set port if specified
74+
if config.Port > 0 {
75+
// #nosec G115 -- Port values are validated elsewhere, safe conversion
76+
mcpServer.Spec.Port = int32(config.Port)
77+
}
78+
79+
// Set target port if specified
80+
if config.TargetPort > 0 {
81+
// #nosec G115 -- Port values are validated elsewhere, safe conversion
82+
mcpServer.Spec.TargetPort = int32(config.TargetPort)
83+
}
84+
85+
// Set proxy mode if transport is stdio
86+
if config.Transport == types.TransportTypeStdio && config.ProxyMode != "" {
87+
mcpServer.Spec.ProxyMode = string(config.ProxyMode)
88+
}
89+
90+
// Convert environment variables
91+
if len(config.EnvVars) > 0 {
92+
mcpServer.Spec.Env = make([]v1alpha1.EnvVar, 0, len(config.EnvVars))
93+
for key, value := range config.EnvVars {
94+
mcpServer.Spec.Env = append(mcpServer.Spec.Env, v1alpha1.EnvVar{
95+
Name: key,
96+
Value: value,
97+
})
98+
}
99+
}
100+
101+
// Convert volumes
102+
if len(config.Volumes) > 0 {
103+
mcpServer.Spec.Volumes = make([]v1alpha1.Volume, 0, len(config.Volumes))
104+
for i, vol := range config.Volumes {
105+
volume, err := parseVolumeString(vol, i)
106+
if err != nil {
107+
return nil, fmt.Errorf("failed to parse volume %q: %w", vol, err)
108+
}
109+
mcpServer.Spec.Volumes = append(mcpServer.Spec.Volumes, volume)
110+
}
111+
}
112+
113+
// Convert permission profile
114+
if config.PermissionProfile != nil {
115+
// For now, we export permission profiles as inline ConfigMaps would need to be created separately
116+
// This is a simplified export - users may need to adjust this
117+
mcpServer.Spec.PermissionProfile = &v1alpha1.PermissionProfileRef{
118+
Type: v1alpha1.PermissionProfileTypeBuiltin,
119+
Name: "none", // Default to none, user should adjust based on their needs
120+
}
121+
}
122+
123+
// Convert OIDC config
124+
if config.OIDCConfig != nil {
125+
mcpServer.Spec.OIDCConfig = &v1alpha1.OIDCConfigRef{
126+
Type: v1alpha1.OIDCConfigTypeInline,
127+
Inline: &v1alpha1.InlineOIDCConfig{
128+
Issuer: config.OIDCConfig.Issuer,
129+
Audience: config.OIDCConfig.Audience,
130+
},
131+
}
132+
133+
if config.OIDCConfig.JWKSURL != "" {
134+
mcpServer.Spec.OIDCConfig.Inline.JWKSURL = config.OIDCConfig.JWKSURL
135+
}
136+
}
137+
138+
// Convert authz config
139+
if config.AuthzConfig != nil && config.AuthzConfig.Cedar != nil && len(config.AuthzConfig.Cedar.Policies) > 0 {
140+
mcpServer.Spec.AuthzConfig = &v1alpha1.AuthzConfigRef{
141+
Type: v1alpha1.AuthzConfigTypeInline,
142+
Inline: &v1alpha1.InlineAuthzConfig{
143+
Policies: config.AuthzConfig.Cedar.Policies,
144+
},
145+
}
146+
147+
if config.AuthzConfig.Cedar.EntitiesJSON != "" {
148+
mcpServer.Spec.AuthzConfig.Inline.EntitiesJSON = config.AuthzConfig.Cedar.EntitiesJSON
149+
}
150+
}
151+
152+
// Convert audit config - audit is always enabled if config exists
153+
if config.AuditConfig != nil {
154+
mcpServer.Spec.Audit = &v1alpha1.AuditConfig{
155+
Enabled: true,
156+
}
157+
}
158+
159+
// Convert telemetry config
160+
if config.TelemetryConfig != nil {
161+
mcpServer.Spec.Telemetry = &v1alpha1.TelemetryConfig{}
162+
163+
if config.TelemetryConfig.Endpoint != "" {
164+
mcpServer.Spec.Telemetry.OpenTelemetry = &v1alpha1.OpenTelemetryConfig{
165+
Enabled: true,
166+
Endpoint: config.TelemetryConfig.Endpoint,
167+
Insecure: config.TelemetryConfig.Insecure,
168+
}
169+
170+
if config.TelemetryConfig.ServiceName != "" {
171+
mcpServer.Spec.Telemetry.OpenTelemetry.ServiceName = config.TelemetryConfig.ServiceName
172+
}
173+
}
174+
175+
// Convert Prometheus metrics path setting
176+
if config.TelemetryConfig.EnablePrometheusMetricsPath {
177+
if mcpServer.Spec.Telemetry.Prometheus == nil {
178+
mcpServer.Spec.Telemetry.Prometheus = &v1alpha1.PrometheusConfig{}
179+
}
180+
mcpServer.Spec.Telemetry.Prometheus.Enabled = true
181+
}
182+
}
183+
184+
// Convert tools filter
185+
if len(config.ToolsFilter) > 0 {
186+
mcpServer.Spec.ToolsFilter = config.ToolsFilter
187+
}
188+
189+
return mcpServer, nil
190+
}
191+
192+
// parseVolumeString parses a volume string in the format "host-path:container-path[:ro]"
193+
func parseVolumeString(volStr string, index int) (v1alpha1.Volume, error) {
194+
parts := strings.Split(volStr, ":")
195+
if len(parts) < 2 {
196+
return v1alpha1.Volume{}, fmt.Errorf("invalid volume format, expected 'host-path:container-path[:ro]'")
197+
}
198+
199+
volume := v1alpha1.Volume{
200+
Name: fmt.Sprintf("volume-%d", index),
201+
HostPath: parts[0],
202+
MountPath: parts[1],
203+
ReadOnly: false,
204+
}
205+
206+
// Check for read-only flag
207+
if len(parts) == 3 && parts[2] == "ro" {
208+
volume.ReadOnly = true
209+
}
210+
211+
return volume, nil
212+
}
213+
214+
// sanitizeK8sName sanitizes a string to be a valid Kubernetes resource name
215+
// Kubernetes names must be lowercase alphanumeric with hyphens, max 253 chars
216+
func sanitizeK8sName(name string) string {
217+
// Convert to lowercase
218+
name = strings.ToLower(name)
219+
220+
// Replace invalid characters with hyphens
221+
var result strings.Builder
222+
for _, r := range name {
223+
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
224+
result.WriteRune(r)
225+
} else {
226+
result.WriteRune('-')
227+
}
228+
}
229+
230+
// Remove leading/trailing hyphens
231+
sanitized := strings.Trim(result.String(), "-")
232+
233+
// Limit length to 253 characters (Kubernetes limit)
234+
if len(sanitized) > 253 {
235+
sanitized = sanitized[:253]
236+
}
237+
238+
// Ensure we don't end with a hyphen after truncation
239+
sanitized = strings.TrimRight(sanitized, "-")
240+
241+
return sanitized
242+
}

0 commit comments

Comments
 (0)