diff --git a/cmd/docker-mcp/commands/catalog_next.go b/cmd/docker-mcp/commands/catalog_next.go index 23162a6f..42ee735d 100644 --- a/cmd/docker-mcp/commands/catalog_next.go +++ b/cmd/docker-mcp/commands/catalog_next.go @@ -9,14 +9,25 @@ import ( catalognext "github.com/docker/mcp-gateway/pkg/catalog_next" "github.com/docker/mcp-gateway/pkg/db" + "github.com/docker/mcp-gateway/pkg/docker" + "github.com/docker/mcp-gateway/pkg/migrate" "github.com/docker/mcp-gateway/pkg/oci" "github.com/docker/mcp-gateway/pkg/workingset" ) -func catalogNextCommand() *cobra.Command { +func catalogNextCommand(docker docker.Client) *cobra.Command { cmd := &cobra.Command{ Use: "catalog-next", Short: "Manage catalogs (next generation)", + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + dao, err := db.New() + if err != nil { + return err + } + defer dao.Close() + migrate.MigrateConfig(cmd.Context(), docker, dao) + return nil + }, } cmd.AddCommand(createCatalogNextCommand()) diff --git a/cmd/docker-mcp/commands/gateway.go b/cmd/docker-mcp/commands/gateway.go index a7be957a..04e1ce75 100644 --- a/cmd/docker-mcp/commands/gateway.go +++ b/cmd/docker-mcp/commands/gateway.go @@ -123,6 +123,8 @@ func gatewayCommand(docker docker.Client, dockerCli command.Cli) *cobra.Command options.MCPRegistryServers = mcpServers } + // TODO(cody): When all commands are migrated, we should default this parameter to "default" + // Also need to consider the case when there is no default profile if options.WorkingSet != "" { if len(options.ServerNames) > 0 { return fmt.Errorf("cannot use --profile with --servers flag") diff --git a/cmd/docker-mcp/commands/root.go b/cmd/docker-mcp/commands/root.go index 032c59d6..24bf6eaf 100644 --- a/cmd/docker-mcp/commands/root.go +++ b/cmd/docker-mcp/commands/root.go @@ -71,8 +71,8 @@ func Root(ctx context.Context, cwd string, dockerCli command.Cli) *cobra.Command dockerClient := docker.NewClient(dockerCli) if isWorkingSetsFeatureEnabled(dockerCli) { - cmd.AddCommand(workingSetCommand()) - cmd.AddCommand(catalogNextCommand()) + cmd.AddCommand(workingSetCommand(dockerClient)) + cmd.AddCommand(catalogNextCommand(dockerClient)) } cmd.AddCommand(catalogCommand(dockerCli)) cmd.AddCommand(clientCommand(dockerCli, cwd)) diff --git a/cmd/docker-mcp/commands/workingset.go b/cmd/docker-mcp/commands/workingset.go index f30576cf..d1482014 100644 --- a/cmd/docker-mcp/commands/workingset.go +++ b/cmd/docker-mcp/commands/workingset.go @@ -9,18 +9,29 @@ import ( "github.com/docker/mcp-gateway/pkg/client" "github.com/docker/mcp-gateway/pkg/db" + "github.com/docker/mcp-gateway/pkg/docker" + "github.com/docker/mcp-gateway/pkg/migrate" "github.com/docker/mcp-gateway/pkg/oci" "github.com/docker/mcp-gateway/pkg/registryapi" "github.com/docker/mcp-gateway/pkg/sliceutil" "github.com/docker/mcp-gateway/pkg/workingset" ) -func workingSetCommand() *cobra.Command { +func workingSetCommand(docker docker.Client) *cobra.Command { cfg := client.ReadConfig() cmd := &cobra.Command{ Use: "profile", Short: "Manage profiles", + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + dao, err := db.New() + if err != nil { + return err + } + defer dao.Close() + migrate.MigrateConfig(cmd.Context(), docker, dao) + return nil + }, } cmd.AddCommand(exportWorkingSetCommand()) diff --git a/pkg/db/db.go b/pkg/db/db.go index ff991428..eeec0f4e 100644 --- a/pkg/db/db.go +++ b/pkg/db/db.go @@ -23,6 +23,10 @@ import ( type DAO interface { WorkingSetDAO CatalogDAO + MigrationStatusDAO + + // Normally unnecessary to call this + Close() error } type dao struct { @@ -97,6 +101,10 @@ func New(opts ...Option) (DAO, error) { return &dao{db: sqlxDb}, nil } +func (d *dao) Close() error { + return d.db.Close() +} + func DefaultDatabaseFilename() (string, error) { homeDir, err := user.HomeDir() if err != nil { diff --git a/pkg/db/migration_status.go b/pkg/db/migration_status.go new file mode 100644 index 00000000..5928f97b --- /dev/null +++ b/pkg/db/migration_status.go @@ -0,0 +1,56 @@ +package db + +import ( + "context" + "time" +) + +type MigrationStatusDAO interface { + GetMigrationStatus(ctx context.Context) (*MigrationStatus, error) + UpdateMigrationStatus(ctx context.Context, status MigrationStatus) error +} + +type MigrationStatus struct { + ID *int64 `db:"id"` + Status string `db:"status"` + Logs string `db:"logs"` + LastUpdated *time.Time `db:"last_updated"` +} + +func (d *dao) GetMigrationStatus(ctx context.Context) (*MigrationStatus, error) { + const query = `SELECT id, status, logs, last_updated FROM migration_status LIMIT 1` + + var migrationStatus MigrationStatus + err := d.db.GetContext(ctx, &migrationStatus, query) + if err != nil { + return nil, err + } + return &migrationStatus, nil +} + +func (d *dao) UpdateMigrationStatus(ctx context.Context, status MigrationStatus) error { + tx, err := d.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer txClose(tx, &err) + + const deleteQuery = `DELETE FROM migration_status` + _, err = tx.ExecContext(ctx, deleteQuery) + if err != nil { + return err + } + + const query = `INSERT INTO migration_status (status, logs) VALUES ($1, $2)` + + _, err = tx.ExecContext(ctx, query, status.Status, status.Logs) + if err != nil { + return err + } + + if err = tx.Commit(); err != nil { + return err + } + + return nil +} diff --git a/pkg/db/migrations/004_create_migration_status.up.sql b/pkg/db/migrations/004_create_migration_status.up.sql new file mode 100644 index 00000000..93d0b7dd --- /dev/null +++ b/pkg/db/migrations/004_create_migration_status.up.sql @@ -0,0 +1,6 @@ +create table migration_status ( + id integer primary key, + status text not null, + logs text, + last_updated DATETIME DEFAULT CURRENT_TIMESTAMP +); diff --git a/pkg/gateway/configuration_workingset.go b/pkg/gateway/configuration_workingset.go index fdc9baf9..328458d5 100644 --- a/pkg/gateway/configuration_workingset.go +++ b/pkg/gateway/configuration_workingset.go @@ -12,6 +12,7 @@ import ( "github.com/docker/mcp-gateway/pkg/db" "github.com/docker/mcp-gateway/pkg/docker" "github.com/docker/mcp-gateway/pkg/log" + "github.com/docker/mcp-gateway/pkg/migrate" "github.com/docker/mcp-gateway/pkg/oci" "github.com/docker/mcp-gateway/pkg/workingset" ) @@ -31,7 +32,15 @@ func NewWorkingSetConfiguration(workingSet string, ociService oci.Service, docke } func (c *WorkingSetConfiguration) Read(ctx context.Context) (Configuration, chan Configuration, func() error, error) { - configuration, err := c.readOnce(ctx) + dao, err := db.New() + if err != nil { + return Configuration{}, nil, nil, fmt.Errorf("failed to create database client: %w", err) + } + + // Do migration from legacy files + migrate.MigrateConfig(ctx, c.docker, dao) + + configuration, err := c.readOnce(ctx, dao) if err != nil { return Configuration{}, nil, nil, err } @@ -42,15 +51,10 @@ func (c *WorkingSetConfiguration) Read(ctx context.Context) (Configuration, chan return configuration, updates, func() error { return nil }, nil } -func (c *WorkingSetConfiguration) readOnce(ctx context.Context) (Configuration, error) { +func (c *WorkingSetConfiguration) readOnce(ctx context.Context, dao db.DAO) (Configuration, error) { start := time.Now() log.Log("- Reading profile configuration...") - dao, err := db.New() - if err != nil { - return Configuration{}, fmt.Errorf("failed to create database client: %w", err) - } - dbWorkingSet, err := dao.GetWorkingSet(ctx, c.WorkingSet) if err != nil { if errors.Is(err, sql.ErrNoRows) { diff --git a/pkg/migrate/migrate.go b/pkg/migrate/migrate.go new file mode 100644 index 00000000..d4158b52 --- /dev/null +++ b/pkg/migrate/migrate.go @@ -0,0 +1,252 @@ +package migrate + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + legacycatalog "github.com/docker/mcp-gateway/pkg/catalog" + "github.com/docker/mcp-gateway/pkg/config" + "github.com/docker/mcp-gateway/pkg/db" + "github.com/docker/mcp-gateway/pkg/docker" + "github.com/docker/mcp-gateway/pkg/workingset" +) + +const ( + MigrationStatusSuccess = "success" + MigrationStatusFailed = "failed" +) + +//revive:disable +func MigrateConfig(ctx context.Context, docker docker.Client, dao db.DAO) { + _, err := dao.GetMigrationStatus(ctx) + if err == nil { + // Migration already run, skip + return + } + + if err != nil && !errors.Is(err, sql.ErrNoRows) { + fmt.Fprintf(os.Stderr, "failed to get migration status: %s", err.Error()) + return + } + + // err == sql.ErrNoRows so we need to perform the migration + + status := MigrationStatusFailed + logs := []string{} + + defer func() { + err = dao.UpdateMigrationStatus(ctx, db.MigrationStatus{ + Status: status, + Logs: strings.Join(logs, "\n"), + }) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to update migration status: %s", err.Error()) + } + }() + + // Anything beyond this point should log failures to `logs` + + registry, cfg, tools, oldCatalog, err := readLegacyDefaults(ctx, docker) + if err != nil { + logs = append(logs, fmt.Sprintf("failed to read legacy defaults: %s", err.Error())) + // Failed migration + return + } + + // Only create a default profile if there are existing installed servers + if len(registry.ServerNames()) > 0 { + createLogs, err := createDefaultProfile(ctx, dao, registry, cfg, tools, oldCatalog) + if err != nil { + logs = append(logs, fmt.Sprintf("failed to create default profile: %s", err.Error())) + // Failed migration + return + } + logs = append(logs, createLogs...) + logs = append(logs, fmt.Sprintf("default profile created with %d servers", len(registry.ServerNames()))) + } else { + logs = append(logs, "no existing installed servers found, skipping default profile creation") + } + + // Migration considered successful by this point + status = MigrationStatusSuccess + + err = backupLegacyFiles() + if err != nil { + logs = append(logs, fmt.Sprintf("failed to backup legacy files: %s", err.Error())) + return + } +} + +func createDefaultProfile(ctx context.Context, dao db.DAO, registry *config.Registry, cfg map[string]map[string]any, tools *config.ToolsConfig, oldCatalog *legacycatalog.Catalog) ([]string, error) { + logs := []string{} + + // Add default secrets + secrets := make(map[string]workingset.Secret) + secrets["default"] = workingset.Secret{ + Provider: workingset.SecretProviderDockerDesktop, + } + + profile := workingset.WorkingSet{ + ID: "default", + Name: "Default Profile", + Version: workingset.CurrentWorkingSetVersion, + Servers: make([]workingset.Server, 0), + Secrets: secrets, + } + + for _, server := range registry.ServerNames() { + oldServer, ok := oldCatalog.Servers[server] + if !ok { + logs = append(logs, fmt.Sprintf("server %s not found in old catalog, skipping", server)) + continue // Ignore + } + oldServer.Name = server // Name is set after loading + + profileServer := workingset.Server{ + Config: cfg[server], + Tools: tools.ServerTools[server], + Secrets: "default", + } + if oldServer.Type == "server" { + profileServer.Type = workingset.ServerTypeImage + profileServer.Image = oldServer.Image + } else { + // TODO(cody): Support remotes + logs = append(logs, fmt.Sprintf("server %s has an invalid server type: %s, skipping", server, oldServer.Type)) + continue // Ignore + } + profileServer.Snapshot = &workingset.ServerSnapshot{ + Server: oldServer, + } + profile.Servers = append(profile.Servers, profileServer) + logs = append(logs, fmt.Sprintf("added server %s to profile", server)) + } + + if err := profile.Validate(); err != nil { + return logs, fmt.Errorf("invalid profile: %w", err) + } + + err := dao.CreateWorkingSet(ctx, profile.ToDb()) + if err != nil { + return logs, fmt.Errorf("failed to create profile: %w", err) + } + + return logs, nil +} + +func readLegacyDefaults(ctx context.Context, docker docker.Client) (*config.Registry, map[string]map[string]any, *config.ToolsConfig, *legacycatalog.Catalog, error) { + registryPath, err := config.FilePath("registry.yaml") + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to get registry path: %w", err) + } + configPath, err := config.FilePath("config.yaml") + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to get config path: %w", err) + } + toolsPath, err := config.FilePath("tools.yaml") + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to get tools path: %w", err) + } + + registryYaml, err := config.ReadConfigFile(ctx, docker, registryPath) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to read registry file: %w", err) + } + registry, err := config.ParseRegistryConfig(registryYaml) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to parse registry file: %w", err) + } + + configYaml, err := config.ReadConfigFile(ctx, docker, configPath) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to read config file: %w", err) + } + cfg, err := config.ParseConfig(configYaml) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to parse config file: %w", err) + } + + toolsYaml, err := config.ReadConfigFile(ctx, docker, toolsPath) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to read tools file: %w", err) + } + tools, err := config.ParseToolsConfig(toolsYaml) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to parse tools file: %w", err) + } + + mcpCatalog, err := legacycatalog.ReadFrom(ctx, []string{legacycatalog.DockerCatalogFilename}) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("reading catalog: %w", err) + } + + return ®istry, cfg, &tools, &mcpCatalog, nil +} + +func backupLegacyFiles() error { + // Create backup directory + backupDir, err := config.FilePath(".backup") + if err != nil { + return fmt.Errorf("failed to get backup directory path: %w", err) + } + + err = os.MkdirAll(backupDir, 0o755) + if err != nil { + return fmt.Errorf("failed to create backup directory: %w", err) + } + + // Get paths to legacy files + registryPath, err := config.FilePath("registry.yaml") + if err != nil { + return fmt.Errorf("failed to get registry path: %w", err) + } + configPath, err := config.FilePath("config.yaml") + if err != nil { + return fmt.Errorf("failed to get config path: %w", err) + } + toolsPath, err := config.FilePath("tools.yaml") + if err != nil { + return fmt.Errorf("failed to get tools path: %w", err) + } + + catalogIndexPath, err := config.FilePath("catalog.json") + if err != nil { + return fmt.Errorf("failed to get catalog index path: %w", err) + } + + catalogsDir, err := config.FilePath("catalogs") + if err != nil { + return fmt.Errorf("failed to get old catalog path: %w", err) + } + + oldCatalogPath := filepath.Join(catalogsDir, legacycatalog.DockerCatalogFilename) + + // Move files to backup directory + _ = moveFile(registryPath, filepath.Join(backupDir, "registry.yaml")) + _ = moveFile(configPath, filepath.Join(backupDir, "config.yaml")) + _ = moveFile(toolsPath, filepath.Join(backupDir, "tools.yaml")) + _ = moveFile(catalogIndexPath, filepath.Join(backupDir, "catalog.json")) + _ = moveFile(oldCatalogPath, filepath.Join(backupDir, legacycatalog.DockerCatalogFilename)) + + // We use os.Remove to remove the directory, so it's only removed if empty + // We don't want to remove any custom catalog yamls the user may have added + _ = os.Remove(catalogsDir) + + return nil +} + +// moveFile moves a file from src to dst. If src doesn't exist, it's a no-op. +func moveFile(src, dst string) error { + // Check if source file exists + if _, err := os.Stat(src); os.IsNotExist(err) { + return nil // File doesn't exist, nothing to move + } + + // Move the file + return os.Rename(src, dst) +} diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go new file mode 100644 index 00000000..2d683fd9 --- /dev/null +++ b/pkg/migrate/migrate_test.go @@ -0,0 +1,668 @@ +package migrate + +import ( + "context" + "database/sql" + "io" + "os" + "path/filepath" + "testing" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/volume" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + legacycatalog "github.com/docker/mcp-gateway/pkg/catalog" + "github.com/docker/mcp-gateway/pkg/db" + "github.com/docker/mcp-gateway/pkg/docker" +) + +// setupTestEnvironment creates a temporary directory structure with legacy config files +func setupTestEnvironment(t *testing.T) string { + t.Helper() + + // Save original HOME env var + originalHome := os.Getenv("HOME") + + // Create temp directory + tempDir := t.TempDir() + + // Override HOME to point to temp directory + os.Setenv("HOME", tempDir) + + // Create .docker/mcp directory structure + mcpDir := filepath.Join(tempDir, ".docker", "mcp") + catalogsDir := filepath.Join(mcpDir, "catalogs") + err := os.MkdirAll(catalogsDir, 0o755) + require.NoError(t, err) + + t.Cleanup(func() { + os.Setenv("HOME", originalHome) + }) + + return mcpDir +} + +// setupTestDB creates a test database +func setupTestDB(t *testing.T) db.DAO { + t.Helper() + + tempDir := t.TempDir() + dbFile := filepath.Join(tempDir, "test.db") + + dao, err := db.New(db.WithDatabaseFile(dbFile)) + require.NoError(t, err) + + return dao +} + +func TestMigrateConfig_SkipsIfAlreadyRun(t *testing.T) { + mcpDir := setupTestEnvironment(t) + + dao := setupTestDB(t) + ctx := t.Context() + + // Create legacy files (these should not be read) + writeTestLegacyFiles(t, mcpDir, "server1") + + // Mark migration as already completed + err := dao.UpdateMigrationStatus(ctx, db.MigrationStatus{ + Status: MigrationStatusSuccess, + Logs: "Previously migrated", + }) + require.NoError(t, err) + + // Run migration - should skip + var mockDocker docker.Client = &mockDockerClient{} + MigrateConfig(ctx, mockDocker, dao) + + // Verify no working sets were created + workingSets, err := dao.ListWorkingSets(ctx) + require.NoError(t, err) + assert.Empty(t, workingSets, "No working sets should be created when migration already ran") + + // Verify status unchanged + status, err := dao.GetMigrationStatus(ctx) + require.NoError(t, err) + assert.Equal(t, MigrationStatusSuccess, status.Status) + assert.Equal(t, "Previously migrated", status.Logs) +} + +func TestMigrateConfig_SuccessWithServers(t *testing.T) { + mcpDir := setupTestEnvironment(t) + + dao := setupTestDB(t) + ctx := t.Context() + + // Create legacy files with servers + writeTestLegacyFiles(t, mcpDir, "server1", "server2") + + mockDocker := &mockDockerClient{} + MigrateConfig(ctx, mockDocker, dao) + + // Verify migration status is success + status, err := dao.GetMigrationStatus(ctx) + require.NoError(t, err) + assert.Equal(t, MigrationStatusSuccess, status.Status) + assert.Contains(t, status.Logs, "default profile created with 2 servers") + + // Verify working set was created + workingSets, err := dao.ListWorkingSets(ctx) + require.NoError(t, err) + assert.Len(t, workingSets, 1) + assert.Equal(t, "default", workingSets[0].ID) + assert.Equal(t, "Default Profile", workingSets[0].Name) + + // Verify servers in working set + assert.Len(t, workingSets[0].Servers, 2) + + // Verify secrets + assert.Len(t, workingSets[0].Secrets, 1) + assert.Equal(t, "docker-desktop-store", workingSets[0].Secrets["default"].Provider) + + // Verify legacy files were backed up + assertLegacyFilesBackedUp(t, mcpDir) +} + +func TestMigrateConfig_SuccessWithSingleServer(t *testing.T) { + mcpDir := setupTestEnvironment(t) + + dao := setupTestDB(t) + ctx := t.Context() + + // Create legacy files with one server + writeTestLegacyFiles(t, mcpDir, "postgres-server") + + mockDocker := &mockDockerClient{} + MigrateConfig(ctx, mockDocker, dao) + + // Verify migration status is success + status, err := dao.GetMigrationStatus(ctx) + require.NoError(t, err) + assert.Equal(t, MigrationStatusSuccess, status.Status) + assert.Contains(t, status.Logs, "default profile created with 1 servers") + + // Verify working set was created + workingSets, err := dao.ListWorkingSets(ctx) + require.NoError(t, err) + assert.Len(t, workingSets, 1) + assert.Len(t, workingSets[0].Servers, 1) + + // Verify server details + server := workingSets[0].Servers[0] + assert.Equal(t, "image", server.Type) + assert.Nil(t, server.Tools) + assert.Nil(t, server.Config) + assert.Equal(t, "test/postgres-server:latest", server.Image) + assert.Equal(t, "default", server.Secrets) + assert.NotNil(t, server.Snapshot) + assert.Equal(t, "postgres-server", server.Snapshot.Server.Name) +} + +func TestMigrateConfig_SkipsWithNoServers(t *testing.T) { + mcpDir := setupTestEnvironment(t) + + dao := setupTestDB(t) + ctx := t.Context() + + // Create legacy files with NO servers + writeTestLegacyFiles(t, mcpDir) + + mockDocker := &mockDockerClient{} + MigrateConfig(ctx, mockDocker, dao) + + // Verify migration status is success but no profile created + status, err := dao.GetMigrationStatus(ctx) + require.NoError(t, err) + assert.Equal(t, MigrationStatusSuccess, status.Status) + assert.Contains(t, status.Logs, "no existing installed servers found, skipping default profile creation") + + // Verify NO working set was created + workingSets, err := dao.ListWorkingSets(ctx) + require.NoError(t, err) + assert.Empty(t, workingSets) + + // Verify legacy files were still backed up + assertLegacyFilesBackedUp(t, mcpDir) +} + +func TestMigrateConfig_FailureReadingLegacyFiles(t *testing.T) { + mcpDir := setupTestEnvironment(t) + + dao := setupTestDB(t) + ctx := t.Context() + + // Create malformed registry.yaml + registryPath := filepath.Join(mcpDir, "registry.yaml") + err := os.WriteFile(registryPath, []byte("invalid: yaml: [[["), 0o644) + require.NoError(t, err) + + mockDocker := &mockDockerClient{} + MigrateConfig(ctx, mockDocker, dao) + + // Verify migration status is failed + status, err := dao.GetMigrationStatus(ctx) + require.NoError(t, err) + assert.Equal(t, MigrationStatusFailed, status.Status) + assert.Contains(t, status.Logs, "failed to read legacy defaults") + + // Verify NO working set was created + workingSets, err := dao.ListWorkingSets(ctx) + require.NoError(t, err) + assert.Empty(t, workingSets) +} + +func TestMigrateConfig_FailureMissingRegistryFile(t *testing.T) { + mcpDir := setupTestEnvironment(t) + + dao := setupTestDB(t) + ctx := t.Context() + + // Create config.yaml and tools.yaml but NOT registry.yaml + configPath := filepath.Join(mcpDir, "config.yaml") + err := os.WriteFile(configPath, []byte("{}"), 0o644) + require.NoError(t, err) + + toolsPath := filepath.Join(mcpDir, "tools.yaml") + err = os.WriteFile(toolsPath, []byte("{}"), 0o644) + require.NoError(t, err) + + // Don't create registry.yaml or catalog + + mockDocker := &mockDockerClient{} + MigrateConfig(ctx, mockDocker, dao) + + // Verify migration status is failed + status, err := dao.GetMigrationStatus(ctx) + require.NoError(t, err) + assert.Equal(t, MigrationStatusFailed, status.Status) + assert.Contains(t, status.Logs, "failed to read legacy defaults") +} + +func TestMigrateConfig_WithServerConfigAndTools(t *testing.T) { + mcpDir := setupTestEnvironment(t) + + dao := setupTestDB(t) + ctx := t.Context() + + // Create legacy files with servers that have config and tools + serverNames := []string{"server1"} + + // Registry + registryYaml := `registry: + server1: + ref: ""` + err := os.WriteFile(filepath.Join(mcpDir, "registry.yaml"), []byte(registryYaml), 0o644) + require.NoError(t, err) + + // Config + configYaml := `server1: + timeout: 30 + retries: 3` + err = os.WriteFile(filepath.Join(mcpDir, "config.yaml"), []byte(configYaml), 0o644) + require.NoError(t, err) + + // Tools + toolsYaml := `server1: + - tool1 + - tool2 + - tool3` + err = os.WriteFile(filepath.Join(mcpDir, "tools.yaml"), []byte(toolsYaml), 0o644) + require.NoError(t, err) + + // Catalog + writeCatalogFile(t, mcpDir, serverNames) + + mockDocker := &mockDockerClient{} + MigrateConfig(ctx, mockDocker, dao) + + // Verify migration status is success + status, err := dao.GetMigrationStatus(ctx) + require.NoError(t, err) + assert.Equal(t, MigrationStatusSuccess, status.Status) + + // Verify working set + workingSets, err := dao.ListWorkingSets(ctx) + require.NoError(t, err) + require.Len(t, workingSets, 1) + require.Len(t, workingSets[0].Servers, 1) + + server := workingSets[0].Servers[0] + + // Verify config + assert.NotNil(t, server.Config) + assert.InEpsilon(t, float64(30), server.Config["timeout"], 0.0000001) + assert.InEpsilon(t, float64(3), server.Config["retries"], 0.0000001) + + // Verify tools + assert.Equal(t, []string{"tool1", "tool2", "tool3"}, server.Tools) +} + +func TestMigrateConfig_SkipsNonServerTypes(t *testing.T) { + mcpDir := setupTestEnvironment(t) + + dao := setupTestDB(t) + ctx := t.Context() + + // Create registry with mixed server types + registryYaml := `registry: + good-server: + ref: "" + bad-server: + ref: ""` + err := os.WriteFile(filepath.Join(mcpDir, "registry.yaml"), []byte(registryYaml), 0o644) + require.NoError(t, err) + + // Config + err = os.WriteFile(filepath.Join(mcpDir, "config.yaml"), []byte("{}"), 0o644) + require.NoError(t, err) + + // Tools + err = os.WriteFile(filepath.Join(mcpDir, "tools.yaml"), []byte("{}"), 0o644) + require.NoError(t, err) + + // Catalog with one valid and one invalid server type + catalogYaml := `registry: + good-server: + type: server + image: test/good:latest + title: Good Server + description: A good server + bad-server: + type: bad + image: test/bad:latest + title: Bad Server + description: A bad server (not supported)` + catalogsDir := filepath.Join(mcpDir, "catalogs") + err = os.MkdirAll(catalogsDir, 0o755) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(catalogsDir, legacycatalog.DockerCatalogFilename), []byte(catalogYaml), 0o644) + require.NoError(t, err) + + mockDocker := &mockDockerClient{} + MigrateConfig(ctx, mockDocker, dao) + + // Verify migration status is success + status, err := dao.GetMigrationStatus(ctx) + require.NoError(t, err) + assert.Equal(t, MigrationStatusSuccess, status.Status) + assert.Contains(t, status.Logs, "server bad-server has an invalid server type: bad, skipping") + + // Verify only good-server was added + workingSets, err := dao.ListWorkingSets(ctx) + require.NoError(t, err) + require.Len(t, workingSets, 1) + assert.Len(t, workingSets[0].Servers, 1) + assert.Equal(t, "test/good:latest", workingSets[0].Servers[0].Image) +} + +func TestMigrateConfig_ServerNotInCatalog(t *testing.T) { + mcpDir := setupTestEnvironment(t) + + dao := setupTestDB(t) + ctx := t.Context() + + // Create registry with server + registryYaml := `registry: + missing-server: + ref: ""` + err := os.WriteFile(filepath.Join(mcpDir, "registry.yaml"), []byte(registryYaml), 0o644) + require.NoError(t, err) + + // Config + err = os.WriteFile(filepath.Join(mcpDir, "config.yaml"), []byte("{}"), 0o644) + require.NoError(t, err) + + // Tools + err = os.WriteFile(filepath.Join(mcpDir, "tools.yaml"), []byte("{}"), 0o644) + require.NoError(t, err) + + // Empty catalog (server not in it) + catalogYaml := `registry: {}` + catalogsDir := filepath.Join(mcpDir, "catalogs") + err = os.MkdirAll(catalogsDir, 0o755) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(catalogsDir, legacycatalog.DockerCatalogFilename), []byte(catalogYaml), 0o644) + require.NoError(t, err) + + mockDocker := &mockDockerClient{} + MigrateConfig(ctx, mockDocker, dao) + + // Verify migration status is success + status, err := dao.GetMigrationStatus(ctx) + require.NoError(t, err) + assert.Equal(t, MigrationStatusSuccess, status.Status) + assert.Contains(t, status.Logs, "missing-server not found in old catalog, skipping") + + // Verify no servers were added, but profile might still be created if there are other servers + // In this case, with only one server that's missing, no profile should be created + workingSets, err := dao.ListWorkingSets(ctx) + require.NoError(t, err) + assert.Len(t, workingSets, 1) + assert.Empty(t, workingSets[0].Servers) +} + +func TestMigrateConfig_MultipleServersWithPartialFailure(t *testing.T) { + mcpDir := setupTestEnvironment(t) + + dao := setupTestDB(t) + ctx := t.Context() + + // Create registry with multiple servers + registryYaml := `registry: + good-server1: + ref: "" + missing-server: + ref: "" + good-server2: + ref: ""` + err := os.WriteFile(filepath.Join(mcpDir, "registry.yaml"), []byte(registryYaml), 0o644) + require.NoError(t, err) + + // Config + err = os.WriteFile(filepath.Join(mcpDir, "config.yaml"), []byte("{}"), 0o644) + require.NoError(t, err) + + // Tools + err = os.WriteFile(filepath.Join(mcpDir, "tools.yaml"), []byte("{}"), 0o644) + require.NoError(t, err) + + // Catalog with only 2 of 3 servers + catalogYaml := `registry: + good-server1: + type: server + image: test/good1:latest + title: Good Server 1 + good-server2: + type: server + image: test/good2:latest + title: Good Server 2` + catalogsDir := filepath.Join(mcpDir, "catalogs") + err = os.MkdirAll(catalogsDir, 0o755) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(catalogsDir, legacycatalog.DockerCatalogFilename), []byte(catalogYaml), 0o644) + require.NoError(t, err) + + mockDocker := &mockDockerClient{} + MigrateConfig(ctx, mockDocker, dao) + + // Verify migration status is success (partial success) + status, err := dao.GetMigrationStatus(ctx) + require.NoError(t, err) + assert.Equal(t, MigrationStatusSuccess, status.Status) + assert.Contains(t, status.Logs, "missing-server not found in old catalog, skipping") + assert.Contains(t, status.Logs, "added server good-server1 to profile") + assert.Contains(t, status.Logs, "added server good-server2 to profile") + + // Verify two servers were added + workingSets, err := dao.ListWorkingSets(ctx) + require.NoError(t, err) + require.Len(t, workingSets, 1) + assert.Len(t, workingSets[0].Servers, 2) +} + +func TestMigrateConfig_LegacyFilesBackedUpOnSuccess(t *testing.T) { + mcpDir := setupTestEnvironment(t) + + dao := setupTestDB(t) + ctx := t.Context() + + // Create legacy files + writeTestLegacyFiles(t, mcpDir, "server1") + + // Also create catalog.json + catalogIndexPath := filepath.Join(mcpDir, "catalog.json") + err := os.WriteFile(catalogIndexPath, []byte(`{"catalogs":{}}`), 0o644) + require.NoError(t, err) + + mockDocker := &mockDockerClient{} + MigrateConfig(ctx, mockDocker, dao) + + // Verify migration status is success + status, err := dao.GetMigrationStatus(ctx) + require.NoError(t, err) + assert.Equal(t, MigrationStatusSuccess, status.Status) + + // Verify all legacy files were backed up + assertLegacyFilesBackedUp(t, mcpDir) + + // Verify catalog.json was also backed up + backupCatalogIndexPath := filepath.Join(mcpDir, ".backup", "catalog.json") + _, err = os.Stat(backupCatalogIndexPath) + assert.False(t, os.IsNotExist(err), "catalog.json should be backed up") + + // Verify original catalog.json no longer exists + _, err = os.Stat(catalogIndexPath) + assert.True(t, os.IsNotExist(err), "original catalog.json should be removed") +} + +// Helper functions + +func writeTestLegacyFiles(t *testing.T, mcpDir string, serverNames ...string) { + t.Helper() + + // Build registry YAML + registryYaml := "registry:\n" + for _, serverName := range serverNames { + registryYaml += " " + serverName + ":\n" + registryYaml += " ref: \"\"\n" + } + + err := os.WriteFile(filepath.Join(mcpDir, "registry.yaml"), []byte(registryYaml), 0o644) + require.NoError(t, err) + + // Create empty config.yaml + err = os.WriteFile(filepath.Join(mcpDir, "config.yaml"), []byte("{}"), 0o644) + require.NoError(t, err) + + // Create empty tools.yaml + err = os.WriteFile(filepath.Join(mcpDir, "tools.yaml"), []byte("{}"), 0o644) + require.NoError(t, err) + + // Create catalog file + writeCatalogFile(t, mcpDir, serverNames) +} + +func writeCatalogFile(t *testing.T, mcpDir string, serverNames []string) { + t.Helper() + + catalogYaml := "registry:\n" + for _, serverName := range serverNames { + catalogYaml += " " + serverName + ":\n" + catalogYaml += " type: server\n" + catalogYaml += " image: test/" + serverName + ":latest\n" + catalogYaml += " title: " + serverName + "\n" + catalogYaml += " description: Test server " + serverName + "\n" + } + + catalogsDir := filepath.Join(mcpDir, "catalogs") + err := os.MkdirAll(catalogsDir, 0o755) + require.NoError(t, err) + + err = os.WriteFile(filepath.Join(catalogsDir, legacycatalog.DockerCatalogFilename), []byte(catalogYaml), 0o644) + require.NoError(t, err) +} + +func assertLegacyFilesBackedUp(t *testing.T, mcpDir string) { + t.Helper() + + backupDir := filepath.Join(mcpDir, ".backup") + + // Verify backup directory exists + _, err := os.Stat(backupDir) + assert.False(t, os.IsNotExist(err), "backup directory should exist") + + // Verify original files no longer exist in original location + registryPath := filepath.Join(mcpDir, "registry.yaml") + _, err = os.Stat(registryPath) + assert.True(t, os.IsNotExist(err), "original registry.yaml should be removed") + + configPath := filepath.Join(mcpDir, "config.yaml") + _, err = os.Stat(configPath) + assert.True(t, os.IsNotExist(err), "original config.yaml should be removed") + + toolsPath := filepath.Join(mcpDir, "tools.yaml") + _, err = os.Stat(toolsPath) + assert.True(t, os.IsNotExist(err), "original tools.yaml should be removed") + + catalogPath := filepath.Join(mcpDir, "catalogs", legacycatalog.DockerCatalogFilename) + _, err = os.Stat(catalogPath) + assert.True(t, os.IsNotExist(err), "original catalog file should be removed") + + // Verify files exist in backup directory + backupRegistryPath := filepath.Join(backupDir, "registry.yaml") + _, err = os.Stat(backupRegistryPath) + assert.False(t, os.IsNotExist(err), "registry.yaml should be backed up") + + backupConfigPath := filepath.Join(backupDir, "config.yaml") + _, err = os.Stat(backupConfigPath) + assert.False(t, os.IsNotExist(err), "config.yaml should be backed up") + + backupToolsPath := filepath.Join(backupDir, "tools.yaml") + _, err = os.Stat(backupToolsPath) + assert.False(t, os.IsNotExist(err), "tools.yaml should be backed up") + + backupCatalogPath := filepath.Join(backupDir, legacycatalog.DockerCatalogFilename) + _, err = os.Stat(backupCatalogPath) + assert.False(t, os.IsNotExist(err), "catalog file should be backed up") +} + +// mockDockerClient is a simple mock implementation of docker.Client for testing +type mockDockerClient struct { + inspectVolumeFunc func(ctx context.Context, name string) (volume.Volume, error) +} + +func (m *mockDockerClient) ContainerExists(_ context.Context, _ string) (bool, container.InspectResponse, error) { + return false, container.InspectResponse{}, nil +} + +func (m *mockDockerClient) RemoveContainer(_ context.Context, _ string, _ bool) error { + return nil +} + +func (m *mockDockerClient) StartContainer(_ context.Context, _ string, _ container.Config, _ container.HostConfig, _ network.NetworkingConfig) error { + return nil +} + +func (m *mockDockerClient) StopContainer(_ context.Context, _ string, _ int) error { + return nil +} + +func (m *mockDockerClient) FindContainerByLabel(_ context.Context, _ string) (string, error) { + return "", nil +} + +func (m *mockDockerClient) FindAllContainersByLabel(_ context.Context, _ string) ([]string, error) { + return nil, nil +} + +func (m *mockDockerClient) InspectContainer(_ context.Context, _ string) (container.InspectResponse, error) { + return container.InspectResponse{}, nil +} + +func (m *mockDockerClient) ReadLogs(_ context.Context, _ string, _ container.LogsOptions) (io.ReadCloser, error) { + return nil, nil //nolint:nilnil +} + +func (m *mockDockerClient) ImageExists(_ context.Context, _ string) (bool, error) { + return false, nil +} + +func (m *mockDockerClient) InspectImage(_ context.Context, _ string) (image.InspectResponse, error) { + return image.InspectResponse{}, nil +} + +func (m *mockDockerClient) PullImage(_ context.Context, _ string) error { + return nil +} + +func (m *mockDockerClient) PullImages(_ context.Context, _ ...string) error { + return nil +} + +func (m *mockDockerClient) CreateNetwork(_ context.Context, _ string, _ bool, _ map[string]string) error { + return nil +} + +func (m *mockDockerClient) RemoveNetwork(_ context.Context, _ string) error { + return nil +} + +func (m *mockDockerClient) ConnectNetwork(_ context.Context, _ string, _ string, _ string) error { + return nil +} + +func (m *mockDockerClient) InspectVolume(ctx context.Context, name string) (volume.Volume, error) { + if m.inspectVolumeFunc != nil { + return m.inspectVolumeFunc(ctx, name) + } + // Return error to simulate volume not found + return volume.Volume{}, sql.ErrNoRows +} + +func (m *mockDockerClient) ReadSecrets(_ context.Context, _ []string, _ bool) (map[string]string, error) { + return nil, nil //nolint:nilnil +}