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
39 changes: 33 additions & 6 deletions cmd/thv/app/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,35 +7,54 @@ import (

"github.com/spf13/cobra"

"github.com/stacklok/toolhive/pkg/export"
"github.com/stacklok/toolhive/pkg/runner"
)

var exportFormat string

func newExportCmd() *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: "export <workload name> <path>",
Short: "Export a workload's run configuration to a file",
Long: `Export a workload's run configuration to a file for sharing or backup.

The exported configuration can be used with 'thv run --from-config <path>' to recreate
the same workload with identical settings.

You can export in different formats:
- json: Export as RunConfig JSON (default, can be used with 'thv run --from-config')
- k8s: Export as Kubernetes MCPServer resource YAML

Examples:

# Export a workload configuration to a file
# Export a workload configuration to a JSON file
thv export my-server ./my-server-config.json

# Export as Kubernetes MCPServer resource
thv export my-server ./my-server.yaml --format k8s

# Export to a specific directory
thv export github-mcp /tmp/configs/github-config.json`,
Args: cobra.ExactArgs(2),
RunE: exportCmdFunc,
}

cmd.Flags().StringVar(&exportFormat, "format", "json", "Export format: json or k8s")

return cmd
}

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

// Validate format
if exportFormat != "json" && exportFormat != "k8s" {
return fmt.Errorf("invalid format '%s': must be 'json' or 'k8s'", exportFormat)
}

// Load the saved run configuration
runConfig, err := runner.LoadState(ctx, workloadName)
if err != nil {
Expand All @@ -56,11 +75,19 @@ func exportCmdFunc(cmd *cobra.Command, args []string) error {
}
defer outputFile.Close()

// Write the configuration to the file
if err := runConfig.WriteJSON(outputFile); err != nil {
return fmt.Errorf("failed to write configuration to file: %w", err)
// Write the configuration based on format
switch exportFormat {
case "json":
if err := runConfig.WriteJSON(outputFile); err != nil {
return fmt.Errorf("failed to write configuration to file: %w", err)
}
fmt.Printf("Successfully exported run configuration for '%s' to '%s'\n", workloadName, outputPath)
case "k8s":
if err := export.WriteK8sManifest(runConfig, outputFile); err != nil {
return fmt.Errorf("failed to write Kubernetes manifest: %w", err)
}
fmt.Printf("Successfully exported Kubernetes MCPServer resource for '%s' to '%s'\n", workloadName, outputPath)
}

fmt.Printf("Successfully exported run configuration for '%s' to '%s'\n", workloadName, outputPath)
return nil
}
2 changes: 1 addition & 1 deletion cmd/thv/app/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func newVersionCmd() *cobra.Command {
// If --json is set, override the format
cmd.PreRun = func(_ *cobra.Command, _ []string) {
if jsonOutput {
outputFormat = "json"
outputFormat = FormatJSON
}
}

Expand Down
12 changes: 10 additions & 2 deletions docs/cli/thv_export.md

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

232 changes: 232 additions & 0 deletions pkg/export/k8s.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
// Package export provides functionality for exporting ToolHive configurations to various formats.
package export

import (
"fmt"
"io"
"strings"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/yaml"

v1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1"
"github.com/stacklok/toolhive/pkg/runner"
"github.com/stacklok/toolhive/pkg/transport/types"
)

// WriteK8sManifest converts a RunConfig to a Kubernetes MCPServer resource and writes it as YAML
func WriteK8sManifest(config *runner.RunConfig, w io.Writer) error {
mcpServer, err := runConfigToMCPServer(config)
if err != nil {
return fmt.Errorf("failed to convert RunConfig to MCPServer: %w", err)
}

yamlBytes, err := yaml.Marshal(mcpServer)
if err != nil {
return fmt.Errorf("failed to marshal MCPServer to YAML: %w", err)
}

_, err = w.Write(yamlBytes)
return err
}

// runConfigToMCPServer converts a RunConfig to a Kubernetes MCPServer resource
// nolint:gocyclo // Complexity due to mapping multiple config fields to K8s resource
func runConfigToMCPServer(config *runner.RunConfig) (*v1alpha1.MCPServer, error) {
// Use the base name or container name for the Kubernetes resource name
name := config.BaseName
if name == "" {
name = config.ContainerName
}
if name == "" {
name = config.Name
}

// Sanitize the name to be a valid Kubernetes resource name
name = sanitizeK8sName(name)

mcpServer := &v1alpha1.MCPServer{
TypeMeta: metav1.TypeMeta{
APIVersion: "toolhive.stacklok.com/v1alpha1",
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
APIVersion: "toolhive.stacklok.com/v1alpha1",
APIVersion: "toolhive.stacklok.dev/v1alpha1",

Kind: "MCPServer",
},
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
Spec: v1alpha1.MCPServerSpec{
Image: config.Image,
Transport: string(config.Transport),
Args: config.CmdArgs,
},
}

// Set port if specified
if config.Port > 0 {
// #nosec G115 -- Port values are validated elsewhere, safe conversion
mcpServer.Spec.Port = int32(config.Port)
}

// Set target port if specified
if config.TargetPort > 0 {
// #nosec G115 -- Port values are validated elsewhere, safe conversion
mcpServer.Spec.TargetPort = int32(config.TargetPort)
}

// Set proxy mode if transport is stdio
if config.Transport == types.TransportTypeStdio && config.ProxyMode != "" {
mcpServer.Spec.ProxyMode = string(config.ProxyMode)
}

// Convert environment variables
if len(config.EnvVars) > 0 {
mcpServer.Spec.Env = make([]v1alpha1.EnvVar, 0, len(config.EnvVars))
for key, value := range config.EnvVars {
mcpServer.Spec.Env = append(mcpServer.Spec.Env, v1alpha1.EnvVar{
Name: key,
Value: value,
})
}
}

// Convert volumes
if len(config.Volumes) > 0 {
mcpServer.Spec.Volumes = make([]v1alpha1.Volume, 0, len(config.Volumes))
for i, vol := range config.Volumes {
volume, err := parseVolumeString(vol, i)
if err != nil {
return nil, fmt.Errorf("failed to parse volume %q: %w", vol, err)
}
mcpServer.Spec.Volumes = append(mcpServer.Spec.Volumes, volume)
}
}

// Convert permission profile
if config.PermissionProfile != nil {
// For now, we export permission profiles as inline ConfigMaps would need to be created separately
// This is a simplified export - users may need to adjust this
mcpServer.Spec.PermissionProfile = &v1alpha1.PermissionProfileRef{
Type: v1alpha1.PermissionProfileTypeBuiltin,
Name: "none", // Default to none, user should adjust based on their needs
}
}

// Convert OIDC config
if config.OIDCConfig != nil {
mcpServer.Spec.OIDCConfig = &v1alpha1.OIDCConfigRef{
Type: v1alpha1.OIDCConfigTypeInline,
Inline: &v1alpha1.InlineOIDCConfig{
Issuer: config.OIDCConfig.Issuer,
Audience: config.OIDCConfig.Audience,
},
}

if config.OIDCConfig.JWKSURL != "" {
mcpServer.Spec.OIDCConfig.Inline.JWKSURL = config.OIDCConfig.JWKSURL
}
}

// Convert authz config
if config.AuthzConfig != nil && config.AuthzConfig.Cedar != nil && len(config.AuthzConfig.Cedar.Policies) > 0 {
mcpServer.Spec.AuthzConfig = &v1alpha1.AuthzConfigRef{
Type: v1alpha1.AuthzConfigTypeInline,
Inline: &v1alpha1.InlineAuthzConfig{
Policies: config.AuthzConfig.Cedar.Policies,
},
}

if config.AuthzConfig.Cedar.EntitiesJSON != "" {
mcpServer.Spec.AuthzConfig.Inline.EntitiesJSON = config.AuthzConfig.Cedar.EntitiesJSON
}
}

// Convert audit config - audit is always enabled if config exists
if config.AuditConfig != nil {
mcpServer.Spec.Audit = &v1alpha1.AuditConfig{
Enabled: true,
}
}

// Convert telemetry config
if config.TelemetryConfig != nil {
mcpServer.Spec.Telemetry = &v1alpha1.TelemetryConfig{}

if config.TelemetryConfig.Endpoint != "" {
mcpServer.Spec.Telemetry.OpenTelemetry = &v1alpha1.OpenTelemetryConfig{
Enabled: true,
Endpoint: config.TelemetryConfig.Endpoint,
Insecure: config.TelemetryConfig.Insecure,
}

if config.TelemetryConfig.ServiceName != "" {
mcpServer.Spec.Telemetry.OpenTelemetry.ServiceName = config.TelemetryConfig.ServiceName
}
}

// Convert Prometheus metrics path setting
if config.TelemetryConfig.EnablePrometheusMetricsPath {
if mcpServer.Spec.Telemetry.Prometheus == nil {
mcpServer.Spec.Telemetry.Prometheus = &v1alpha1.PrometheusConfig{}
}
mcpServer.Spec.Telemetry.Prometheus.Enabled = true
}
}

// Convert tools filter
if len(config.ToolsFilter) > 0 {
mcpServer.Spec.ToolsFilter = config.ToolsFilter
}

return mcpServer, nil
}

// parseVolumeString parses a volume string in the format "host-path:container-path[:ro]"
func parseVolumeString(volStr string, index int) (v1alpha1.Volume, error) {
parts := strings.Split(volStr, ":")
if len(parts) < 2 {
return v1alpha1.Volume{}, fmt.Errorf("invalid volume format, expected 'host-path:container-path[:ro]'")
}

volume := v1alpha1.Volume{
Name: fmt.Sprintf("volume-%d", index),
HostPath: parts[0],
MountPath: parts[1],
ReadOnly: false,
}

// Check for read-only flag
if len(parts) == 3 && parts[2] == "ro" {
volume.ReadOnly = true
}

return volume, nil
}

// sanitizeK8sName sanitizes a string to be a valid Kubernetes resource name
// Kubernetes names must be lowercase alphanumeric with hyphens, max 253 chars
func sanitizeK8sName(name string) string {
// Convert to lowercase
name = strings.ToLower(name)

// Replace invalid characters with hyphens
var result strings.Builder
for _, r := range name {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
result.WriteRune(r)
} else {
result.WriteRune('-')
}
}

// Remove leading/trailing hyphens
sanitized := strings.Trim(result.String(), "-")

// Limit length to 253 characters (Kubernetes limit)
if len(sanitized) > 253 {
sanitized = sanitized[:253]
}

// Ensure we don't end with a hyphen after truncation
sanitized = strings.TrimRight(sanitized, "-")

return sanitized
}
Loading
Loading