Skip to content

Commit ea576eb

Browse files
JAORMXclaude
andcommitted
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]>
1 parent b58c533 commit ea576eb

File tree

6 files changed

+1011
-9
lines changed

6 files changed

+1011
-9
lines changed

cmd/thv/app/export.go

Lines changed: 33 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,19 @@ 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+
if err := export.WriteK8sManifest(runConfig, outputFile); err != nil {
87+
return fmt.Errorf("failed to write Kubernetes manifest: %w", err)
88+
}
89+
fmt.Printf("Successfully exported Kubernetes MCPServer resource for '%s' to '%s'\n", workloadName, outputPath)
6290
}
6391

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

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

0 commit comments

Comments
 (0)