Skip to content

feat: Add OCI artifact support for runtime configurations sharing #1212

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
67 changes: 57 additions & 10 deletions cmd/thv/app/export.go
Original file line number Diff line number Diff line change
@@ -1,46 +1,93 @@
package app

import (
"context"
"fmt"
"os"
"path/filepath"

"github.com/spf13/cobra"

"github.com/stacklok/toolhive/pkg/container/images"
"github.com/stacklok/toolhive/pkg/oci"
"github.com/stacklok/toolhive/pkg/runner"
"github.com/stacklok/toolhive/pkg/runner/export"
)

func newExportCmd() *cobra.Command {
return &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.
cmd := &cobra.Command{
Use: "export <workload name> <path|oci-reference>",
Short: "Export a workload's run configuration to a file or OCI artifact",
Long: `Export a workload's run configuration to a file or OCI artifact for sharing or backup.

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

When exporting to an OCI reference, the artifact is pushed to the remote registry.
When exporting to a file path, the configuration is saved as a JSON file.

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

# Export to a specific directory
thv export github-mcp /tmp/configs/github-config.json`,
thv export github-mcp /tmp/configs/github-config.json

# Export to an OCI artifact (pushes to registry)
thv export my-server registry.example.com/configs/my-server:latest`,
Args: cobra.ExactArgs(2),
RunE: exportCmdFunc,
}

return cmd
}

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

// Load the saved run configuration
runnerInstance, err := runner.LoadState(ctx, workloadName)
if err != nil {
return fmt.Errorf("failed to load run configuration for workload '%s': %w", workloadName, err)
}

// Determine if destination is an OCI reference or file path
if oci.IsOCIReference(destination) {
return exportToOCI(ctx, runnerInstance.Config, workloadName, destination)
}

return exportToFile(runnerInstance.Config, workloadName, destination)
}

// exportToOCI exports the configuration to an OCI artifact
func exportToOCI(ctx context.Context, config *runner.RunConfig, workloadName, ref string) error {
// Validate the OCI reference early
if err := oci.ValidateReference(ref); err != nil {
return fmt.Errorf("invalid OCI reference: %w", err)
}

imageManager := images.NewImageManager(ctx)
ociClient := oci.NewClient(imageManager)
exporter := export.NewOCIExporter(ociClient)

// Push directly to registry (no local storage for OCI artifacts)
if err := exporter.PushRunConfig(ctx, config, ref); err != nil {
return fmt.Errorf("failed to push configuration to OCI registry: %w", err)
}
fmt.Printf("Successfully exported run configuration for '%s' to OCI registry '%s'\n", workloadName, ref)

return nil
}

// exportToFile exports the configuration to a local file
func exportToFile(config *runner.RunConfig, workloadName, outputPath string) error {
// Validate input
if config == nil {
return fmt.Errorf("configuration cannot be nil")
}

// Ensure the output directory exists
outputDir := filepath.Dir(outputPath)
if err := os.MkdirAll(outputDir, 0750); err != nil {
Expand All @@ -56,10 +103,10 @@ func exportCmdFunc(cmd *cobra.Command, args []string) error {
defer outputFile.Close()

// Write the configuration to the file
if err := runnerInstance.Config.WriteJSON(outputFile); err != nil {
if err := config.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)
fmt.Printf("Successfully exported run configuration for '%s' to file '%s'\n", workloadName, outputPath)
return nil
}
32 changes: 32 additions & 0 deletions cmd/thv/app/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package app

import (
"testing"

"github.com/stretchr/testify/assert"

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

func TestExportToFile_InvalidPath(t *testing.T) {
t.Parallel()

// Create a valid config for testing
config := &runner.RunConfig{
Name: "test-config",
Image: "test-image:latest",
}

// Test with invalid directory path
err := exportToFile(config, "test", "/invalid/path/that/does/not/exist/config.json")
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to create output directory")
}

func TestExportToFile_NilConfig(t *testing.T) {
t.Parallel()

// Test with nil config
err := exportToFile(nil, "test", "/tmp/test-config.json")
assert.Error(t, err)
}
9 changes: 7 additions & 2 deletions cmd/thv/app/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ var runCmd = &cobra.Command{
Short: "Run an MCP server",
Long: `Run an MCP server with the specified name, image, or protocol scheme.

ToolHive supports four ways to run an MCP server:
ToolHive supports five ways to run an MCP server:

1. From the registry:
$ thv run server-name [-- args...]
Expand All @@ -45,7 +45,12 @@ ToolHive supports four ways to run an MCP server:
or go (Golang). For Go, you can also specify local paths starting
with './' or '../' to build and run local Go projects.

4. From an exported configuration:
4. From an OCI runtime configuration artifact:
$ thv run registry.example.com/configs/my-server:latest
Runs an MCP server using a runtime configuration stored as an OCI artifact.
The system automatically detects and loads the configuration.

5. From an exported configuration:
$ thv run --from-config <path>
Runs an MCP server using a previously exported configuration file.

Expand Down
60 changes: 60 additions & 0 deletions cmd/thv/app/run_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ import (

cfg "github.com/stacklok/toolhive/pkg/config"
"github.com/stacklok/toolhive/pkg/container"
"github.com/stacklok/toolhive/pkg/container/images"
"github.com/stacklok/toolhive/pkg/container/runtime"
"github.com/stacklok/toolhive/pkg/logger"
"github.com/stacklok/toolhive/pkg/oci"
"github.com/stacklok/toolhive/pkg/process"
"github.com/stacklok/toolhive/pkg/registry"
"github.com/stacklok/toolhive/pkg/runner"
"github.com/stacklok/toolhive/pkg/runner/export"
"github.com/stacklok/toolhive/pkg/runner/retriever"
"github.com/stacklok/toolhive/pkg/transport"
"github.com/stacklok/toolhive/pkg/transport/types"
Expand Down Expand Up @@ -317,3 +321,59 @@ func getTelemetryFromFlags(cmd *cobra.Command, config *cfg.Config, otelEndpoint

return finalOtelEndpoint, finalOtelSamplingRate, finalOtelEnvironmentVariables
}

// isOCIRuntimeConfigArtifact checks if the serverOrImage is an OCI runtime configuration artifact
func isOCIRuntimeConfigArtifact(ctx context.Context, serverOrImage string) bool {
// Try to parse as OCI reference first
if !oci.IsOCIReference(serverOrImage) {
return false
}

// Try to load as runtime configuration
imageManager := images.NewImageManager(ctx)
ociClient := oci.NewClient(imageManager)
exporter := export.NewOCIExporter(ociClient)
_, err := exporter.PullRunConfig(ctx, serverOrImage)
return err == nil
}

// loadAndMergeOCIRunConfig loads a runtime configuration from OCI and merges with command-line flags
func loadAndMergeOCIRunConfig(ctx context.Context, ref string, flags *RunFlags, debugMode bool) (*runner.RunConfig, error) {
// Load the runtime configuration from OCI
imageManager := images.NewImageManager(ctx)
ociClient := oci.NewClient(imageManager)
exporter := export.NewOCIExporter(ociClient)
ociConfig, err := exporter.PullRunConfig(ctx, ref)
if err != nil {
return nil, fmt.Errorf("failed to load runtime configuration from OCI artifact: %w", err)
}

// Create container runtime
rt, err := container.NewFactory().Create(ctx)
if err != nil {
return nil, fmt.Errorf("failed to create container runtime: %v", err)
}

// Set the runtime in the config
ociConfig.Deployer = rt

// Override with any command-line flags that were explicitly set
// This allows users to override specific settings from the OCI config
if flags.Name != "" {
ociConfig.Name = flags.Name
}
if flags.Host != "" {
ociConfig.Host = flags.Host
}
if flags.ProxyPort != 0 {
ociConfig.Port = flags.ProxyPort
}
if debugMode {
ociConfig.Debug = true
}

logger.Infof("Successfully loaded runtime configuration from OCI artifact: %s", ref)
logger.Infof("Using image: %s", ociConfig.Image)

return ociConfig, nil
}
Loading