diff --git a/cmd/thv/app/export.go b/cmd/thv/app/export.go index 8270271b9..ffe3ef59b 100644 --- a/cmd/thv/app/export.go +++ b/cmd/thv/app/export.go @@ -7,11 +7,14 @@ 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 ", Short: "Export a workload's run configuration to a file", Long: `Export a workload's run configuration to a file for sharing or backup. @@ -19,16 +22,27 @@ func newExportCmd() *cobra.Command { The exported configuration can be used with 'thv run --from-config ' 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 { @@ -36,6 +50,11 @@ func exportCmdFunc(cmd *cobra.Command, args []string) error { 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 { @@ -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 } diff --git a/cmd/thv/app/version.go b/cmd/thv/app/version.go index af663ef51..4e00d9a12 100644 --- a/cmd/thv/app/version.go +++ b/cmd/thv/app/version.go @@ -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 } } diff --git a/docs/cli/thv_export.md b/docs/cli/thv_export.md index ff9a2d70b..db3fd7f55 100644 --- a/docs/cli/thv_export.md +++ b/docs/cli/thv_export.md @@ -20,11 +20,18 @@ Export a workload's run configuration to a file for sharing or backup. The exported configuration can be used with 'thv run --from-config ' 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 @@ -35,7 +42,8 @@ thv export [flags] ### Options ``` - -h, --help help for export + --format string Export format: json or k8s (default "json") + -h, --help help for export ``` ### Options inherited from parent commands diff --git a/pkg/export/k8s.go b/pkg/export/k8s.go new file mode 100644 index 000000000..0bfdd9019 --- /dev/null +++ b/pkg/export/k8s.go @@ -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", + 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 +} diff --git a/pkg/export/k8s_test.go b/pkg/export/k8s_test.go new file mode 100644 index 000000000..4be380289 --- /dev/null +++ b/pkg/export/k8s_test.go @@ -0,0 +1,512 @@ +package export + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" + + v1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + "github.com/stacklok/toolhive/pkg/audit" + "github.com/stacklok/toolhive/pkg/auth" + "github.com/stacklok/toolhive/pkg/authz" + "github.com/stacklok/toolhive/pkg/permissions" + "github.com/stacklok/toolhive/pkg/runner" + "github.com/stacklok/toolhive/pkg/telemetry" + "github.com/stacklok/toolhive/pkg/transport/types" +) + +func TestWriteK8sManifest(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config *runner.RunConfig + wantErr bool + validateFn func(t *testing.T, mcpServer *v1alpha1.MCPServer) + }{ + { + name: "basic stdio config", + config: &runner.RunConfig{ + Image: "ghcr.io/stacklok/mcp-server-github:latest", + Name: "github", + BaseName: "github", + ContainerName: "thv-github", + Transport: types.TransportTypeStdio, + ProxyMode: types.ProxyModeSSE, + Port: 8080, + CmdArgs: []string{"--verbose"}, + }, + validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { + t.Helper() + assert.Equal(t, "toolhive.stacklok.com/v1alpha1", mcpServer.APIVersion) + assert.Equal(t, "MCPServer", mcpServer.Kind) + assert.Equal(t, "github", mcpServer.Name) + assert.Equal(t, "ghcr.io/stacklok/mcp-server-github:latest", mcpServer.Spec.Image) + assert.Equal(t, "stdio", mcpServer.Spec.Transport) + assert.Equal(t, "sse", mcpServer.Spec.ProxyMode) + assert.Equal(t, int32(8080), mcpServer.Spec.Port) + assert.Equal(t, []string{"--verbose"}, mcpServer.Spec.Args) + }, + }, + { + name: "sse transport with target port", + config: &runner.RunConfig{ + Image: "ghcr.io/stacklok/mcp-server-fetch:latest", + Name: "fetch", + BaseName: "fetch", + Transport: types.TransportTypeSSE, + Port: 8081, + TargetPort: 3000, + }, + validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { + t.Helper() + assert.Equal(t, "sse", mcpServer.Spec.Transport) + assert.Equal(t, int32(8081), mcpServer.Spec.Port) + assert.Equal(t, int32(3000), mcpServer.Spec.TargetPort) + }, + }, + { + name: "config with environment variables", + config: &runner.RunConfig{ + Image: "ghcr.io/stacklok/mcp-server-github:latest", + Name: "github", + BaseName: "github", + Transport: types.TransportTypeStdio, + EnvVars: map[string]string{ + "GITHUB_TOKEN": "secret-token", + "DEBUG": "true", + }, + }, + validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { + t.Helper() + require.Len(t, mcpServer.Spec.Env, 2) + envMap := make(map[string]string) + for _, env := range mcpServer.Spec.Env { + envMap[env.Name] = env.Value + } + assert.Equal(t, "secret-token", envMap["GITHUB_TOKEN"]) + assert.Equal(t, "true", envMap["DEBUG"]) + }, + }, + { + name: "config with volumes", + config: &runner.RunConfig{ + Image: "ghcr.io/stacklok/mcp-server:latest", + Name: "test", + BaseName: "test", + Transport: types.TransportTypeStdio, + Volumes: []string{ + "/host/path:/container/path", + "/readonly:/data:ro", + }, + }, + validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { + t.Helper() + require.Len(t, mcpServer.Spec.Volumes, 2) + assert.Equal(t, "/host/path", mcpServer.Spec.Volumes[0].HostPath) + assert.Equal(t, "/container/path", mcpServer.Spec.Volumes[0].MountPath) + assert.False(t, mcpServer.Spec.Volumes[0].ReadOnly) + assert.Equal(t, "/readonly", mcpServer.Spec.Volumes[1].HostPath) + assert.Equal(t, "/data", mcpServer.Spec.Volumes[1].MountPath) + assert.True(t, mcpServer.Spec.Volumes[1].ReadOnly) + }, + }, + { + name: "config with permission profile", + config: &runner.RunConfig{ + Image: "ghcr.io/stacklok/mcp-server:latest", + Name: "test", + BaseName: "test", + Transport: types.TransportTypeStdio, + PermissionProfile: &permissions.Profile{ + Read: []permissions.MountDeclaration{"/data"}, + Write: []permissions.MountDeclaration{"/output"}, + }, + }, + validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { + t.Helper() + require.NotNil(t, mcpServer.Spec.PermissionProfile) + assert.Equal(t, v1alpha1.PermissionProfileTypeBuiltin, mcpServer.Spec.PermissionProfile.Type) + assert.Equal(t, "none", mcpServer.Spec.PermissionProfile.Name) + }, + }, + { + name: "config with OIDC", + config: &runner.RunConfig{ + Image: "ghcr.io/stacklok/mcp-server:latest", + Name: "test", + BaseName: "test", + Transport: types.TransportTypeStdio, + OIDCConfig: &auth.TokenValidatorConfig{ + Issuer: "https://accounts.google.com", + Audience: "my-client-id", + JWKSURL: "https://accounts.google.com/.well-known/jwks.json", + }, + }, + validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { + t.Helper() + require.NotNil(t, mcpServer.Spec.OIDCConfig) + assert.Equal(t, v1alpha1.OIDCConfigTypeInline, mcpServer.Spec.OIDCConfig.Type) + require.NotNil(t, mcpServer.Spec.OIDCConfig.Inline) + assert.Equal(t, "https://accounts.google.com", mcpServer.Spec.OIDCConfig.Inline.Issuer) + assert.Equal(t, "my-client-id", mcpServer.Spec.OIDCConfig.Inline.Audience) + assert.Equal(t, "https://accounts.google.com/.well-known/jwks.json", mcpServer.Spec.OIDCConfig.Inline.JWKSURL) + }, + }, + { + name: "config with authz", + config: &runner.RunConfig{ + Image: "ghcr.io/stacklok/mcp-server:latest", + Name: "test", + BaseName: "test", + Transport: types.TransportTypeStdio, + AuthzConfig: &authz.Config{ + Type: authz.ConfigTypeCedarV1, + Cedar: &authz.CedarConfig{ + Policies: []string{ + "permit(principal, action, resource);", + }, + EntitiesJSON: "[]", + }, + }, + }, + validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { + t.Helper() + require.NotNil(t, mcpServer.Spec.AuthzConfig) + assert.Equal(t, v1alpha1.AuthzConfigTypeInline, mcpServer.Spec.AuthzConfig.Type) + require.NotNil(t, mcpServer.Spec.AuthzConfig.Inline) + require.Len(t, mcpServer.Spec.AuthzConfig.Inline.Policies, 1) + assert.Equal(t, "permit(principal, action, resource);", mcpServer.Spec.AuthzConfig.Inline.Policies[0]) + assert.Equal(t, "[]", mcpServer.Spec.AuthzConfig.Inline.EntitiesJSON) + }, + }, + { + name: "config with audit", + config: &runner.RunConfig{ + Image: "ghcr.io/stacklok/mcp-server:latest", + Name: "test", + BaseName: "test", + Transport: types.TransportTypeStdio, + AuditConfig: &audit.Config{ + Component: "test-component", + }, + }, + validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { + t.Helper() + require.NotNil(t, mcpServer.Spec.Audit) + assert.True(t, mcpServer.Spec.Audit.Enabled) + }, + }, + { + name: "config with telemetry", + config: &runner.RunConfig{ + Image: "ghcr.io/stacklok/mcp-server:latest", + Name: "test", + BaseName: "test", + Transport: types.TransportTypeStdio, + TelemetryConfig: &telemetry.Config{ + Endpoint: "http://otel-collector:4318", + ServiceName: "my-service", + Insecure: true, + }, + }, + validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { + t.Helper() + require.NotNil(t, mcpServer.Spec.Telemetry) + require.NotNil(t, mcpServer.Spec.Telemetry.OpenTelemetry) + assert.True(t, mcpServer.Spec.Telemetry.OpenTelemetry.Enabled) + assert.Equal(t, "http://otel-collector:4318", mcpServer.Spec.Telemetry.OpenTelemetry.Endpoint) + assert.Equal(t, "my-service", mcpServer.Spec.Telemetry.OpenTelemetry.ServiceName) + assert.True(t, mcpServer.Spec.Telemetry.OpenTelemetry.Insecure) + }, + }, + { + name: "config with prometheus metrics", + config: &runner.RunConfig{ + Image: "ghcr.io/stacklok/mcp-server:latest", + Name: "test", + BaseName: "test", + Transport: types.TransportTypeStdio, + TelemetryConfig: &telemetry.Config{ + EnablePrometheusMetricsPath: true, + }, + }, + validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { + t.Helper() + require.NotNil(t, mcpServer.Spec.Telemetry) + require.NotNil(t, mcpServer.Spec.Telemetry.Prometheus) + assert.True(t, mcpServer.Spec.Telemetry.Prometheus.Enabled) + }, + }, + { + name: "config with tools filter", + config: &runner.RunConfig{ + Image: "ghcr.io/stacklok/mcp-server:latest", + Name: "test", + BaseName: "test", + Transport: types.TransportTypeStdio, + ToolsFilter: []string{"tool1", "tool2"}, + }, + validateFn: func(t *testing.T, mcpServer *v1alpha1.MCPServer) { + t.Helper() + require.Len(t, mcpServer.Spec.ToolsFilter, 2) + assert.Equal(t, "tool1", mcpServer.Spec.ToolsFilter[0]) + assert.Equal(t, "tool2", mcpServer.Spec.ToolsFilter[1]) + }, + }, + { + name: "invalid volume format", + config: &runner.RunConfig{ + Image: "ghcr.io/stacklok/mcp-server:latest", + Name: "test", + BaseName: "test", + Transport: types.TransportTypeStdio, + Volumes: []string{ + "invalid", + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + err := WriteK8sManifest(tt.config, &buf) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.NotEmpty(t, buf.String()) + + // Parse the YAML to validate structure + var mcpServer v1alpha1.MCPServer + err = yaml.Unmarshal(buf.Bytes(), &mcpServer) + require.NoError(t, err) + + // Run custom validation + if tt.validateFn != nil { + tt.validateFn(t, &mcpServer) + } + }) + } +} + +func TestParseVolumeString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + volStr string + index int + wantVol v1alpha1.Volume + wantErr bool + }{ + { + name: "basic volume", + volStr: "/host/path:/container/path", + index: 0, + wantVol: v1alpha1.Volume{ + Name: "volume-0", + HostPath: "/host/path", + MountPath: "/container/path", + ReadOnly: false, + }, + }, + { + name: "read-only volume", + volStr: "/host/path:/container/path:ro", + index: 1, + wantVol: v1alpha1.Volume{ + Name: "volume-1", + HostPath: "/host/path", + MountPath: "/container/path", + ReadOnly: true, + }, + }, + { + name: "invalid format - missing colon", + volStr: "/host/path", + index: 0, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + vol, err := parseVolumeString(tt.volStr, tt.index) + + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantVol, vol) + }) + } +} + +func TestSanitizeK8sName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple lowercase", + input: "test", + expected: "test", + }, + { + name: "uppercase to lowercase", + input: "TEST", + expected: "test", + }, + { + name: "with hyphens", + input: "test-server", + expected: "test-server", + }, + { + name: "with underscores", + input: "test_server", + expected: "test-server", + }, + { + name: "with special characters", + input: "test@server!", + expected: "test-server", + }, + { + name: "leading and trailing hyphens", + input: "-test-", + expected: "test", + }, + { + name: "multiple special characters", + input: "test___server", + expected: "test---server", + }, + { + name: "alphanumeric", + input: "test123", + expected: "test123", + }, + { + name: "long name over 253 chars", + input: strings.Repeat("a", 300), + expected: strings.Repeat("a", 253), + }, + { + name: "long name with trailing hyphen after truncation", + input: strings.Repeat("a", 252) + "-" + strings.Repeat("b", 50), + expected: strings.Repeat("a", 252), + }, + { + name: "container name format", + input: "thv-github", + expected: "thv-github", + }, + { + name: "image-based name", + input: "ghcr.io/stacklok/mcp-server-github", + expected: "ghcr-io-stacklok-mcp-server-github", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := sanitizeK8sName(tt.input) + assert.Equal(t, tt.expected, result) + + // Validate that result is a valid Kubernetes name + assert.LessOrEqual(t, len(result), 253) + assert.NotEmpty(t, result) + assert.NotContains(t, result, "_") + assert.NotContains(t, result, ".") + if len(result) > 0 { + assert.NotEqual(t, "-", string(result[0])) + assert.NotEqual(t, "-", string(result[len(result)-1])) + } + }) + } +} + +func TestRunConfigToMCPServer(t *testing.T) { + t.Parallel() + + t.Run("uses base name for resource name", func(t *testing.T) { + t.Parallel() + + config := &runner.RunConfig{ + Image: "test:latest", + BaseName: "my-base-name", + ContainerName: "thv-my-container", + Name: "my-name", + Transport: types.TransportTypeStdio, + } + + mcpServer, err := runConfigToMCPServer(config) + require.NoError(t, err) + assert.Equal(t, "my-base-name", mcpServer.Name) + }) + + t.Run("falls back to container name", func(t *testing.T) { + t.Parallel() + + config := &runner.RunConfig{ + Image: "test:latest", + ContainerName: "thv-my-container", + Name: "my-name", + Transport: types.TransportTypeStdio, + } + + mcpServer, err := runConfigToMCPServer(config) + require.NoError(t, err) + assert.Equal(t, "thv-my-container", mcpServer.Name) + }) + + t.Run("falls back to name", func(t *testing.T) { + t.Parallel() + + config := &runner.RunConfig{ + Image: "test:latest", + Name: "my-name", + Transport: types.TransportTypeStdio, + } + + mcpServer, err := runConfigToMCPServer(config) + require.NoError(t, err) + assert.Equal(t, "my-name", mcpServer.Name) + }) + + t.Run("sanitizes name", func(t *testing.T) { + t.Parallel() + + config := &runner.RunConfig{ + Image: "test:latest", + Name: "My_Name_With_CAPS", + Transport: types.TransportTypeStdio, + } + + mcpServer, err := runConfigToMCPServer(config) + require.NoError(t, err) + assert.Equal(t, "my-name-with-caps", mcpServer.Name) + }) +} diff --git a/test/e2e/export_test.go b/test/e2e/export_test.go new file mode 100644 index 000000000..935fcaddd --- /dev/null +++ b/test/e2e/export_test.go @@ -0,0 +1,223 @@ +package e2e_test + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "sigs.k8s.io/yaml" + + v1alpha1 "github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1" + "github.com/stacklok/toolhive/pkg/runner" + "github.com/stacklok/toolhive/test/e2e" +) + +var _ = Describe("Export Command", Label("core", "export", "e2e"), func() { + var ( + config *e2e.TestConfig + serverName string + tempDir string + ) + + BeforeEach(func() { + config = e2e.NewTestConfig() + serverName = generateExportTestServerName("export-test") + tempDir = GinkgoT().TempDir() + + // Check if thv binary is available + err := e2e.CheckTHVBinaryAvailable(config) + Expect(err).ToNot(HaveOccurred(), "thv binary should be available") + }) + + AfterEach(func() { + if config.CleanupAfter { + // Clean up the server if it exists + err := e2e.StopAndRemoveMCPServer(config, serverName) + Expect(err).ToNot(HaveOccurred(), "Should be able to stop and remove server") + } + }) + + Describe("Exporting server configurations", func() { + Context("when exporting as JSON (default format)", func() { + It("should export a valid RunConfig JSON", func() { + By("Starting an OSV MCP server") + stdout, stderr := e2e.NewTHVCommand(config, "run", "--name", serverName, "osv").ExpectSuccess() + Expect(stdout+stderr).To(ContainSubstring("osv"), "Output should mention the osv server") + + By("Waiting for the server to be running") + err := e2e.WaitForMCPServer(config, serverName, 60*time.Second) + Expect(err).ToNot(HaveOccurred(), "Server should be running within 60 seconds") + + By("Exporting the server configuration to JSON") + exportPath := filepath.Join(tempDir, "export.json") + stdout, _ = e2e.NewTHVCommand(config, "export", serverName, exportPath).ExpectSuccess() + Expect(stdout).To(ContainSubstring("Successfully exported run configuration")) + + By("Verifying the exported file exists and is valid JSON") + Expect(exportPath).To(BeAnExistingFile()) + + fileContent, err := os.ReadFile(exportPath) + Expect(err).ToNot(HaveOccurred()) + + var runConfig runner.RunConfig + err = json.Unmarshal(fileContent, &runConfig) + Expect(err).ToNot(HaveOccurred(), "Exported file should be valid JSON") + + By("Verifying the exported configuration contains expected fields") + Expect(runConfig.Image).ToNot(BeEmpty(), "Image should be set") + Expect(runConfig.Name).To(Equal(serverName), "Name should match") + Expect(runConfig.Transport).ToNot(BeEmpty(), "Transport should be set") + Expect(runConfig.SchemaVersion).ToNot(BeEmpty(), "Schema version should be set") + }) + }) + + Context("when exporting as Kubernetes manifest", func() { + It("should export a valid MCPServer YAML", func() { + By("Starting an OSV MCP server") + stdout, stderr := e2e.NewTHVCommand(config, "run", "--name", serverName, "osv").ExpectSuccess() + Expect(stdout+stderr).To(ContainSubstring("osv"), "Output should mention the osv server") + + By("Waiting for the server to be running") + err := e2e.WaitForMCPServer(config, serverName, 60*time.Second) + Expect(err).ToNot(HaveOccurred(), "Server should be running within 60 seconds") + + By("Exporting the server configuration to Kubernetes YAML") + exportPath := filepath.Join(tempDir, "export.yaml") + stdout, _ = e2e.NewTHVCommand(config, "export", serverName, exportPath, "--format", "k8s").ExpectSuccess() + Expect(stdout).To(ContainSubstring("Successfully exported Kubernetes MCPServer resource")) + + By("Verifying the exported file exists and is valid YAML") + Expect(exportPath).To(BeAnExistingFile()) + + fileContent, err := os.ReadFile(exportPath) + Expect(err).ToNot(HaveOccurred()) + + var mcpServer v1alpha1.MCPServer + err = yaml.Unmarshal(fileContent, &mcpServer) + Expect(err).ToNot(HaveOccurred(), "Exported file should be valid YAML") + + By("Verifying the exported MCPServer has correct structure") + Expect(mcpServer.APIVersion).To(Equal("toolhive.stacklok.com/v1alpha1")) + Expect(mcpServer.Kind).To(Equal("MCPServer")) + Expect(mcpServer.Name).ToNot(BeEmpty(), "Name should be set") + Expect(mcpServer.Spec.Image).ToNot(BeEmpty(), "Image should be set") + Expect(mcpServer.Spec.Transport).ToNot(BeEmpty(), "Transport should be set") + }) + }) + + Context("when exporting a server with environment variables", func() { + It("should include environment variables in the export", func() { + By("Starting a server with environment variables") + stdout, stderr := e2e.NewTHVCommand(config, + "run", + "--name", serverName, + "--env", "TEST_VAR=test_value", + "--env", "ANOTHER_VAR=another_value", + "osv", + ).ExpectSuccess() + Expect(stdout+stderr).To(ContainSubstring("osv"), "Output should mention the osv server") + + By("Waiting for the server to be running") + err := e2e.WaitForMCPServer(config, serverName, 60*time.Second) + Expect(err).ToNot(HaveOccurred(), "Server should be running within 60 seconds") + + By("Exporting as JSON and verifying environment variables") + jsonPath := filepath.Join(tempDir, "with-env.json") + e2e.NewTHVCommand(config, "export", serverName, jsonPath).ExpectSuccess() + + fileContent, err := os.ReadFile(jsonPath) + Expect(err).ToNot(HaveOccurred()) + + var runConfig runner.RunConfig + err = json.Unmarshal(fileContent, &runConfig) + Expect(err).ToNot(HaveOccurred()) + + Expect(runConfig.EnvVars).To(HaveKey("TEST_VAR")) + Expect(runConfig.EnvVars["TEST_VAR"]).To(Equal("test_value")) + Expect(runConfig.EnvVars).To(HaveKey("ANOTHER_VAR")) + Expect(runConfig.EnvVars["ANOTHER_VAR"]).To(Equal("another_value")) + + By("Exporting as Kubernetes and verifying environment variables") + yamlPath := filepath.Join(tempDir, "with-env.yaml") + e2e.NewTHVCommand(config, "export", serverName, yamlPath, "--format", "k8s").ExpectSuccess() + + fileContent, err = os.ReadFile(yamlPath) + Expect(err).ToNot(HaveOccurred()) + + var mcpServer v1alpha1.MCPServer + err = yaml.Unmarshal(fileContent, &mcpServer) + Expect(err).ToNot(HaveOccurred()) + + Expect(mcpServer.Spec.Env).ToNot(BeEmpty()) + envMap := make(map[string]string) + for _, env := range mcpServer.Spec.Env { + envMap[env.Name] = env.Value + } + Expect(envMap).To(HaveKey("TEST_VAR")) + Expect(envMap["TEST_VAR"]).To(Equal("test_value")) + Expect(envMap).To(HaveKey("ANOTHER_VAR")) + Expect(envMap["ANOTHER_VAR"]).To(Equal("another_value")) + }) + }) + + Context("when exporting with invalid format", func() { + It("should fail with an appropriate error", func() { + By("Starting an OSV MCP server") + stdout, stderr := e2e.NewTHVCommand(config, "run", "--name", serverName, "osv").ExpectSuccess() + Expect(stdout+stderr).To(ContainSubstring("osv"), "Output should mention the osv server") + + By("Waiting for the server to be running") + err := e2e.WaitForMCPServer(config, serverName, 60*time.Second) + Expect(err).ToNot(HaveOccurred(), "Server should be running within 60 seconds") + + By("Attempting to export with an invalid format") + exportPath := filepath.Join(tempDir, "invalid.txt") + _, stderr, err = e2e.NewTHVCommand(config, "export", serverName, exportPath, "--format", "invalid").ExpectFailure() + Expect(stderr).To(ContainSubstring("invalid format")) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("when exporting a non-existent server", func() { + It("should fail with an appropriate error", func() { + By("Attempting to export a non-existent server") + exportPath := filepath.Join(tempDir, "nonexistent.json") + _, stderr, err := e2e.NewTHVCommand(config, "export", "nonexistent-server", exportPath).ExpectFailure() + Expect(stderr).To(Or( + ContainSubstring("not found"), + ContainSubstring("failed to load"), + )) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("when creating nested directories for export", func() { + It("should create the directory structure", func() { + By("Starting an OSV MCP server") + stdout, stderr := e2e.NewTHVCommand(config, "run", "--name", serverName, "osv").ExpectSuccess() + Expect(stdout+stderr).To(ContainSubstring("osv"), "Output should mention the osv server") + + By("Waiting for the server to be running") + err := e2e.WaitForMCPServer(config, serverName, 60*time.Second) + Expect(err).ToNot(HaveOccurred(), "Server should be running within 60 seconds") + + By("Exporting to a nested directory path") + exportPath := filepath.Join(tempDir, "nested", "dirs", "export.json") + stdout, _ = e2e.NewTHVCommand(config, "export", serverName, exportPath).ExpectSuccess() + Expect(stdout).To(ContainSubstring("Successfully exported run configuration")) + + By("Verifying the nested directories were created") + Expect(exportPath).To(BeAnExistingFile()) + }) + }) + }) +}) + +// generateExportTestServerName creates a unique server name for export tests +func generateExportTestServerName(prefix string) string { + return fmt.Sprintf("%s-%d", prefix, GinkgoRandomSeed()) +}