diff --git a/README.md b/README.md index dbccf34..fc8d040 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,19 @@ # dev-postgres-mcp -A Model Context Protocol (MCP) server that provides tools for creating, managing, and accessing ephemeral PostgreSQL database instances running in Docker containers. +A Model Context Protocol (MCP) server that provides tools for creating, managing, and accessing ephemeral database instances running in Docker containers. Supports PostgreSQL, MySQL, and MariaDB. ## Features -- **Ephemeral PostgreSQL Instances**: Create temporary PostgreSQL databases in Docker containers +- **Multi-Database Support**: Create temporary PostgreSQL, MySQL, and MariaDB instances in Docker containers - **Dynamic Port Allocation**: Automatic port assignment to prevent conflicts (range: 15432-25432) -- **Multiple PostgreSQL Versions**: Support for PostgreSQL 15, 16, and 17 (default: 17) +- **Multiple Database Versions**: Support for various versions of each database type + - PostgreSQL: 15, 16, 17 (default: 17) + - MySQL: 5.7, 8.0 (default: 8.0) + - MariaDB: 10.6, 11 (default: 11) - **Superuser Access**: Auto-generated credentials with full database access - **MCP Integration**: Compatible with Augment Code and other MCP clients - **CLI Management**: Command-line tools for instance management outside of MCP -- **Health Monitoring**: Built-in health checks for PostgreSQL instances +- **Health Monitoring**: Built-in health checks for all database types - **Comprehensive Logging**: Structured logging with configurable levels and formats ## Quick Start @@ -53,17 +56,25 @@ dev-postgres-mcp mcp serve --log-level debug #### CLI Commands ```bash -# List all running PostgreSQL instances -dev-postgres-mcp postgres list +# List all running database instances +dev-postgres-mcp database list + +# List instances of a specific type +dev-postgres-mcp database list --type postgresql +dev-postgres-mcp database list --type mysql +dev-postgres-mcp database list --type mariadb # List instances in JSON format -dev-postgres-mcp postgres list --format json +dev-postgres-mcp database list --format json + +# Get details of a specific instance +dev-postgres-mcp database get # Drop a specific instance -dev-postgres-mcp postgres drop +dev-postgres-mcp database drop # Force drop without confirmation -dev-postgres-mcp postgres drop --force +dev-postgres-mcp database drop --force # Show version information dev-postgres-mcp version @@ -76,32 +87,37 @@ dev-postgres-mcp --help The server provides the following MCP tools: -### `create_postgres_instance` +#### `create_database_instance` -Creates a new ephemeral PostgreSQL instance. +Creates a new ephemeral database instance. **Parameters:** -- `version` (optional): PostgreSQL version (15, 16, 17) - default: 17 -- `database` (optional): Database name - default: postgres -- `username` (optional): PostgreSQL username - default: postgres -- `password` (optional): PostgreSQL password - auto-generated if not provided +- `type` (optional): Database type - postgresql, mysql, mariadb (default: postgresql) +- `version` (optional): Database version - defaults vary by type +- `database` (optional): Database name - defaults vary by type +- `username` (optional): Database username - defaults vary by type +- `password` (optional): Database password - auto-generated if not provided **Returns:** -- Instance ID +- Instance ID (without dashes) +- Database type - Connection DSN - Port number - Database details -### `list_postgres_instances` +#### `list_database_instances` -Lists all running PostgreSQL instances. +Lists all running database instances. + +**Parameters:** +- `type` (optional): Filter by database type - postgresql, mysql, mariadb **Returns:** -- Array of instance details including ID, port, database, version, status, and creation time +- Array of instance details including ID, type, port, database, version, status, and creation time -### `get_postgres_instance` +#### `get_database_instance` -Gets details of a specific PostgreSQL instance. +Gets details of a specific database instance. **Parameters:** - `instance_id` (required): The instance ID to retrieve @@ -109,19 +125,19 @@ Gets details of a specific PostgreSQL instance. **Returns:** - Complete instance details including connection information -### `drop_postgres_instance` +#### `drop_database_instance` -Removes a PostgreSQL instance and cleans up resources. +Removes a database instance and cleans up resources. **Parameters:** - `instance_id` (required): The instance ID to remove **Returns:** -- Confirmation of successful removal +- Confirmation of removal -### `health_check_postgres` +#### `health_check_database` -Performs a health check on a PostgreSQL instance. +Performs a health check on a database instance. **Parameters:** - `instance_id` (required): The instance ID to check diff --git a/cmd/dev-postgres-mcp/root.go b/cmd/dev-postgres-mcp/root.go index 82c6e84..ef9b051 100644 --- a/cmd/dev-postgres-mcp/root.go +++ b/cmd/dev-postgres-mcp/root.go @@ -13,9 +13,9 @@ import ( "github.com/spf13/cobra" "github.com/stokaro/dev-postgres-mcp/cmd/common/version" + "github.com/stokaro/dev-postgres-mcp/internal/database" "github.com/stokaro/dev-postgres-mcp/internal/docker" "github.com/stokaro/dev-postgres-mcp/internal/mcp" - "github.com/stokaro/dev-postgres-mcp/internal/postgres" "github.com/stokaro/dev-postgres-mcp/pkg/types" ) @@ -24,18 +24,19 @@ import ( func Execute(args ...string) { var rootCmd = &cobra.Command{ Use: "dev-postgres-mcp", - Short: "MCP server for managing ephemeral PostgreSQL instances", + Short: "MCP server for managing ephemeral database instances", Long: `dev-postgres-mcp is a Model Context Protocol (MCP) server that provides -tools for creating, managing, and accessing ephemeral PostgreSQL database instances +tools for creating, managing, and accessing ephemeral database instances running in Docker containers. -Each PostgreSQL instance is completely ephemeral and can be created and destroyed -on demand, making it perfect for development, testing, and experimentation. +Supports PostgreSQL, MySQL, and MariaDB databases. Each instance is completely +ephemeral and can be created and destroyed on demand, making it perfect for +development, testing, and experimentation. FEATURES: - • Create ephemeral PostgreSQL instances in Docker containers + • Create ephemeral PostgreSQL, MySQL, and MariaDB instances in Docker containers • Dynamic port allocation to prevent conflicts - • Support for multiple PostgreSQL versions (default: PostgreSQL 17) + • Support for multiple database versions • Superuser access with auto-generated credentials • MCP integration compatible with Augment Code and other MCP clients • CLI tools for instance management @@ -44,11 +45,17 @@ EXAMPLES: # Start the MCP server dev-postgres-mcp mcp serve - # List all running PostgreSQL instances - dev-postgres-mcp postgres list + # List all running database instances + dev-postgres-mcp database list + + # List only MySQL instances + dev-postgres-mcp database list --type mysql + + # Get details of a specific instance + dev-postgres-mcp database get # Drop a specific instance - dev-postgres-mcp postgres drop + dev-postgres-mcp database drop Use "dev-postgres-mcp [command] --help" for detailed information about each command.`, Args: cobra.NoArgs, // Disallow unknown subcommands @@ -57,11 +64,13 @@ Use "dev-postgres-mcp [command] --help" for detailed information about each comm }, } - rootCmd.SetArgs(args) + if len(args) > 0 { + rootCmd.SetArgs(args) + } // Add subcommands rootCmd.AddCommand(newMCPCommand()) - rootCmd.AddCommand(newPostgresCommand()) + rootCmd.AddCommand(newDatabaseCommand()) rootCmd.AddCommand(version.New()) err := rootCmd.Execute() @@ -75,7 +84,7 @@ func newMCPCommand() *cobra.Command { cmd := &cobra.Command{ Use: "mcp", Short: "MCP server commands", - Long: "Commands for managing the MCP (Model Context Protocol) server.", + Long: "Commands for running the Model Context Protocol server.", } cmd.AddCommand(newMCPServeCommand()) @@ -87,96 +96,176 @@ func newMCPCommand() *cobra.Command { func newMCPServeCommand() *cobra.Command { var startPort int var endPort int - var logLevel string cmd := &cobra.Command{ Use: "serve", Short: "Start the MCP server", - Long: `Start the MCP server using stdio transport. - -This command starts the MCP server that provides tools for managing PostgreSQL -instances. The server communicates using the Model Context Protocol over stdio, -making it compatible with MCP clients like Augment Code. - -The server provides the following tools: - • create_postgres_instance - Create a new PostgreSQL instance - • list_postgres_instances - List all running instances - • get_postgres_instance - Get details of a specific instance - • drop_postgres_instance - Remove a PostgreSQL instance - • health_check_postgres - Check instance health - -ENVIRONMENT VARIABLES: - DEV_POSTGRES_MCP_LOG_LEVEL Log level (debug, info, warn, error) - DEV_POSTGRES_MCP_LOG_FORMAT Log format (text, json)`, + Long: `Start the Model Context Protocol server for database instance management. + +This command starts the MCP server that provides tools for managing database +instances (PostgreSQL, MySQL, MariaDB). The server communicates using the Model +Context Protocol over stdio, making it compatible with MCP clients like Augment Code. + +The server provides the following unified tools: + • create_database_instance - Create a new database instance + • list_database_instances - List all running instances + • get_database_instance - Get details of a specific instance + • drop_database_instance - Remove a database instance + • health_check_database - Check instance health + +The server will run until interrupted (Ctrl+C) and will automatically clean up +all managed database instances on shutdown.`, RunE: func(_ *cobra.Command, _ []string) error { - return runMCPServe(startPort, endPort, logLevel) + return runMCPServe(startPort, endPort) }, } - cmd.Flags().IntVar(&startPort, "start-port", 15432, "Start of port range for PostgreSQL instances") - cmd.Flags().IntVar(&endPort, "end-port", 25432, "End of port range for PostgreSQL instances") - cmd.Flags().StringVar(&logLevel, "log-level", "", "Log level (debug, info, warn, error)") + cmd.Flags().IntVar(&startPort, "start-port", 15432, "Start of port range for database instances") + cmd.Flags().IntVar(&endPort, "end-port", 25432, "End of port range for database instances") return cmd } -// newPostgresCommand creates the postgres command group. -func newPostgresCommand() *cobra.Command { +// runMCPServe runs the MCP server. +func runMCPServe(startPort, endPort int) error { + // Create MCP server + config := mcp.ServerConfig{ + Name: "dev-postgres-mcp", + Version: "1.0.0", + StartPort: startPort, + EndPort: endPort, + LogLevel: "info", + } + server, err := mcp.NewServer(config) + if err != nil { + return fmt.Errorf("failed to create MCP server: %w", err) + } + defer server.Close() + + // Set up signal handling for graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + go func() { + <-sigChan + fmt.Fprintln(os.Stderr, "\nReceived interrupt signal, shutting down...") + cancel() + }() + + // Start the server + if err := server.Start(ctx); err != nil { + if ctx.Err() != nil { + // Context was cancelled, this is expected during shutdown + return nil + } + // Server error + return err + } + + return nil +} + +// newDatabaseCommand creates the database command group. +func newDatabaseCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "postgres", - Short: "PostgreSQL instance management commands", - Long: "Commands for managing PostgreSQL instances outside of the MCP protocol.", + Use: "database", + Short: "Database instance management commands", + Long: "Commands for managing database instances (PostgreSQL, MySQL, MariaDB) outside of the MCP protocol.", + Aliases: []string{"db"}, } - cmd.AddCommand(newPostgresListCommand()) - cmd.AddCommand(newPostgresDropCommand()) + cmd.AddCommand(newDatabaseListCommand()) + cmd.AddCommand(newDatabaseGetCommand()) + cmd.AddCommand(newDatabaseDropCommand()) return cmd } -// newPostgresListCommand creates the postgres list command. -func newPostgresListCommand() *cobra.Command { +// DropOptions holds options for dropping instances. +type DropOptions struct { + Force bool +} + +// Database command implementations + +// newDatabaseListCommand creates the database list command. +func newDatabaseListCommand() *cobra.Command { var format string var startPort int var endPort int + var dbType string cmd := &cobra.Command{ Use: "list", - Short: "List all running PostgreSQL instances", - Long: `List all currently running PostgreSQL instances managed by this server. + Short: "List all running database instances", + Long: `List all currently running database instances managed by this server. This command shows details about each instance including: • Instance ID + • Database Type (PostgreSQL, MySQL, MariaDB) • Container ID • Port number • Database name • Status • Creation time`, RunE: func(_ *cobra.Command, _ []string) error { - return runPostgresList(format, startPort, endPort) + return runDatabaseList(format, startPort, endPort, dbType) }, } cmd.Flags().StringVar(&format, "format", "table", "Output format (table, json)") - cmd.Flags().IntVar(&startPort, "start-port", 15432, "Start of port range for PostgreSQL instances") - cmd.Flags().IntVar(&endPort, "end-port", 25432, "End of port range for PostgreSQL instances") + cmd.Flags().IntVar(&startPort, "start-port", 15432, "Start of port range for database instances") + cmd.Flags().IntVar(&endPort, "end-port", 25432, "End of port range for database instances") + cmd.Flags().StringVar(&dbType, "type", "", "Filter by database type (postgresql, mysql, mariadb)") + + return cmd +} + +// newDatabaseGetCommand creates the database get command. +func newDatabaseGetCommand() *cobra.Command { + var startPort int + var endPort int + + cmd := &cobra.Command{ + Use: "get ", + Short: "Get details of a specific database instance", + Long: `Get detailed information about a specific database instance by its ID. + +This command shows comprehensive details about the instance including: + • Instance ID and type + • Container information + • Connection details (DSN) + • Current status + • Resource allocation`, + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + instanceID := args[0] + return runDatabaseGet(instanceID, startPort, endPort) + }, + } + + cmd.Flags().IntVar(&startPort, "start-port", 15432, "Start of port range for database instances") + cmd.Flags().IntVar(&endPort, "end-port", 25432, "End of port range for database instances") return cmd } -// newPostgresDropCommand creates the postgres drop command. -func newPostgresDropCommand() *cobra.Command { +// newDatabaseDropCommand creates the database drop command. +func newDatabaseDropCommand() *cobra.Command { var startPort int var endPort int var force bool cmd := &cobra.Command{ Use: "drop ", - Short: "Drop a PostgreSQL instance", - Long: `Drop (remove) a specific PostgreSQL instance by its ID. + Short: "Drop a database instance", + Long: `Drop (remove) a specific database instance by its ID. This command will: - • Stop the PostgreSQL container + • Stop the database container • Remove the container and all associated data • Free up the allocated port • Clean up all resources @@ -185,19 +274,19 @@ WARNING: This action is irreversible. All data in the instance will be lost.`, Args: cobra.ExactArgs(1), RunE: func(_ *cobra.Command, args []string) error { instanceID := args[0] - return runPostgresDrop(instanceID, startPort, endPort, DropOptions{Force: force}) + return runDatabaseDrop(instanceID, startPort, endPort, DropOptions{Force: force}) }, } - cmd.Flags().IntVar(&startPort, "start-port", 15432, "Start of port range for PostgreSQL instances") - cmd.Flags().IntVar(&endPort, "end-port", 25432, "End of port range for PostgreSQL instances") + cmd.Flags().IntVar(&startPort, "start-port", 15432, "Start of port range for database instances") + cmd.Flags().IntVar(&endPort, "end-port", 25432, "End of port range for database instances") cmd.Flags().BoolVar(&force, "force", false, "Force removal without confirmation") return cmd } -// runPostgresList lists all PostgreSQL instances. -func runPostgresList(format string, startPort, endPort int) error { +// runDatabaseList lists all database instances. +func runDatabaseList(format string, startPort, endPort int, dbType string) error { // Create Docker manager dockerMgr, err := docker.NewManager(startPort, endPort) if err != nil { @@ -211,73 +300,113 @@ func runPostgresList(format string, startPort, endPort int) error { return fmt.Errorf("Docker daemon is not accessible: %w", err) } - // Create PostgreSQL manager - postgresManager := postgres.NewManager(dockerMgr) + // Create unified database manager + unifiedManager := database.NewUnifiedManager(dockerMgr) // List instances - instances, err := postgresManager.ListInstances(ctx) + var instances []*types.DatabaseInstance + if dbType != "" { + dbTypeEnum := types.DatabaseType(dbType) + if !dbTypeEnum.IsValid() { + return fmt.Errorf("invalid database type: %s", dbType) + } + instances, err = unifiedManager.ListInstancesByType(ctx, dbTypeEnum) + } else { + instances, err = unifiedManager.ListInstances(ctx) + } + if err != nil { return fmt.Errorf("failed to list instances: %w", err) } - // Output results + // Format output switch format { case "json": - return outputInstancesJSON(instances) + // Ensure instances is an empty array instead of null when empty + if instances == nil { + instances = []*types.DatabaseInstance{} + } + // Create JSON response with count and instances + response := map[string]any{ + "count": len(instances), + "instances": instances, + } + output, err := json.MarshalIndent(response, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal instances to JSON: %w", err) + } + fmt.Println(string(output)) case "table": - return outputInstancesTable(instances) + if len(instances) == 0 { + fmt.Println("No database instances are currently running.") + return nil + } + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "ID\tCONTAINER ID\tTYPE\tPORT\tDATABASE\tSTATUS\tCREATED") + for _, instance := range instances { + // Truncate IDs for display (first 12 characters) + instanceID := instance.ID + if len(instanceID) > 12 { + instanceID = instanceID[:12] + } + containerID := instance.ContainerID + if len(containerID) > 12 { + containerID = containerID[:12] + } + + fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\t%s\t%s\n", + instanceID, + containerID, + instance.Type, + instance.Port, + instance.Database, + instance.Status, + instance.CreatedAt.Format(time.RFC3339)) + } + w.Flush() default: return fmt.Errorf("unsupported format: %s", format) } -} -// outputInstancesJSON outputs instances in JSON format. -func outputInstancesJSON(instances []*types.PostgreSQLInstance) error { - encoder := json.NewEncoder(os.Stdout) - encoder.SetIndent("", " ") - return encoder.Encode(map[string]any{ - "count": len(instances), - "instances": instances, - }) + return nil } -// outputInstancesTable outputs instances in table format. -func outputInstancesTable(instances []*types.PostgreSQLInstance) error { - if len(instances) == 0 { - fmt.Println("No PostgreSQL instances are currently running.") - return nil +// runDatabaseGet gets details of a specific database instance. +func runDatabaseGet(instanceID string, startPort, endPort int) error { + // Create Docker manager + dockerMgr, err := docker.NewManager(startPort, endPort) + if err != nil { + return fmt.Errorf("failed to create Docker manager: %w", err) } + defer dockerMgr.Close() - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - defer w.Flush() - - // Header - fmt.Fprintln(w, "INSTANCE ID\tCONTAINER ID\tPORT\tDATABASE\tUSERNAME\tVERSION\tSTATUS\tCREATED") - - // Rows - for _, instance := range instances { - fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\t%s\t%s\t%s\n", - instance.ID[:12], // Show short ID like Docker (12 chars) - instance.ContainerID[:12], // Show short container ID like Docker (12 chars) - instance.Port, - instance.Database, - instance.Username, - instance.Version, - instance.Status, - instance.CreatedAt.Format(time.RFC3339), - ) + // Test Docker connection + ctx := context.Background() + if err := dockerMgr.Ping(ctx); err != nil { + return fmt.Errorf("Docker daemon is not accessible: %w", err) } - return nil -} + // Create unified database manager + unifiedManager := database.NewUnifiedManager(dockerMgr) -// DropOptions contains options for dropping PostgreSQL instances. -type DropOptions struct { - Force bool + // Get instance + instance, err := unifiedManager.GetInstance(ctx, instanceID) + if err != nil { + return fmt.Errorf("failed to get instance: %w", err) + } + + // Format output as JSON + output, err := json.MarshalIndent(instance, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal instance to JSON: %w", err) + } + + fmt.Println(string(output)) + return nil } -// runPostgresDrop drops a PostgreSQL instance. -func runPostgresDrop(instanceID string, startPort, endPort int, opts DropOptions) error { +// runDatabaseDrop drops a database instance. +func runDatabaseDrop(instanceID string, startPort, endPort int, opts DropOptions) error { // Create Docker manager dockerMgr, err := docker.NewManager(startPort, endPort) if err != nil { @@ -291,107 +420,41 @@ func runPostgresDrop(instanceID string, startPort, endPort int, opts DropOptions return fmt.Errorf("Docker daemon is not accessible: %w", err) } - // Create PostgreSQL manager - postgresManager := postgres.NewManager(dockerMgr) + // Create unified database manager + unifiedManager := database.NewUnifiedManager(dockerMgr) // Get instance details first to verify it exists - instance, err := postgresManager.GetInstance(ctx, instanceID) + instance, err := unifiedManager.GetInstance(ctx, instanceID) if err != nil { return fmt.Errorf("instance %s not found: %w", instanceID, err) } - // Confirmation prompt unless force is used + // Confirm deletion unless force flag is set if !opts.Force { - fmt.Printf("WARNING: This will permanently delete PostgreSQL instance %s and all its data.\n", instanceID) + fmt.Printf("Are you sure you want to drop database instance %s (%s)? This action cannot be undone.\n", instance.ID, instance.Type) fmt.Printf("Instance details:\n") - fmt.Printf(" ID: %s\n", instance.ID) + fmt.Printf(" Type: %s\n", instance.Type) fmt.Printf(" Port: %d\n", instance.Port) fmt.Printf(" Database: %s\n", instance.Database) - fmt.Printf(" Version: %s\n", instance.Version) - fmt.Printf(" Created: %s\n", instance.CreatedAt.Format(time.RFC3339)) - fmt.Print("\nAre you sure you want to continue? (y/N): ") + fmt.Printf(" Status: %s\n", instance.Status) + fmt.Printf("\nType 'yes' to confirm: ") + + var confirmation string + if _, err := fmt.Scanln(&confirmation); err != nil { + return fmt.Errorf("failed to read confirmation: %w", err) + } - var response string - fmt.Scanln(&response) - if response != "y" && response != "Y" && response != "yes" && response != "YES" { + if confirmation != "yes" { fmt.Println("Operation cancelled.") return nil } } // Drop the instance - if err := postgresManager.DropInstance(ctx, instanceID); err != nil { + if err := unifiedManager.DropInstance(ctx, instanceID); err != nil { return fmt.Errorf("failed to drop instance: %w", err) } - fmt.Printf("PostgreSQL instance %s has been successfully dropped.\n", instanceID) + fmt.Printf("Database instance %s (%s) has been successfully dropped.\n", instance.ID, instance.Type) return nil } - -// runMCPServe starts the MCP server. -func runMCPServe(startPort, endPort int, logLevel string) error { - // Setup logging - loggingConfig := mcp.GetLoggingConfigFromEnv() - if logLevel != "" { - switch logLevel { - case "debug": - loggingConfig.Level = mcp.LogLevelDebug - case "info": - loggingConfig.Level = mcp.LogLevelInfo - case "warn": - loggingConfig.Level = mcp.LogLevelWarn - case "error": - loggingConfig.Level = mcp.LogLevelError - default: - return fmt.Errorf("invalid log level: %s", logLevel) - } - } - mcp.SetupLogging(loggingConfig) - - // Create server configuration - config := mcp.ServerConfig{ - Name: "dev-postgres-mcp", - Version: version.Version, - StartPort: startPort, - EndPort: endPort, - LogLevel: string(loggingConfig.Level), - } - - // Create and start server - server, err := mcp.NewServer(config) - if err != nil { - return fmt.Errorf("failed to create MCP server: %w", err) - } - - // Setup graceful shutdown - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // Handle shutdown signals - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - go func() { - <-sigChan - cancel() - }() - - // Start server in a goroutine - serverErr := make(chan error, 1) - go func() { - serverErr <- server.Start(ctx) - }() - - // Wait for shutdown signal or server error - select { - case <-ctx.Done(): - // Graceful shutdown - if err := server.Stop(context.Background()); err != nil { - return fmt.Errorf("failed to stop server gracefully: %w", err) - } - return nil - case err := <-serverErr: - // Server error - return err - } -} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..2327804 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,166 @@ +# Architecture + +This document describes the clean, modern architecture of the dev-postgres-mcp multi-database management system. + +## Overview + +The dev-postgres-mcp system is designed as a Model Context Protocol (MCP) server that provides unified management of ephemeral database instances across multiple database types (PostgreSQL, MySQL, MariaDB). The architecture follows a layered approach with clear separation of concerns. + +## Architecture Diagram + +```mermaid +graph TB + subgraph "Client Layer" + MCP_CLIENT[MCP Client
Augment Code, etc.] + CLI_USER[CLI User] + end + + subgraph "Interface Layer" + MCP_SERVER[MCP Server] + CLI_COMMANDS[CLI Commands] + end + + subgraph "Application Layer" + TOOL_HANDLER[Tool Handler] + DB_COMMANDS[Database Commands] + end + + subgraph "Business Logic Layer" + UNIFIED_MGR[Unified Manager] + PG_MGR[PostgreSQL Manager] + MY_MGR[MySQL Manager] + MA_MGR[MariaDB Manager] + end + + subgraph "Infrastructure Layer" + DOCKER_MGR[Docker Manager] + PORT_MGR[Port Manager] + end + + subgraph "Container Layer" + PG_CONTAINER[PostgreSQL
Container] + MY_CONTAINER[MySQL
Container] + MA_CONTAINER[MariaDB
Container] + end + + %% Client connections + MCP_CLIENT --> MCP_SERVER + CLI_USER --> CLI_COMMANDS + + %% Interface to Application + MCP_SERVER --> TOOL_HANDLER + CLI_COMMANDS --> DB_COMMANDS + + %% Application to Business Logic + TOOL_HANDLER --> UNIFIED_MGR + DB_COMMANDS --> UNIFIED_MGR + + %% Business Logic relationships + UNIFIED_MGR --> PG_MGR + UNIFIED_MGR --> MY_MGR + UNIFIED_MGR --> MA_MGR + + %% Business Logic to Infrastructure + PG_MGR --> DOCKER_MGR + MY_MGR --> DOCKER_MGR + MA_MGR --> DOCKER_MGR + DOCKER_MGR --> PORT_MGR + + %% Infrastructure to Containers + DOCKER_MGR --> PG_CONTAINER + DOCKER_MGR --> MY_CONTAINER + DOCKER_MGR --> MA_CONTAINER + + %% Styling + classDef clientLayer fill:#e1f5fe + classDef interfaceLayer fill:#f3e5f5 + classDef applicationLayer fill:#e8f5e8 + classDef businessLayer fill:#fff3e0 + classDef infraLayer fill:#fce4ec + classDef containerLayer fill:#f1f8e9 + + class MCP_CLIENT,CLI_USER clientLayer + class MCP_SERVER,CLI_COMMANDS interfaceLayer + class TOOL_HANDLER,DB_COMMANDS applicationLayer + class UNIFIED_MGR,PG_MGR,MY_MGR,MA_MGR businessLayer + class DOCKER_MGR,PORT_MGR infraLayer + class PG_CONTAINER,MY_CONTAINER,MA_CONTAINER containerLayer +``` + +## Layer Descriptions + +### Client Layer +- **MCP Client**: External clients like Augment Code that communicate via the Model Context Protocol +- **CLI User**: Direct command-line interface users + +### Interface Layer +- **MCP Server**: Handles MCP protocol communication and tool registration +- **CLI Commands**: Command-line interface for direct database management + +### Application Layer +- **Tool Handler**: Processes MCP tool calls and routes them to appropriate managers +- **Database Commands**: Handles CLI command execution and user interaction + +### Business Logic Layer +- **Unified Manager**: Central orchestrator that provides a unified interface for all database operations +- **Database-Specific Managers**: Individual managers for PostgreSQL, MySQL, and MariaDB that handle database-specific logic + +### Infrastructure Layer +- **Docker Manager**: Manages Docker container lifecycle, networking, and resource allocation +- **Port Manager**: Handles dynamic port allocation and conflict resolution + +### Container Layer +- **Database Containers**: Actual Docker containers running the database instances + +## Key Design Principles + +### 1. Unified Interface +All database operations are exposed through a single, consistent interface regardless of the underlying database type. This simplifies client interaction and reduces complexity. + +### 2. Database-Agnostic Operations +Common operations (create, list, get, drop, health check) work identically across all supported database types, with database-specific details handled internally. + +### 3. Clean Separation of Concerns +Each layer has a specific responsibility: +- Interface layers handle protocol/user interaction +- Application layers handle request processing +- Business logic layers handle database management +- Infrastructure layers handle resource management + +### 4. Extensibility +New database types can be added by implementing the `DatabaseManager` interface and registering with the unified manager. + +### 5. Resource Management +Centralized port allocation and Docker container management ensure efficient resource utilization and prevent conflicts. + +## Data Flow + +### Creating a Database Instance +1. Client sends `create_database_instance` request via MCP or CLI +2. Tool Handler/DB Commands validate parameters +3. Unified Manager routes to appropriate database-specific manager +4. Database Manager requests resources from Docker Manager +5. Docker Manager allocates port and creates container +6. Database Manager configures and starts the database +7. Instance details are returned to the client + +### Listing Database Instances +1. Client requests instance list (optionally filtered by type) +2. Unified Manager queries all or specific database managers +3. Database Managers query Docker Manager for container status +4. Consolidated list is returned to the client + +### Managing Instance Lifecycle +1. All lifecycle operations (get, drop, health check) follow similar patterns +2. Unified Manager routes to appropriate database manager +3. Database Manager coordinates with Docker Manager +4. Results are returned through the same path + +## Benefits of This Architecture + +1. **Simplicity**: Clean, modern design without legacy compatibility layers +2. **Maintainability**: Clear separation of concerns makes the codebase easy to understand and modify +3. **Extensibility**: New database types can be added without affecting existing code +4. **Consistency**: Unified interface provides consistent behavior across all database types +5. **Resource Efficiency**: Centralized resource management prevents conflicts and optimizes utilization +6. **Testability**: Each layer can be tested independently with clear interfaces diff --git a/internal/database/generic_manager.go b/internal/database/generic_manager.go new file mode 100644 index 0000000..a357687 --- /dev/null +++ b/internal/database/generic_manager.go @@ -0,0 +1,588 @@ +// Package database provides database instance management functionality. +package database + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "strconv" + "strings" + "sync" + "text/template" + "time" + + "github.com/docker/docker/api/types/container" + + "github.com/stokaro/dev-postgres-mcp/internal/docker" + "github.com/stokaro/dev-postgres-mcp/pkg/types" +) + +// DatabaseConfig holds database-specific configuration. +type DatabaseConfig struct { + Type types.DatabaseType + DefaultPort int + DefaultVersion string + DefaultDatabase string + DefaultUsername string + EnvironmentTemplate map[string]string // Template strings for environment variables + HealthCheckCommand []string // Health check command template strings + ContainerPort string // Internal container port + + // Compiled templates (populated during initialization) + envTemplates map[string]*template.Template + healthTemplates []*template.Template +} + +// GetDatabaseConfig returns configuration for a specific database type. +func GetDatabaseConfig(dbType types.DatabaseType) DatabaseConfig { + var config DatabaseConfig + + switch dbType { + case types.DatabaseTypePostgreSQL: + config = DatabaseConfig{ + Type: types.DatabaseTypePostgreSQL, + DefaultPort: 5432, + DefaultVersion: "17", + DefaultDatabase: "postgres", + DefaultUsername: "postgres", + EnvironmentTemplate: map[string]string{ + "POSTGRES_DB": "{{.Database}}", + "POSTGRES_USER": "{{.Username}}", + "POSTGRES_PASSWORD": "{{.Password}}", + }, + HealthCheckCommand: []string{"CMD-SHELL", "pg_isready -U {{.Username}} -d {{.Database}}"}, + ContainerPort: "5432/tcp", + } + case types.DatabaseTypeMySQL: + config = DatabaseConfig{ + Type: types.DatabaseTypeMySQL, + DefaultPort: 3306, + DefaultVersion: "8.0", + DefaultDatabase: "mysql", + DefaultUsername: "root", + EnvironmentTemplate: map[string]string{ + "MYSQL_DATABASE": "{{.Database}}", + "MYSQL_ROOT_PASSWORD": "{{.Password}}", + }, + HealthCheckCommand: []string{"CMD-SHELL", "mysqladmin ping -u root -p{{.Password}} --silent"}, + ContainerPort: "3306/tcp", + } + case types.DatabaseTypeMariaDB: + config = DatabaseConfig{ + Type: types.DatabaseTypeMariaDB, + DefaultPort: 3306, + DefaultVersion: "11", + DefaultDatabase: "mysql", + DefaultUsername: "root", + EnvironmentTemplate: map[string]string{ + "MARIADB_DATABASE": "{{.Database}}", + "MARIADB_ROOT_PASSWORD": "{{.Password}}", + }, + HealthCheckCommand: []string{"CMD-SHELL", "mariadb -u root -p{{.Password}} -e 'SELECT 1' || mysqladmin ping -h localhost -u root -p{{.Password}}"}, + ContainerPort: "3306/tcp", + } + default: + panic(fmt.Sprintf("unsupported database type: %s", dbType)) + } + + // Compile templates + config.envTemplates = make(map[string]*template.Template) + for key, tmplStr := range config.EnvironmentTemplate { + tmpl, err := template.New(key).Parse(tmplStr) + if err != nil { + panic(fmt.Sprintf("failed to parse environment template for %s.%s: %v", dbType, key, err)) + } + config.envTemplates[key] = tmpl + } + + config.healthTemplates = make([]*template.Template, len(config.HealthCheckCommand)) + for i, tmplStr := range config.HealthCheckCommand { + tmpl, err := template.New(fmt.Sprintf("health_%d", i)).Parse(tmplStr) + if err != nil { + panic(fmt.Sprintf("failed to parse health check template for %s[%d]: %v", dbType, i, err)) + } + config.healthTemplates[i] = tmpl + } + + return config +} + +// GenericManager implements DatabaseManager for any database type using configuration. +type GenericManager struct { + mu sync.RWMutex + instances map[string]*types.DatabaseInstance + docker *docker.Manager + config DatabaseConfig +} + +// NewGenericManager creates a new generic database manager for the specified type. +func NewGenericManager(dockerManager *docker.Manager, dbType types.DatabaseType) *GenericManager { + return &GenericManager{ + instances: make(map[string]*types.DatabaseInstance), + docker: dockerManager, + config: GetDatabaseConfig(dbType), + } +} + +// CreateInstance creates a new database instance. +func (m *GenericManager) CreateInstance(ctx context.Context, opts types.CreateInstanceOptions) (*types.DatabaseInstance, error) { + m.mu.Lock() + defer m.mu.Unlock() + + // Generate instance ID + instanceID := types.GenerateInstanceID() + + slog.Info("Creating database instance", + "type", m.config.Type, + "instance_id", instanceID, + "version", opts.Version, + "database", opts.Database, + "username", opts.Username) + + // Allocate port + port, err := m.docker.AllocatePort(ctx) + if err != nil { + return nil, fmt.Errorf("failed to allocate port: %w", err) + } + + // Create and start container + instance, err := m.createContainer(ctx, instanceID, opts, port) + if err != nil { + // Release port on failure + m.docker.ReleasePort(port) + return nil, fmt.Errorf("failed to create %s container: %w", m.config.Type, err) + } + + // Store instance + m.instances[instanceID] = instance + + slog.Info("Database instance created successfully", + "type", m.config.Type, + "instance_id", instanceID, + "port", port, + "dsn", instance.DSN) + + return instance, nil +} + +// ListInstances returns all database instances of this type. +func (m *GenericManager) ListInstances(ctx context.Context) ([]*types.DatabaseInstance, error) { + // Get all containers of this database type + containers, err := m.listContainers(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list %s containers: %w", m.config.Type, err) + } + + var instances []*types.DatabaseInstance + for _, cont := range containers { + // Extract instance information from container labels + instanceID := cont.Labels["dev-postgres-mcp.instance-id"] + if instanceID == "" { + continue + } + + database := cont.Labels["dev-postgres-mcp.database"] + username := cont.Labels["dev-postgres-mcp.username"] + version := cont.Labels["dev-postgres-mcp.version"] + portStr := cont.Labels["dev-postgres-mcp.port"] + createdAtStr := cont.Labels["dev-postgres-mcp.created-at"] + + port, _ := strconv.Atoi(portStr) + createdAt, _ := time.Parse(time.RFC3339, createdAtStr) + + // Determine status + status := "unknown" + if len(cont.Names) > 0 { + if cont.State == "running" { + status = "running" + } else { + status = cont.State + } + } + + instance := &types.DatabaseInstance{ + ID: instanceID, + Type: m.config.Type, + ContainerID: cont.ID, + Port: port, + Database: database, + Username: username, + Version: version, + CreatedAt: createdAt, + Status: status, + } + + // We don't store password in labels for security, so we can't retrieve it + // The DSN will be incomplete, but that's acceptable for listing + instance.DSN = types.BuildDSN(instance) + + instances = append(instances, instance) + } + + // Update in-memory instances + m.mu.Lock() + m.instances = make(map[string]*types.DatabaseInstance) + for _, instance := range instances { + m.instances[instance.ID] = instance + } + m.mu.Unlock() + + return instances, nil +} + +// GetInstance returns a specific database instance by ID. +func (m *GenericManager) GetInstance(ctx context.Context, id string) (*types.DatabaseInstance, error) { + // First try exact match in-memory instances + m.mu.RLock() + if instance, exists := m.instances[id]; exists { + m.mu.RUnlock() + + // Update status from Docker + status, err := m.getContainerStatus(ctx, instance.ContainerID) + if err != nil { + slog.Warn("Failed to get container status", "instance_id", id, "error", err) + status = "unknown" + } + + // Create a copy to avoid modifying the original + instanceCopy := *instance + instanceCopy.Status = status + return &instanceCopy, nil + } + m.mu.RUnlock() + + // Try partial ID matching by listing all instances + instances, err := m.ListInstances(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list instances: %w", err) + } + + // Find matching instances (exact match or prefix match) + var matches []*types.DatabaseInstance + for _, instance := range instances { + if instance.ID == id || strings.HasPrefix(instance.ID, id) { + matches = append(matches, instance) + } + } + + if len(matches) == 0 { + return nil, fmt.Errorf("%s instance %s not found", m.config.Type, id) + } + + if len(matches) > 1 { + return nil, fmt.Errorf("multiple %s instances match %s", m.config.Type, id) + } + + return matches[0], nil +} + +// DropInstance removes a database instance. +func (m *GenericManager) DropInstance(ctx context.Context, id string) error { + // Get instance details first + instance, err := m.GetInstance(ctx, id) + if err != nil { + return err + } + + slog.Info("Dropping database instance", "type", m.config.Type, "instance_id", instance.ID) + + // Stop and remove container + if err := m.docker.StopContainer(ctx, instance.ContainerID); err != nil { + slog.Warn("Failed to stop container", "type", m.config.Type, "instance_id", instance.ID, "error", err) + } + + if err := m.docker.RemoveContainer(ctx, instance.ContainerID); err != nil { + return fmt.Errorf("failed to remove %s container: %w", m.config.Type, err) + } + + // Release port + m.docker.ReleasePort(instance.Port) + + // Remove from in-memory instances + m.mu.Lock() + delete(m.instances, instance.ID) + m.mu.Unlock() + + slog.Info("Database instance dropped successfully", "type", m.config.Type, "instance_id", instance.ID) + return nil +} + +// HealthCheck performs a health check on a database instance. +func (m *GenericManager) HealthCheck(ctx context.Context, id string) (*types.HealthCheckResult, error) { + instance, err := m.GetInstance(ctx, id) + if err != nil { + return nil, err + } + + start := time.Now() + status, err := m.getContainerStatus(ctx, instance.ContainerID) + duration := time.Since(start) + + result := &types.HealthCheckResult{ + Duration: duration.String(), + Timestamp: time.Now().UTC().Format(time.RFC3339), + } + + if err != nil { + result.Status = types.HealthStatusUnknown + result.Message = fmt.Sprintf("Failed to check status: %v", err) + return result, nil + } + + switch status { + case "running": + result.Status = types.HealthStatusHealthy + result.Message = fmt.Sprintf("%s instance is running and healthy", m.config.Type) + case "starting": + result.Status = types.HealthStatusStarting + result.Message = fmt.Sprintf("%s instance is starting up", m.config.Type) + case "unhealthy": + result.Status = types.HealthStatusUnhealthy + result.Message = fmt.Sprintf("%s instance is unhealthy", m.config.Type) + default: + result.Status = types.HealthStatusUnknown + result.Message = fmt.Sprintf("%s instance status: %s", m.config.Type, status) + } + + return result, nil +} + +// Cleanup removes all database instances of this type. +func (m *GenericManager) Cleanup(ctx context.Context) error { + instances, err := m.ListInstances(ctx) + if err != nil { + return fmt.Errorf("failed to list %s instances for cleanup: %w", m.config.Type, err) + } + + var errors []error + for _, instance := range instances { + if err := m.DropInstance(ctx, instance.ID); err != nil { + errors = append(errors, err) + } + } + + if len(errors) > 0 { + return fmt.Errorf("failed to cleanup some %s instances: %v", m.config.Type, errors) + } + + return nil +} + +// GetInstanceCount returns the number of database instances of this type. +func (m *GenericManager) GetInstanceCount() int { + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.instances) +} + +// GetDatabaseType returns the database type this manager handles. +func (m *GenericManager) GetDatabaseType() types.DatabaseType { + return m.config.Type +} + +// Helper methods + +// TemplateData holds the data for template execution. +type TemplateData struct { + Database string + Username string + Password string +} + +// executeTemplate executes a template with the given data. +func (m *GenericManager) executeTemplate(tmpl *template.Template, data TemplateData) (string, error) { + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("failed to execute template: %w", err) + } + return buf.String(), nil +} + +// createContainer creates and starts a database container. +func (m *GenericManager) createContainer(ctx context.Context, instanceID string, opts types.CreateInstanceOptions, port int) (*types.DatabaseInstance, error) { + image := types.GetDockerImage(m.config.Type, opts.Version) + containerName := types.GetContainerName(instanceID, m.config.Type) + + slog.Info("Creating database container", + "type", m.config.Type, + "instance_id", instanceID, + "image", image, + "port", port, + "database", opts.Database) + + // Pull the image if needed + if err := m.docker.PullImage(ctx, image); err != nil { + return nil, fmt.Errorf("failed to pull %s image: %w", m.config.Type, err) + } + + // Prepare template data + data := TemplateData{ + Database: opts.Database, + Username: opts.Username, + Password: opts.Password, + } + + // Build environment variables using compiled templates + var env []string + for key, tmpl := range m.config.envTemplates { + value, err := m.executeTemplate(tmpl, data) + if err != nil { + return nil, fmt.Errorf("failed to execute environment template for %s: %w", key, err) + } + env = append(env, fmt.Sprintf("%s=%s", key, value)) + } + + // Build health check command using compiled templates + var healthCmd []string + for _, tmpl := range m.config.healthTemplates { + cmdPart, err := m.executeTemplate(tmpl, data) + if err != nil { + return nil, fmt.Errorf("failed to execute health check template: %w", err) + } + healthCmd = append(healthCmd, cmdPart) + } + + // Create container using the generic Docker client + containerID, err := m.docker.CreateGenericContainer(ctx, docker.GenericContainerConfig{ + Image: image, + ContainerName: containerName, + Environment: env, + Port: port, + ContainerPort: m.config.ContainerPort, + HealthCheck: healthCmd, + Labels: map[string]string{ + "dev-postgres-mcp.managed": "true", + "dev-postgres-mcp.type": string(m.config.Type), + "dev-postgres-mcp.instance-id": instanceID, + "dev-postgres-mcp.database": opts.Database, + "dev-postgres-mcp.username": opts.Username, + "dev-postgres-mcp.version": opts.Version, + "dev-postgres-mcp.port": strconv.Itoa(port), + "dev-postgres-mcp.created-at": time.Now().UTC().Format(time.RFC3339), + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to create %s container: %w", m.config.Type, err) + } + + // Start container + if err := m.docker.StartContainer(ctx, containerID); err != nil { + // Clean up on failure + _ = m.docker.RemoveContainer(ctx, containerID) + return nil, fmt.Errorf("failed to start %s container: %w", m.config.Type, err) + } + + // Wait for container to be healthy + if err := m.waitForHealthy(ctx, containerID, 120*time.Second); err != nil { + // Clean up on failure + _ = m.docker.RemoveContainer(ctx, containerID) + return nil, fmt.Errorf("%s container failed to become healthy: %w", m.config.Type, err) + } + + // Create instance object + instance := &types.DatabaseInstance{ + ID: instanceID, + Type: m.config.Type, + ContainerID: containerID, + Port: port, + Database: opts.Database, + Username: opts.Username, + Password: opts.Password, + Version: opts.Version, + CreatedAt: time.Now(), + Status: "running", + } + instance.DSN = types.BuildDSN(instance) + + slog.Info("Database container created and started successfully", + "type", m.config.Type, + "instance_id", instanceID, + "container_id", containerID, + "port", port) + + return instance, nil +} + +// listContainers lists all containers of this database type. +func (m *GenericManager) listContainers(ctx context.Context) ([]container.Summary, error) { + return m.docker.ListContainersByType(ctx, m.config.Type) +} + +// getContainerStatus returns the status of a container. +func (m *GenericManager) getContainerStatus(ctx context.Context, containerID string) (string, error) { + inspect, err := m.docker.InspectContainer(ctx, containerID) + if err != nil { + return "", err + } + + if !inspect.State.Running { + return "stopped", nil + } + + // Check health status + if inspect.State.Health != nil { + switch inspect.State.Health.Status { + case "healthy": + return "running", nil + case "unhealthy": + return "unhealthy", nil + case "starting": + return "starting", nil + default: + return "unknown", nil + } + } + + return "running", nil +} + +// waitForHealthy waits for a container to become healthy. +func (m *GenericManager) waitForHealthy(ctx context.Context, containerID string, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + slog.Info("Waiting for database container to become healthy", "type", m.config.Type, "container_id", containerID) + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("timeout waiting for container to become healthy: %w", ctx.Err()) + case <-ticker.C: + inspect, err := m.docker.InspectContainer(ctx, containerID) + if err != nil { + return fmt.Errorf("failed to inspect container: %w", err) + } + + if !inspect.State.Running { + return fmt.Errorf("container stopped unexpectedly") + } + + if inspect.State.Health != nil { + switch inspect.State.Health.Status { + case "healthy": + slog.Info("Database container is healthy", "type", m.config.Type, "container_id", containerID) + return nil + case "unhealthy": + logs, _ := m.docker.ContainerLogs(ctx, containerID, container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + Tail: "50", + }) + return fmt.Errorf("container became unhealthy, logs: %s", logs) + case "starting": + slog.Debug("Database container is still starting", "type", m.config.Type, "container_id", containerID) + continue + } + } + + // If no health check is configured, assume it's ready after a short delay + // This shouldn't happen with our configuration, but it's a fallback + time.Sleep(5 * time.Second) + return nil + } + } +} diff --git a/internal/database/manager.go b/internal/database/manager.go new file mode 100644 index 0000000..4fb91c2 --- /dev/null +++ b/internal/database/manager.go @@ -0,0 +1,234 @@ +// Package database provides unified database instance management functionality. +package database + +import ( + "context" + "fmt" + "log/slog" + "sync" + + "github.com/stokaro/dev-postgres-mcp/internal/docker" + "github.com/stokaro/dev-postgres-mcp/pkg/types" +) + +// UnifiedManager manages database instances across multiple database types. +type UnifiedManager struct { + mu sync.RWMutex + instances map[string]*types.DatabaseInstance + docker *docker.Manager + managers map[types.DatabaseType]types.DatabaseManager +} + +// NewUnifiedManager creates a new unified database manager. +func NewUnifiedManager(dockerManager *docker.Manager) *UnifiedManager { + managers := make(map[types.DatabaseType]types.DatabaseManager) + + // Create database-specific managers using the generic manager + managers[types.DatabaseTypePostgreSQL] = NewGenericManager(dockerManager, types.DatabaseTypePostgreSQL) + managers[types.DatabaseTypeMySQL] = NewGenericManager(dockerManager, types.DatabaseTypeMySQL) + managers[types.DatabaseTypeMariaDB] = NewGenericManager(dockerManager, types.DatabaseTypeMariaDB) + + return &UnifiedManager{ + instances: make(map[string]*types.DatabaseInstance), + docker: dockerManager, + managers: managers, + } +} + +// CreateInstance creates a new database instance of the specified type. +func (m *UnifiedManager) CreateInstance(ctx context.Context, opts types.CreateInstanceOptions) (*types.DatabaseInstance, error) { + // Validate and set defaults + if err := types.ValidateCreateInstanceOptions(&opts); err != nil { + return nil, fmt.Errorf("invalid options: %w", err) + } + + // Get the appropriate manager + manager, exists := m.managers[opts.Type] + if !exists { + return nil, fmt.Errorf("unsupported database type: %s", opts.Type) + } + + // Create the instance + instance, err := manager.CreateInstance(ctx, opts) + if err != nil { + return nil, err + } + + // Store in unified registry + m.mu.Lock() + m.instances[instance.ID] = instance + m.mu.Unlock() + + slog.Info("Database instance created", + "instance_id", instance.ID, + "type", instance.Type, + "version", instance.Version, + "port", instance.Port) + + return instance, nil +} + +// ListInstances returns all database instances across all types. +func (m *UnifiedManager) ListInstances(ctx context.Context) ([]*types.DatabaseInstance, error) { + var allInstances []*types.DatabaseInstance + + // Get instances from each database manager + for dbType, manager := range m.managers { + instances, err := manager.ListInstances(ctx) + if err != nil { + slog.Warn("Failed to list instances for database type", "type", dbType, "error", err) + continue + } + allInstances = append(allInstances, instances...) + } + + // Update in-memory registry + m.mu.Lock() + m.instances = make(map[string]*types.DatabaseInstance) + for _, instance := range allInstances { + m.instances[instance.ID] = instance + } + m.mu.Unlock() + + return allInstances, nil +} + +// ListInstancesByType returns all database instances of a specific type. +func (m *UnifiedManager) ListInstancesByType(ctx context.Context, dbType types.DatabaseType) ([]*types.DatabaseInstance, error) { + manager, exists := m.managers[dbType] + if !exists { + return nil, fmt.Errorf("unsupported database type: %s", dbType) + } + + return manager.ListInstances(ctx) +} + +// GetInstance returns a specific database instance by ID. +func (m *UnifiedManager) GetInstance(ctx context.Context, id string) (*types.DatabaseInstance, error) { + // First try exact match in-memory instances + m.mu.RLock() + if instance, exists := m.instances[id]; exists { + m.mu.RUnlock() + + // Get the appropriate manager and update status + manager, exists := m.managers[instance.Type] + if exists { + if updatedInstance, err := manager.GetInstance(ctx, id); err == nil { + return updatedInstance, nil + } + } + + return instance, nil + } + m.mu.RUnlock() + + // Try to find in each database manager (supports partial ID matching) + for _, manager := range m.managers { + if instance, err := manager.GetInstance(ctx, id); err == nil { + // Update in-memory registry + m.mu.Lock() + m.instances[instance.ID] = instance + m.mu.Unlock() + return instance, nil + } + } + + return nil, fmt.Errorf("instance %s not found", id) +} + +// DropInstance removes a database instance. +func (m *UnifiedManager) DropInstance(ctx context.Context, id string) error { + // Get the instance to determine its type + instance, err := m.GetInstance(ctx, id) + if err != nil { + return err + } + + // Get the appropriate manager + manager, exists := m.managers[instance.Type] + if !exists { + return fmt.Errorf("unsupported database type: %s", instance.Type) + } + + // Drop the instance + if err := manager.DropInstance(ctx, id); err != nil { + return err + } + + // Remove from in-memory registry + m.mu.Lock() + delete(m.instances, id) + m.mu.Unlock() + + slog.Info("Database instance dropped", + "instance_id", id, + "type", instance.Type) + + return nil +} + +// HealthCheck performs a health check on a database instance. +func (m *UnifiedManager) HealthCheck(ctx context.Context, id string) (*types.HealthCheckResult, error) { + // Get the instance to determine its type + instance, err := m.GetInstance(ctx, id) + if err != nil { + return nil, err + } + + // Get the appropriate manager + manager, exists := m.managers[instance.Type] + if !exists { + return nil, fmt.Errorf("unsupported database type: %s", instance.Type) + } + + return manager.HealthCheck(ctx, id) +} + +// Cleanup removes all instances managed by this manager. +func (m *UnifiedManager) Cleanup(ctx context.Context) error { + var errors []error + + // Cleanup each database manager + for dbType, manager := range m.managers { + if err := manager.Cleanup(ctx); err != nil { + slog.Error("Failed to cleanup database instances", "type", dbType, "error", err) + errors = append(errors, fmt.Errorf("failed to cleanup %s instances: %w", dbType, err)) + } + } + + // Clear in-memory registry + m.mu.Lock() + m.instances = make(map[string]*types.DatabaseInstance) + m.mu.Unlock() + + if len(errors) > 0 { + return fmt.Errorf("cleanup failed for some database types: %v", errors) + } + + return nil +} + +// GetInstanceCount returns the total number of instances across all database types. +func (m *UnifiedManager) GetInstanceCount() int { + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.instances) +} + +// GetInstanceCountByType returns the number of instances for a specific database type. +func (m *UnifiedManager) GetInstanceCountByType(dbType types.DatabaseType) int { + manager, exists := m.managers[dbType] + if !exists { + return 0 + } + return manager.GetInstanceCount() +} + +// GetSupportedDatabaseTypes returns a list of supported database types. +func (m *UnifiedManager) GetSupportedDatabaseTypes() []types.DatabaseType { + supportedTypes := make([]types.DatabaseType, 0, len(m.managers)) + for dbType := range m.managers { + supportedTypes = append(supportedTypes, dbType) + } + return supportedTypes +} diff --git a/internal/docker/manager.go b/internal/docker/manager.go index 2ee6987..6f8cc53 100644 --- a/internal/docker/manager.go +++ b/internal/docker/manager.go @@ -6,6 +6,13 @@ import ( "net" "strconv" "sync" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/go-connections/nat" + + "github.com/stokaro/dev-postgres-mcp/pkg/types" ) // PortManager manages port allocation for containers. @@ -92,7 +99,6 @@ func (pm *PortManager) isPortAvailable(port int) bool { type Manager struct { client *Client portManager *PortManager - postgres *PostgreSQLManager } // NewManager creates a new Docker manager with the specified port range. @@ -103,12 +109,10 @@ func NewManager(startPort, endPort int) (*Manager, error) { } portManager := NewPortManager(startPort, endPort) - postgres := NewPostgreSQLManager(client) return &Manager{ client: client, portManager: portManager, - postgres: postgres, }, nil } @@ -132,12 +136,121 @@ func (m *Manager) ReleasePort(port int) { m.portManager.ReleasePort(port) } -// PostgreSQL returns the PostgreSQL manager. -func (m *Manager) PostgreSQL() *PostgreSQLManager { - return m.postgres -} - // GetClient returns the underlying Docker client. func (m *Manager) GetClient() *Client { return m.client } + +// GenericContainerConfig holds configuration for creating a generic database container. +type GenericContainerConfig struct { + Image string + ContainerName string + Environment []string + Port int + ContainerPort string + HealthCheck []string + Labels map[string]string +} + +// CreateGenericContainer creates a generic database container. +func (m *Manager) CreateGenericContainer(ctx context.Context, config GenericContainerConfig) (string, error) { + // Pull the image if needed + if err := m.client.PullImage(ctx, config.Image); err != nil { + return "", fmt.Errorf("failed to pull image: %w", err) + } + + // Configure container + containerConfig := &container.Config{ + Image: config.Image, + Env: config.Environment, + Labels: config.Labels, + ExposedPorts: nat.PortSet{ + nat.Port(config.ContainerPort): struct{}{}, + }, + Healthcheck: &container.HealthConfig{ + Test: config.HealthCheck, + Interval: 10 * time.Second, + Timeout: 5 * time.Second, + Retries: 5, + StartPeriod: 30 * time.Second, + }, + } + + hostConfig := &container.HostConfig{ + PortBindings: nat.PortMap{ + nat.Port(config.ContainerPort): []nat.PortBinding{ + { + HostIP: "127.0.0.1", + HostPort: strconv.Itoa(config.Port), + }, + }, + }, + RestartPolicy: container.RestartPolicy{ + Name: "no", + }, + // Set resource limits + Resources: container.Resources{ + Memory: 512 * 1024 * 1024, // 512MB + NanoCPUs: 1000000000, // 1 CPU core + }, + } + + // Create container + containerID, err := m.client.CreateContainer(ctx, containerConfig, hostConfig, config.ContainerName) + if err != nil { + return "", fmt.Errorf("failed to create container: %w", err) + } + + return containerID, nil +} + +// ListContainersByType lists all containers of a specific database type. +func (m *Manager) ListContainersByType(ctx context.Context, dbType types.DatabaseType) ([]container.Summary, error) { + filterArgs := filters.NewArgs() + filterArgs.Add("label", "dev-postgres-mcp.managed=true") + filterArgs.Add("label", fmt.Sprintf("dev-postgres-mcp.type=%s", dbType)) + + containers, err := m.client.ListContainers(ctx, container.ListOptions{ + All: true, + Filters: filterArgs, + }) + if err != nil { + return nil, err + } + + return containers, nil +} + +// StartContainer starts a container. +func (m *Manager) StartContainer(ctx context.Context, containerID string) error { + return m.client.StartContainer(ctx, containerID) +} + +// StopContainer stops a container. +func (m *Manager) StopContainer(ctx context.Context, containerID string) error { + return m.client.StopContainer(ctx, containerID) +} + +// RemoveContainer removes a container. +func (m *Manager) RemoveContainer(ctx context.Context, containerID string) error { + return m.client.RemoveContainer(ctx, containerID) +} + +// InspectContainer inspects a container. +func (m *Manager) InspectContainer(ctx context.Context, containerID string) (*container.InspectResponse, error) { + inspect, err := m.client.InspectContainer(ctx, containerID) + if err != nil { + return nil, err + } + return &inspect, nil +} + +// ContainerLogs gets container logs. +func (m *Manager) ContainerLogs(ctx context.Context, containerID string, options container.LogsOptions) (string, error) { + return m.client.ContainerLogs(ctx, containerID, options) +} + +// PullImage pulls a Docker image. +func (m *Manager) PullImage(ctx context.Context, image string) error { + return m.client.PullImage(ctx, image) +} diff --git a/internal/docker/postgres.go b/internal/docker/postgres.go deleted file mode 100644 index fa7ca00..0000000 --- a/internal/docker/postgres.go +++ /dev/null @@ -1,250 +0,0 @@ -package docker - -import ( - "context" - "fmt" - "log/slog" - "strconv" - "time" - - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/filters" - "github.com/docker/go-connections/nat" - - "github.com/stokaro/dev-postgres-mcp/pkg/types" -) - -// PostgreSQLContainerConfig holds configuration for creating a PostgreSQL container. -type PostgreSQLContainerConfig struct { - Version string - Database string - Username string - Password string - Port int -} - -// PostgreSQLManager manages PostgreSQL containers. -type PostgreSQLManager struct { - client *Client -} - -// NewPostgreSQLManager creates a new PostgreSQL container manager. -func NewPostgreSQLManager(client *Client) *PostgreSQLManager { - return &PostgreSQLManager{ - client: client, - } -} - -// CreatePostgreSQLContainer creates and starts a new PostgreSQL container. -func (m *PostgreSQLManager) CreatePostgreSQLContainer(ctx context.Context, instanceID string, config PostgreSQLContainerConfig) (*types.PostgreSQLInstance, error) { - image := fmt.Sprintf("postgres:%s", config.Version) - containerName := fmt.Sprintf("mcp-postgres-%s", instanceID) - - slog.Info("Creating PostgreSQL container", - "instance_id", instanceID, - "image", image, - "port", config.Port, - "database", config.Database) - - // Pull the image if needed - if err := m.client.PullImage(ctx, image); err != nil { - return nil, fmt.Errorf("failed to pull PostgreSQL image: %w", err) - } - - // Configure container - containerConfig := &container.Config{ - Image: image, - Env: []string{ - fmt.Sprintf("POSTGRES_DB=%s", config.Database), - fmt.Sprintf("POSTGRES_USER=%s", config.Username), - fmt.Sprintf("POSTGRES_PASSWORD=%s", config.Password), - "POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256", - }, - ExposedPorts: nat.PortSet{ - "5432/tcp": struct{}{}, - }, - Labels: map[string]string{ - "dev-postgres-mcp.managed": "true", - "dev-postgres-mcp.instance-id": instanceID, - "dev-postgres-mcp.database": config.Database, - "dev-postgres-mcp.username": config.Username, - "dev-postgres-mcp.version": config.Version, - "dev-postgres-mcp.port": strconv.Itoa(config.Port), - "dev-postgres-mcp.created-at": time.Now().UTC().Format(time.RFC3339), - }, - Healthcheck: &container.HealthConfig{ - Test: []string{ - "CMD-SHELL", - fmt.Sprintf("pg_isready -U %s -d %s", config.Username, config.Database), - }, - Interval: 10 * time.Second, - Timeout: 5 * time.Second, - Retries: 5, - StartPeriod: 30 * time.Second, - }, - } - - hostConfig := &container.HostConfig{ - PortBindings: nat.PortMap{ - "5432/tcp": []nat.PortBinding{ - { - HostIP: "127.0.0.1", - HostPort: strconv.Itoa(config.Port), - }, - }, - }, - RestartPolicy: container.RestartPolicy{ - Name: "no", - }, - // Set resource limits - Resources: container.Resources{ - Memory: 512 * 1024 * 1024, // 512MB - NanoCPUs: 1000000000, // 1 CPU core - }, - } - - // Create container - containerID, err := m.client.CreateContainer(ctx, containerConfig, hostConfig, containerName) - if err != nil { - return nil, fmt.Errorf("failed to create PostgreSQL container: %w", err) - } - - // Start container - if err := m.client.StartContainer(ctx, containerID); err != nil { - // Clean up on failure - _ = m.client.RemoveContainer(ctx, containerID) - return nil, fmt.Errorf("failed to start PostgreSQL container: %w", err) - } - - // Wait for container to be healthy - if err := m.waitForHealthy(ctx, containerID, 60*time.Second); err != nil { - // Clean up on failure - _ = m.client.RemoveContainer(ctx, containerID) - return nil, fmt.Errorf("PostgreSQL container failed to become healthy: %w", err) - } - - // Create instance object - instance := &types.PostgreSQLInstance{ - ID: instanceID, - ContainerID: containerID, - Port: config.Port, - Database: config.Database, - Username: config.Username, - Password: config.Password, - Version: config.Version, - DSN: fmt.Sprintf("postgres://%s:%s@localhost:%d/%s?sslmode=disable", config.Username, config.Password, config.Port, config.Database), - CreatedAt: time.Now(), - Status: "running", - } - - slog.Info("PostgreSQL container created and started successfully", - "instance_id", instanceID, - "container_id", containerID, - "port", config.Port) - - return instance, nil -} - -// StopPostgreSQLContainer stops a PostgreSQL container. -func (m *PostgreSQLManager) StopPostgreSQLContainer(ctx context.Context, containerID string) error { - return m.client.StopContainer(ctx, containerID) -} - -// RemovePostgreSQLContainer removes a PostgreSQL container. -func (m *PostgreSQLManager) RemovePostgreSQLContainer(ctx context.Context, containerID string) error { - return m.client.RemoveContainer(ctx, containerID) -} - -// GetPostgreSQLContainerStatus returns the status of a PostgreSQL container. -func (m *PostgreSQLManager) GetPostgreSQLContainerStatus(ctx context.Context, containerID string) (string, error) { - inspect, err := m.client.InspectContainer(ctx, containerID) - if err != nil { - return "", err - } - - if !inspect.State.Running { - return "stopped", nil - } - - // Check health status - if inspect.State.Health != nil { - switch inspect.State.Health.Status { - case "healthy": - return "running", nil - case "unhealthy": - return "unhealthy", nil - case "starting": - return "starting", nil - default: - return "unknown", nil - } - } - - return "running", nil -} - -// ListPostgreSQLContainers lists all PostgreSQL containers managed by this service. -func (m *PostgreSQLManager) ListPostgreSQLContainers(ctx context.Context) ([]container.Summary, error) { - filterArgs := filters.NewArgs() - filterArgs.Add("label", "dev-postgres-mcp.managed=true") - - containers, err := m.client.ListContainers(ctx, container.ListOptions{ - All: true, - Filters: filterArgs, - }) - if err != nil { - return nil, err - } - - return containers, nil -} - -// waitForHealthy waits for a container to become healthy. -func (m *PostgreSQLManager) waitForHealthy(ctx context.Context, containerID string, timeout time.Duration) error { - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - - slog.Info("Waiting for PostgreSQL container to become healthy", "container_id", containerID) - - for { - select { - case <-ctx.Done(): - return fmt.Errorf("timeout waiting for container to become healthy: %w", ctx.Err()) - case <-ticker.C: - inspect, err := m.client.InspectContainer(ctx, containerID) - if err != nil { - return fmt.Errorf("failed to inspect container: %w", err) - } - - if !inspect.State.Running { - return fmt.Errorf("container stopped unexpectedly") - } - - if inspect.State.Health != nil { - switch inspect.State.Health.Status { - case "healthy": - slog.Info("PostgreSQL container is healthy", "container_id", containerID) - return nil - case "unhealthy": - logs, _ := m.client.ContainerLogs(ctx, containerID, container.LogsOptions{ - ShowStdout: true, - ShowStderr: true, - Tail: "50", - }) - return fmt.Errorf("container became unhealthy, logs: %s", logs) - case "starting": - slog.Debug("PostgreSQL container is still starting", "container_id", containerID) - continue - } - } - - // If no health check is configured, assume it's ready after a short delay - // This shouldn't happen with our configuration, but it's a fallback - time.Sleep(5 * time.Second) - return nil - } - } -} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 92056c9..5ac5748 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -8,17 +8,17 @@ import ( "github.com/mark3labs/mcp-go/server" + "github.com/stokaro/dev-postgres-mcp/internal/database" "github.com/stokaro/dev-postgres-mcp/internal/docker" - "github.com/stokaro/dev-postgres-mcp/internal/postgres" ) -// Server represents the MCP server for PostgreSQL instance management. +// Server represents the MCP server for database instance management. type Server struct { - mcpServer *server.MCPServer - stdioServer *server.StdioServer - toolHandler *ToolHandler - manager *postgres.Manager - dockerMgr *docker.Manager + mcpServer *server.MCPServer + stdioServer *server.StdioServer + toolHandler *ToolHandler + unifiedManager *database.UnifiedManager + dockerMgr *docker.Manager } // ServerConfig holds configuration for the MCP server. @@ -45,11 +45,11 @@ func NewServer(config ServerConfig) (*Server, error) { return nil, fmt.Errorf("Docker daemon is not accessible: %w", err) } - // Create PostgreSQL manager - postgresManager := postgres.NewManager(dockerMgr) + // Create unified database manager + unifiedManager := database.NewUnifiedManager(dockerMgr) // Create tool handler - toolHandler := NewToolHandler(postgresManager) + toolHandler := NewToolHandler(unifiedManager) // Create MCP server mcpServer := server.NewMCPServer(config.Name, config.Version) @@ -64,17 +64,17 @@ func NewServer(config ServerConfig) (*Server, error) { stdioServer := server.NewStdioServer(mcpServer) return &Server{ - mcpServer: mcpServer, - stdioServer: stdioServer, - toolHandler: toolHandler, - manager: postgresManager, - dockerMgr: dockerMgr, + mcpServer: mcpServer, + stdioServer: stdioServer, + toolHandler: toolHandler, + unifiedManager: unifiedManager, + dockerMgr: dockerMgr, }, nil } // Start starts the MCP server. func (s *Server) Start(ctx context.Context) error { - slog.Info("Starting MCP server for PostgreSQL instance management") + slog.Info("Starting MCP server for database instance management") // Start the stdio server slog.Info("MCP server started, waiting for requests...") @@ -85,9 +85,9 @@ func (s *Server) Start(ctx context.Context) error { func (s *Server) Stop(ctx context.Context) error { slog.Info("Stopping MCP server") - // Cleanup all PostgreSQL instances - if err := s.manager.Cleanup(ctx); err != nil { - slog.Error("Failed to cleanup PostgreSQL instances", "error", err) + // Cleanup all database instances + if err := s.unifiedManager.Cleanup(ctx); err != nil { + slog.Error("Failed to cleanup database instances", "error", err) } // Close Docker manager @@ -99,9 +99,27 @@ func (s *Server) Stop(ctx context.Context) error { return nil } +// Close closes the MCP server and cleans up resources. +func (s *Server) Close() error { + ctx := context.Background() + + // Cleanup all database instances + if err := s.unifiedManager.Cleanup(ctx); err != nil { + slog.Error("Failed to cleanup database instances", "error", err) + } + + // Close Docker manager + if err := s.dockerMgr.Close(); err != nil { + slog.Error("Failed to close Docker manager", "error", err) + return err + } + + return nil +} + // GetInstanceCount returns the number of currently managed instances. func (s *Server) GetInstanceCount() int { - return s.manager.GetInstanceCount() + return s.unifiedManager.GetInstanceCount() } // GetServerInfo returns information about the MCP server. diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index c0e019d..d7f4ead 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -1,4 +1,4 @@ -// Package mcp provides Model Context Protocol integration for PostgreSQL instance management. +// Package mcp provides Model Context Protocol integration for database instance management. package mcp import ( @@ -9,17 +9,17 @@ import ( "github.com/mark3labs/mcp-go/mcp" - "github.com/stokaro/dev-postgres-mcp/internal/postgres" + "github.com/stokaro/dev-postgres-mcp/internal/database" "github.com/stokaro/dev-postgres-mcp/pkg/types" ) -// ToolHandler handles MCP tool calls for PostgreSQL instance management. +// ToolHandler handles MCP tool calls for database instance management. type ToolHandler struct { - manager *postgres.Manager + manager *database.UnifiedManager } -// NewToolHandler creates a new MCP tool handler. -func NewToolHandler(manager *postgres.Manager) *ToolHandler { +// NewToolHandler creates a new MCP tool handler with unified database support. +func NewToolHandler(manager *database.UnifiedManager) *ToolHandler { return &ToolHandler{ manager: manager, } @@ -28,27 +28,29 @@ func NewToolHandler(manager *postgres.Manager) *ToolHandler { // GetTools returns the list of available MCP tools. func (h *ToolHandler) GetTools() []mcp.Tool { return []mcp.Tool{ - mcp.NewTool("create_postgres_instance", - mcp.WithDescription("Create a new ephemeral PostgreSQL instance in a Docker container"), - mcp.WithString("version", mcp.Description("PostgreSQL version to use (default: 17)")), - mcp.WithString("database", mcp.Description("Database name to create (default: postgres)")), - mcp.WithString("username", mcp.Description("PostgreSQL username (default: postgres)")), - mcp.WithString("password", mcp.Description("PostgreSQL password (auto-generated if not provided)")), + mcp.NewTool("create_database_instance", + mcp.WithDescription("Create a new ephemeral database instance in a Docker container"), + mcp.WithString("type", mcp.Description("Database type: postgresql, mysql, or mariadb (default: postgresql)")), + mcp.WithString("version", mcp.Description("Database version to use (defaults vary by type)")), + mcp.WithString("database", mcp.Description("Database name to create (defaults vary by type)")), + mcp.WithString("username", mcp.Description("Database username (defaults vary by type)")), + mcp.WithString("password", mcp.Description("Database password (auto-generated if not provided)")), ), - mcp.NewTool("list_postgres_instances", - mcp.WithDescription("List all running PostgreSQL instances"), + mcp.NewTool("list_database_instances", + mcp.WithDescription("List all running database instances"), + mcp.WithString("type", mcp.Description("Filter by database type: postgresql, mysql, mariadb (optional)")), ), - mcp.NewTool("get_postgres_instance", - mcp.WithDescription("Get details of a specific PostgreSQL instance"), - mcp.WithString("instance_id", mcp.Description("The unique identifier of the PostgreSQL instance"), mcp.Required()), + mcp.NewTool("get_database_instance", + mcp.WithDescription("Get details of a specific database instance"), + mcp.WithString("instance_id", mcp.Description("The unique identifier of the database instance"), mcp.Required()), ), - mcp.NewTool("drop_postgres_instance", - mcp.WithDescription("Remove a PostgreSQL instance and all its data"), - mcp.WithString("instance_id", mcp.Description("The unique identifier of the PostgreSQL instance to remove"), mcp.Required()), + mcp.NewTool("drop_database_instance", + mcp.WithDescription("Remove a database instance and all its data"), + mcp.WithString("instance_id", mcp.Description("The unique identifier of the database instance to remove"), mcp.Required()), ), - mcp.NewTool("health_check_postgres", - mcp.WithDescription("Check the health status of a PostgreSQL instance"), - mcp.WithString("instance_id", mcp.Description("The unique identifier of the PostgreSQL instance to check"), mcp.Required()), + mcp.NewTool("health_check_database", + mcp.WithDescription("Check the health status of a database instance"), + mcp.WithString("instance_id", mcp.Description("The unique identifier of the database instance to check"), mcp.Required()), ), } } @@ -66,31 +68,34 @@ func (h *ToolHandler) HandleTool(ctx context.Context, request mcp.CallToolReques } switch name { - case "create_postgres_instance": - return h.handleCreateInstance(ctx, args) - case "list_postgres_instances": - return h.handleListInstances(ctx, args) - case "get_postgres_instance": - return h.handleGetInstance(ctx, args) - case "drop_postgres_instance": - return h.handleDropInstance(ctx, args) - case "health_check_postgres": - return h.handleHealthCheck(ctx, args) + case "create_database_instance": + return h.handleCreateDatabaseInstance(ctx, args) + case "list_database_instances": + return h.handleListDatabaseInstances(ctx, args) + case "get_database_instance": + return h.handleGetDatabaseInstance(ctx, args) + case "drop_database_instance": + return h.handleDropDatabaseInstance(ctx, args) + case "health_check_database": + return h.handleHealthCheckDatabase(ctx, args) default: return mcp.NewToolResultError(fmt.Sprintf("Unknown tool: %s", name)), nil } } -// handleCreateInstance handles the create_postgres_instance tool call. -func (h *ToolHandler) handleCreateInstance(ctx context.Context, arguments map[string]any) (*mcp.CallToolResult, error) { +// handleCreateDatabaseInstance handles the create_database_instance tool call. +func (h *ToolHandler) handleCreateDatabaseInstance(ctx context.Context, arguments map[string]any) (*mcp.CallToolResult, error) { opts := types.CreateInstanceOptions{} // Parse arguments + if dbType, ok := arguments["type"].(string); ok { + opts.Type = types.DatabaseType(dbType) + } if version, ok := arguments["version"].(string); ok { opts.Version = version } - if database, ok := arguments["database"].(string); ok { - opts.Database = database + if databaseName, ok := arguments["database"].(string); ok { + opts.Database = databaseName } if username, ok := arguments["username"].(string); ok { opts.Username = username @@ -102,12 +107,13 @@ func (h *ToolHandler) handleCreateInstance(ctx context.Context, arguments map[st // Create instance instance, err := h.manager.CreateInstance(ctx, opts) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to create PostgreSQL instance: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to create database instance: %v", err)), nil } // Format response response := map[string]any{ "instance_id": instance.ID, + "type": instance.Type, "container_id": instance.ContainerID, "port": instance.Port, "database": instance.Database, @@ -124,18 +130,31 @@ func (h *ToolHandler) handleCreateInstance(ctx context.Context, arguments map[st return mcp.NewToolResultError(fmt.Sprintf("Failed to format response: %v", err)), nil } - return mcp.NewToolResultText(fmt.Sprintf("PostgreSQL instance created successfully:\n\n```json\n%s\n```", string(responseJSON))), nil + return mcp.NewToolResultText(fmt.Sprintf("Database instance created successfully:\n\n```json\n%s\n```", string(responseJSON))), nil } -// handleListInstances handles the list_postgres_instances tool call. -func (h *ToolHandler) handleListInstances(ctx context.Context, _ map[string]any) (*mcp.CallToolResult, error) { - instances, err := h.manager.ListInstances(ctx) +// handleListDatabaseInstances handles the list_database_instances tool call. +func (h *ToolHandler) handleListDatabaseInstances(ctx context.Context, arguments map[string]any) (*mcp.CallToolResult, error) { + var instances []*types.DatabaseInstance + var err error + + // Check if filtering by type + if dbTypeStr, ok := arguments["type"].(string); ok && dbTypeStr != "" { + dbType := types.DatabaseType(dbTypeStr) + if !dbType.IsValid() { + return mcp.NewToolResultError(fmt.Sprintf("Invalid database type: %s", dbTypeStr)), nil + } + instances, err = h.manager.ListInstancesByType(ctx, dbType) + } else { + instances, err = h.manager.ListInstances(ctx) + } + if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to list PostgreSQL instances: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to list database instances: %v", err)), nil } if len(instances) == 0 { - return mcp.NewToolResultText("No PostgreSQL instances are currently running."), nil + return mcp.NewToolResultText("No database instances are currently running."), nil } // Format response @@ -149,19 +168,19 @@ func (h *ToolHandler) handleListInstances(ctx context.Context, _ map[string]any) return mcp.NewToolResultError(fmt.Sprintf("Failed to format response: %v", err)), nil } - return mcp.NewToolResultText(fmt.Sprintf("Found %d PostgreSQL instance(s):\n\n```json\n%s\n```", len(instances), string(responseJSON))), nil + return mcp.NewToolResultText(fmt.Sprintf("Database instances:\n\n```json\n%s\n```", string(responseJSON))), nil } -// handleGetInstance handles the get_postgres_instance tool call. -func (h *ToolHandler) handleGetInstance(ctx context.Context, arguments map[string]any) (*mcp.CallToolResult, error) { +// handleGetDatabaseInstance handles the get_database_instance tool call. +func (h *ToolHandler) handleGetDatabaseInstance(ctx context.Context, arguments map[string]any) (*mcp.CallToolResult, error) { instanceID, ok := arguments["instance_id"].(string) if !ok || instanceID == "" { - return mcp.NewToolResultError("instance_id is required and must be a string"), nil + return mcp.NewToolResultError("instance_id parameter is required"), nil } instance, err := h.manager.GetInstance(ctx, instanceID) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to get PostgreSQL instance: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to get database instance: %v", err)), nil } responseJSON, err := json.MarshalIndent(instance, "", " ") @@ -169,29 +188,47 @@ func (h *ToolHandler) handleGetInstance(ctx context.Context, arguments map[strin return mcp.NewToolResultError(fmt.Sprintf("Failed to format response: %v", err)), nil } - return mcp.NewToolResultText(fmt.Sprintf("PostgreSQL instance details:\n\n```json\n%s\n```", string(responseJSON))), nil + return mcp.NewToolResultText(fmt.Sprintf("Database instance details:\n\n```json\n%s\n```", string(responseJSON))), nil } -// handleDropInstance handles the drop_postgres_instance tool call. -func (h *ToolHandler) handleDropInstance(ctx context.Context, arguments map[string]any) (*mcp.CallToolResult, error) { +// handleDropDatabaseInstance handles the drop_database_instance tool call. +func (h *ToolHandler) handleDropDatabaseInstance(ctx context.Context, arguments map[string]any) (*mcp.CallToolResult, error) { instanceID, ok := arguments["instance_id"].(string) if !ok || instanceID == "" { - return mcp.NewToolResultError("instance_id is required and must be a string"), nil + return mcp.NewToolResultError("instance_id parameter is required"), nil + } + + // Get instance details before dropping for response + instance, err := h.manager.GetInstance(ctx, instanceID) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to find database instance: %v", err)), nil } - err := h.manager.DropInstance(ctx, instanceID) + err = h.manager.DropInstance(ctx, instanceID) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("Failed to drop PostgreSQL instance: %v", err)), nil + return mcp.NewToolResultError(fmt.Sprintf("Failed to drop database instance: %v", err)), nil + } + + response := map[string]any{ + "message": "Database instance dropped successfully", + "instance_id": instance.ID, + "type": instance.Type, + "port": instance.Port, + } + + responseJSON, err := json.MarshalIndent(response, "", " ") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Failed to format response: %v", err)), nil } - return mcp.NewToolResultText(fmt.Sprintf("PostgreSQL instance %s has been successfully dropped and all data has been removed.", instanceID)), nil + return mcp.NewToolResultText(fmt.Sprintf("Database instance dropped:\n\n```json\n%s\n```", string(responseJSON))), nil } -// handleHealthCheck handles the health_check_postgres tool call. -func (h *ToolHandler) handleHealthCheck(ctx context.Context, arguments map[string]any) (*mcp.CallToolResult, error) { +// handleHealthCheckDatabase handles the health_check_database tool call. +func (h *ToolHandler) handleHealthCheckDatabase(ctx context.Context, arguments map[string]any) (*mcp.CallToolResult, error) { instanceID, ok := arguments["instance_id"].(string) if !ok || instanceID == "" { - return mcp.NewToolResultError("instance_id is required and must be a string"), nil + return mcp.NewToolResultError("instance_id parameter is required"), nil } health, err := h.manager.HealthCheck(ctx, instanceID) diff --git a/internal/postgres/dsn.go b/internal/postgres/dsn.go deleted file mode 100644 index 3204583..0000000 --- a/internal/postgres/dsn.go +++ /dev/null @@ -1,185 +0,0 @@ -package postgres - -import ( - "fmt" - "net/url" - "strconv" - "strings" -) - -// DSNConfig holds configuration for generating a PostgreSQL DSN. -type DSNConfig struct { - Host string - Port int - Database string - Username string - Password string - SSLMode string - Options map[string]string -} - -// GenerateDSN creates a PostgreSQL connection string (DSN) from the given configuration. -func GenerateDSN(config DSNConfig) string { - // Set defaults - if config.Host == "" { - config.Host = "localhost" - } - if config.Port == 0 { - config.Port = 5432 - } - if config.Database == "" { - config.Database = "postgres" - } - if config.Username == "" { - config.Username = "postgres" - } - if config.SSLMode == "" { - config.SSLMode = "disable" - } - - // Build the DSN - dsn := fmt.Sprintf("postgres://%s:%s@%s:%d/%s", - url.QueryEscape(config.Username), - url.QueryEscape(config.Password), - config.Host, - config.Port, - url.QueryEscape(config.Database)) - - // Add query parameters - params := url.Values{} - params.Set("sslmode", config.SSLMode) - - // Add custom options - for key, value := range config.Options { - params.Set(key, value) - } - - if len(params) > 0 { - dsn += "?" + params.Encode() - } - - return dsn -} - -// ParseDSN parses a PostgreSQL DSN and returns the configuration. -func ParseDSN(dsn string) (*DSNConfig, error) { - u, err := url.Parse(dsn) - if err != nil { - return nil, fmt.Errorf("invalid DSN format: %w", err) - } - - if u.Scheme != "postgres" && u.Scheme != "postgresql" { - return nil, fmt.Errorf("unsupported scheme: %s", u.Scheme) - } - - config := &DSNConfig{ - Host: u.Hostname(), - Database: strings.TrimPrefix(u.Path, "/"), - Options: make(map[string]string), - } - - // Parse port - if u.Port() != "" { - port, err := strconv.Atoi(u.Port()) - if err != nil { - return nil, fmt.Errorf("invalid port: %w", err) - } - config.Port = port - } else { - config.Port = 5432 - } - - // Parse username and password - if u.User != nil { - config.Username = u.User.Username() - if password, ok := u.User.Password(); ok { - config.Password = password - } - } - - // Parse query parameters - for key, values := range u.Query() { - if len(values) > 0 { - switch key { - case "sslmode": - config.SSLMode = values[0] - default: - config.Options[key] = values[0] - } - } - } - - // Set default SSL mode if not specified - if config.SSLMode == "" { - config.SSLMode = "disable" - } - - return config, nil -} - -// ValidateDSN validates a PostgreSQL DSN format. -func ValidateDSN(dsn string) error { - _, err := ParseDSN(dsn) - return err -} - -// BuildDSNFromInstance creates a DSN from a PostgreSQL instance. -func BuildDSNFromInstance(host string, port int, database, username, password string) string { - return GenerateDSN(DSNConfig{ - Host: host, - Port: port, - Database: database, - Username: username, - Password: password, - SSLMode: "disable", // For local development instances - }) -} - -// BuildLocalDSN creates a DSN for a local PostgreSQL instance. -func BuildLocalDSN(port int, database, username, password string) string { - return BuildDSNFromInstance("localhost", port, database, username, password) -} - -// MaskPassword returns a DSN with the password masked for logging purposes. -func MaskPassword(dsn string) string { - config, err := ParseDSN(dsn) - if err != nil { - // If we can't parse it, just return the original (might not be a DSN) - return dsn - } - - // Replace password with asterisks - maskedConfig := *config - if maskedConfig.Password != "" { - maskedConfig.Password = "****" - } - - return GenerateDSN(maskedConfig) -} - -// GetDatabaseFromDSN extracts the database name from a DSN. -func GetDatabaseFromDSN(dsn string) (string, error) { - config, err := ParseDSN(dsn) - if err != nil { - return "", err - } - return config.Database, nil -} - -// GetHostPortFromDSN extracts the host and port from a DSN. -func GetHostPortFromDSN(dsn string) (string, int, error) { - config, err := ParseDSN(dsn) - if err != nil { - return "", 0, err - } - return config.Host, config.Port, nil -} - -// GetCredentialsFromDSN extracts the username and password from a DSN. -func GetCredentialsFromDSN(dsn string) (username, password string, err error) { - config, err := ParseDSN(dsn) - if err != nil { - return "", "", err - } - return config.Username, config.Password, nil -} diff --git a/internal/postgres/lifecycle.go b/internal/postgres/lifecycle.go deleted file mode 100644 index cd10cf5..0000000 --- a/internal/postgres/lifecycle.go +++ /dev/null @@ -1,252 +0,0 @@ -package postgres - -import ( - "context" - "fmt" - "log/slog" - "sync" - "time" - - "github.com/stokaro/dev-postgres-mcp/internal/docker" - "github.com/stokaro/dev-postgres-mcp/pkg/types" -) - -// LifecycleManager handles advanced lifecycle operations for PostgreSQL instances. -type LifecycleManager struct { - manager *Manager - healthChecker *docker.HealthChecker - mu sync.RWMutex - monitors map[string]*InstanceMonitor -} - -// InstanceMonitor monitors a single PostgreSQL instance. -type InstanceMonitor struct { - instanceID string - ctx context.Context - cancel context.CancelFunc - healthChecker *docker.HealthChecker - manager *Manager - interval time.Duration - lastHealth *docker.HealthCheck - mu sync.RWMutex -} - -// NewLifecycleManager creates a new lifecycle manager. -func NewLifecycleManager(manager *Manager, dockerClient *docker.Client) *LifecycleManager { - return &LifecycleManager{ - manager: manager, - healthChecker: docker.NewHealthChecker(dockerClient), - monitors: make(map[string]*InstanceMonitor), - } -} - -// StartMonitoring starts monitoring an instance for health and lifecycle events. -func (lm *LifecycleManager) StartMonitoring(instanceID string, interval time.Duration) error { - lm.mu.Lock() - defer lm.mu.Unlock() - - // Check if already monitoring - if _, exists := lm.monitors[instanceID]; exists { - return fmt.Errorf("instance %s is already being monitored", instanceID) - } - - // Verify instance exists - _, err := lm.manager.GetInstance(context.Background(), instanceID) - if err != nil { - return fmt.Errorf("instance %s not found: %w", instanceID, err) - } - - // Create monitor - ctx, cancel := context.WithCancel(context.Background()) - monitor := &InstanceMonitor{ - instanceID: instanceID, - ctx: ctx, - cancel: cancel, - healthChecker: lm.healthChecker, - manager: lm.manager, - interval: interval, - } - - lm.monitors[instanceID] = monitor - - // Start monitoring goroutine - go monitor.run() - - slog.Info("Started monitoring instance", "instance_id", instanceID, "interval", interval) - return nil -} - -// StopMonitoring stops monitoring an instance. -func (lm *LifecycleManager) StopMonitoring(instanceID string) { - lm.mu.Lock() - defer lm.mu.Unlock() - - monitor, exists := lm.monitors[instanceID] - if !exists { - return - } - - monitor.cancel() - delete(lm.monitors, instanceID) - - slog.Info("Stopped monitoring instance", "instance_id", instanceID) -} - -// GetInstanceHealth returns the last known health status of an instance. -func (lm *LifecycleManager) GetInstanceHealth(instanceID string) (*docker.HealthCheck, error) { - lm.mu.RLock() - defer lm.mu.RUnlock() - - monitor, exists := lm.monitors[instanceID] - if !exists { - return nil, fmt.Errorf("instance %s is not being monitored", instanceID) - } - - monitor.mu.RLock() - defer monitor.mu.RUnlock() - - if monitor.lastHealth == nil { - return nil, fmt.Errorf("no health data available for instance %s", instanceID) - } - - return monitor.lastHealth, nil -} - -// GetMonitoredInstances returns a list of all monitored instance IDs. -func (lm *LifecycleManager) GetMonitoredInstances() []string { - lm.mu.RLock() - defer lm.mu.RUnlock() - - instances := make([]string, 0, len(lm.monitors)) - for instanceID := range lm.monitors { - instances = append(instances, instanceID) - } - - return instances -} - -// Shutdown stops all monitoring and cleans up resources. -func (lm *LifecycleManager) Shutdown() { - lm.mu.Lock() - defer lm.mu.Unlock() - - slog.Info("Shutting down lifecycle manager", "monitored_instances", len(lm.monitors)) - - for instanceID, monitor := range lm.monitors { - monitor.cancel() - slog.Debug("Stopped monitoring during shutdown", "instance_id", instanceID) - } - - lm.monitors = make(map[string]*InstanceMonitor) -} - -// run is the main monitoring loop for an instance. -func (im *InstanceMonitor) run() { - ticker := time.NewTicker(im.interval) - defer ticker.Stop() - - slog.Debug("Starting instance monitor", "instance_id", im.instanceID, "interval", im.interval) - - for { - select { - case <-im.ctx.Done(): - slog.Debug("Instance monitor stopped", "instance_id", im.instanceID) - return - case <-ticker.C: - im.performHealthCheck() - } - } -} - -// performHealthCheck performs a health check on the monitored instance. -func (im *InstanceMonitor) performHealthCheck() { - // Get instance details - instance, err := im.manager.GetInstance(im.ctx, im.instanceID) - if err != nil { - slog.Warn("Failed to get instance for health check", "instance_id", im.instanceID, "error", err) - return - } - - // Perform health check - health, err := im.healthChecker.CheckPostgreSQLInstance(im.ctx, instance.ContainerID, instance.DSN) - if err != nil { - slog.Warn("Health check failed", "instance_id", im.instanceID, "error", err) - return - } - - // Update last health status - im.mu.Lock() - im.lastHealth = health - im.mu.Unlock() - - // Log health status changes - slog.Debug("Health check completed", - "instance_id", im.instanceID, - "status", health.Status, - "duration", health.Duration, - "message", health.Message) - - // Handle unhealthy instances - if health.Status == docker.HealthStatusUnhealthy { - slog.Warn("Instance is unhealthy", - "instance_id", im.instanceID, - "message", health.Message) - // Could implement automatic recovery here - } -} - -// CreateInstanceWithMonitoring creates a new instance and starts monitoring it. -func (lm *LifecycleManager) CreateInstanceWithMonitoring(ctx context.Context, opts types.CreateInstanceOptions, monitorInterval time.Duration) (*types.PostgreSQLInstance, error) { - // Create the instance - instance, err := lm.manager.CreateInstance(ctx, opts) - if err != nil { - return nil, err - } - - // Start monitoring - if err := lm.StartMonitoring(instance.ID, monitorInterval); err != nil { - slog.Warn("Failed to start monitoring for new instance", "instance_id", instance.ID, "error", err) - // Don't fail the creation, just log the warning - } - - return instance, nil -} - -// DropInstanceWithCleanup drops an instance and stops monitoring it. -func (lm *LifecycleManager) DropInstanceWithCleanup(ctx context.Context, instanceID string) error { - // Stop monitoring first - lm.StopMonitoring(instanceID) - - // Drop the instance - return lm.manager.DropInstance(ctx, instanceID) -} - -// RestartInstance restarts a PostgreSQL instance. -func (lm *LifecycleManager) RestartInstance(ctx context.Context, instanceID string) error { - instance, err := lm.manager.GetInstance(ctx, instanceID) - if err != nil { - return fmt.Errorf("instance %s not found: %w", instanceID, err) - } - - slog.Info("Restarting PostgreSQL instance", "instance_id", instanceID) - - // Stop the container - dockerManager := lm.manager.docker - if err := dockerManager.PostgreSQL().StopPostgreSQLContainer(ctx, instance.ContainerID); err != nil { - return fmt.Errorf("failed to stop container: %w", err) - } - - // Start the container - if err := dockerManager.GetClient().StartContainer(ctx, instance.ContainerID); err != nil { - return fmt.Errorf("failed to start container: %w", err) - } - - // Wait for it to become healthy - healthChecker := docker.NewHealthChecker(dockerManager.GetClient()) - if err := healthChecker.WaitForHealthy(ctx, instance.ContainerID, instance.DSN, 60*time.Second); err != nil { - return fmt.Errorf("instance failed to become healthy after restart: %w", err) - } - - slog.Info("PostgreSQL instance restarted successfully", "instance_id", instanceID) - return nil -} diff --git a/internal/postgres/manager.go b/internal/postgres/manager.go deleted file mode 100644 index 4099acd..0000000 --- a/internal/postgres/manager.go +++ /dev/null @@ -1,375 +0,0 @@ -// Package postgres provides PostgreSQL instance management functionality. -package postgres - -import ( - "context" - "crypto/rand" - "encoding/base64" - "fmt" - "log/slog" - "strconv" - "strings" - "sync" - "time" - - "github.com/google/uuid" - - "github.com/stokaro/dev-postgres-mcp/internal/docker" - "github.com/stokaro/dev-postgres-mcp/pkg/types" -) - -// Manager manages PostgreSQL instances. -type Manager struct { - mu sync.RWMutex - instances map[string]*types.PostgreSQLInstance - docker *docker.Manager -} - -// NewManager creates a new PostgreSQL instance manager. -func NewManager(dockerManager *docker.Manager) *Manager { - return &Manager{ - instances: make(map[string]*types.PostgreSQLInstance), - docker: dockerManager, - } -} - -// CreateInstance creates a new PostgreSQL instance. -func (m *Manager) CreateInstance(ctx context.Context, opts types.CreateInstanceOptions) (*types.PostgreSQLInstance, error) { - m.mu.Lock() - defer m.mu.Unlock() - - // Generate instance ID - instanceID := uuid.New().String() - - // Set defaults - if opts.Version == "" { - opts.Version = "17" - } - if opts.Database == "" { - opts.Database = "postgres" - } - if opts.Username == "" { - opts.Username = "postgres" - } - if opts.Password == "" { - var err error - opts.Password, err = generatePassword(16) - if err != nil { - return nil, fmt.Errorf("failed to generate password: %w", err) - } - } - - slog.Info("Creating PostgreSQL instance", - "instance_id", instanceID, - "version", opts.Version, - "database", opts.Database, - "username", opts.Username) - - // Allocate port - port, err := m.docker.AllocatePort(ctx) - if err != nil { - return nil, fmt.Errorf("failed to allocate port: %w", err) - } - - // Create container configuration - config := docker.PostgreSQLContainerConfig{ - Version: opts.Version, - Database: opts.Database, - Username: opts.Username, - Password: opts.Password, - Port: port, - } - - // Create and start container - instance, err := m.docker.PostgreSQL().CreatePostgreSQLContainer(ctx, instanceID, config) - if err != nil { - // Release port on failure - m.docker.ReleasePort(port) - return nil, fmt.Errorf("failed to create PostgreSQL container: %w", err) - } - - // Store instance - m.instances[instanceID] = instance - - slog.Info("PostgreSQL instance created successfully", - "instance_id", instanceID, - "port", port, - "dsn", instance.DSN) - - return instance, nil -} - -// ListInstances returns all PostgreSQL instances by discovering them from Docker containers. -func (m *Manager) ListInstances(ctx context.Context) ([]*types.PostgreSQLInstance, error) { - // Get all PostgreSQL containers managed by this service - containers, err := m.docker.PostgreSQL().ListPostgreSQLContainers(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list PostgreSQL containers: %w", err) - } - - instances := make([]*types.PostgreSQLInstance, 0, len(containers)) - for _, container := range containers { - // Extract instance information from container labels - labels := container.Labels - if labels == nil { - continue - } - - // Check if this is a managed container - if labels["dev-postgres-mcp.managed"] != "true" { - continue - } - - // Extract instance details from labels - instanceID := labels["dev-postgres-mcp.instance-id"] - database := labels["dev-postgres-mcp.database"] - username := labels["dev-postgres-mcp.username"] - version := labels["dev-postgres-mcp.version"] - portStr := labels["dev-postgres-mcp.port"] - createdAtStr := labels["dev-postgres-mcp.created-at"] - - if instanceID == "" || database == "" || username == "" || version == "" || portStr == "" { - slog.Warn("Container missing required labels", "container_id", container.ID) - continue - } - - port, err := strconv.Atoi(portStr) - if err != nil { - slog.Warn("Invalid port in container labels", "container_id", container.ID, "port", portStr) - continue - } - - var createdAt time.Time - if createdAtStr != "" { - if parsed, err := time.Parse(time.RFC3339, createdAtStr); err == nil { - createdAt = parsed - } - } - if createdAt.IsZero() { - createdAt = time.Unix(container.Created, 0) - } - - // Get container status - status := "unknown" - if len(container.Names) > 0 { - containerName := container.Names[0] - if containerName[0] == '/' { - containerName = containerName[1:] // Remove leading slash - } - _ = containerName // containerName is extracted but not used in current logic - if containerStatus, err := m.docker.PostgreSQL().GetPostgreSQLContainerStatus(ctx, container.ID); err == nil { - status = containerStatus - } - } - - // Build DSN (we need to extract password from environment or use a placeholder) - dsn := fmt.Sprintf("postgres://%s:****@localhost:%d/%s?sslmode=disable", username, port, database) - - instance := &types.PostgreSQLInstance{ - ID: instanceID, - ContainerID: container.ID, - Port: port, - Database: database, - Username: username, - Password: "****", // Don't expose password in listings - Version: version, - DSN: dsn, - Status: status, - CreatedAt: createdAt, - } - - instances = append(instances, instance) - } - - return instances, nil -} - -// GetInstance returns a specific PostgreSQL instance by ID (supports partial ID matching). -func (m *Manager) GetInstance(ctx context.Context, id string) (*types.PostgreSQLInstance, error) { - // First try exact match in-memory instances (for MCP server context) - m.mu.RLock() - if instance, exists := m.instances[id]; exists { - m.mu.RUnlock() - - // Update status from Docker - status, err := m.docker.PostgreSQL().GetPostgreSQLContainerStatus(ctx, instance.ContainerID) - if err != nil { - slog.Warn("Failed to get container status", "instance_id", id, "error", err) - status = "unknown" - } - - // Create a copy to avoid modifying the original - instanceCopy := *instance - instanceCopy.Status = status - return &instanceCopy, nil - } - m.mu.RUnlock() - - // If not found in memory, try partial ID matching (for CLI context or partial IDs) - return m.FindInstanceByPartialID(ctx, id) -} - -// FindInstanceByPartialID finds an instance by partial ID match (like Docker's container ID matching). -// Returns the instance if exactly one match is found, or an error if no matches or multiple matches. -func (m *Manager) FindInstanceByPartialID(ctx context.Context, partialID string) (*types.PostgreSQLInstance, error) { - if partialID == "" { - return nil, fmt.Errorf("instance ID cannot be empty") - } - - instances, err := m.ListInstances(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list instances: %w", err) - } - - var matches []*types.PostgreSQLInstance - for _, instance := range instances { - if strings.HasPrefix(instance.ID, partialID) { - matches = append(matches, instance) - } - } - - switch len(matches) { - case 0: - return nil, fmt.Errorf("no such instance: %s", partialID) - case 1: - return matches[0], nil - default: - var ids []string - for _, match := range matches { - ids = append(ids, match.ID[:12]) // Show first 12 chars like Docker - } - return nil, fmt.Errorf("multiple instances found with prefix %s: %s", partialID, strings.Join(ids, ", ")) - } -} - -// DropInstance removes a PostgreSQL instance. -func (m *Manager) DropInstance(ctx context.Context, id string) error { - // First try to find instance in memory (for MCP server context) - m.mu.Lock() - instance, exists := m.instances[id] - if exists { - // Remove from instances map first - delete(m.instances, id) - m.mu.Unlock() - - slog.Info("Dropping PostgreSQL instance", "instance_id", id, "container_id", instance.ContainerID) - - // Stop and remove container - if err := m.docker.PostgreSQL().StopPostgreSQLContainer(ctx, instance.ContainerID); err != nil { - slog.Warn("Failed to stop container", "instance_id", id, "error", err) - // Continue with removal even if stop fails - } - - if err := m.docker.PostgreSQL().RemovePostgreSQLContainer(ctx, instance.ContainerID); err != nil { - return fmt.Errorf("failed to remove container: %w", err) - } - - // Release port - m.docker.ReleasePort(instance.Port) - - slog.Info("PostgreSQL instance dropped successfully", "instance_id", id) - return nil - } - m.mu.Unlock() - - // If not found in memory, try to discover from Docker (for CLI context) - discoveredInstance, err := m.GetInstance(ctx, id) - if err != nil { - return fmt.Errorf("instance %s not found", id) - } - - slog.Info("Dropping PostgreSQL instance", "instance_id", id, "container_id", discoveredInstance.ContainerID) - - // Stop and remove container - if err := m.docker.PostgreSQL().StopPostgreSQLContainer(ctx, discoveredInstance.ContainerID); err != nil { - slog.Warn("Failed to stop container", "instance_id", id, "error", err) - // Continue with removal even if stop fails - } - - if err := m.docker.PostgreSQL().RemovePostgreSQLContainer(ctx, discoveredInstance.ContainerID); err != nil { - return fmt.Errorf("failed to remove container: %w", err) - } - - // Release port (best effort - may not work if port manager doesn't know about it) - m.docker.ReleasePort(discoveredInstance.Port) - - slog.Info("PostgreSQL instance dropped successfully", "instance_id", id) - return nil -} - -// HealthCheck performs a health check on a PostgreSQL instance (supports partial ID matching). -func (m *Manager) HealthCheck(ctx context.Context, id string) (*docker.HealthCheck, error) { - instance, err := m.GetInstance(ctx, id) - if err != nil { - return nil, err - } - - // Create health checker - healthChecker := docker.NewHealthChecker(m.docker.GetClient()) - - // Perform comprehensive health check - return healthChecker.CheckPostgreSQLInstance(ctx, instance.ContainerID, instance.DSN) -} - -// Cleanup removes all instances and cleans up resources. -func (m *Manager) Cleanup(ctx context.Context) error { - m.mu.Lock() - defer m.mu.Unlock() - - slog.Info("Cleaning up all PostgreSQL instances", "count", len(m.instances)) - - var errors []error - - for id, instance := range m.instances { - slog.Info("Cleaning up instance", "instance_id", id) - - // Stop and remove container - if err := m.docker.PostgreSQL().StopPostgreSQLContainer(ctx, instance.ContainerID); err != nil { - slog.Warn("Failed to stop container during cleanup", "instance_id", id, "error", err) - } - - if err := m.docker.PostgreSQL().RemovePostgreSQLContainer(ctx, instance.ContainerID); err != nil { - slog.Error("Failed to remove container during cleanup", "instance_id", id, "error", err) - errors = append(errors, fmt.Errorf("failed to remove container %s: %w", id, err)) - } - - // Release port - m.docker.ReleasePort(instance.Port) - } - - // Clear instances map - m.instances = make(map[string]*types.PostgreSQLInstance) - - if len(errors) > 0 { - return fmt.Errorf("cleanup completed with %d errors: %v", len(errors), errors) - } - - slog.Info("Cleanup completed successfully") - return nil -} - -// GetInstanceCount returns the number of currently managed instances. -func (m *Manager) GetInstanceCount() int { - m.mu.RLock() - defer m.mu.RUnlock() - - return len(m.instances) -} - -// generatePassword generates a secure random password. -func generatePassword(length int) (string, error) { - bytes := make([]byte, length) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - - // Use base64 encoding to ensure printable characters - password := base64.URLEncoding.EncodeToString(bytes) - - // Trim to desired length - if len(password) > length { - password = password[:length] - } - - return password, nil -} diff --git a/pkg/types/instance.go b/pkg/types/instance.go index 6a3848f..c571479 100644 --- a/pkg/types/instance.go +++ b/pkg/types/instance.go @@ -7,7 +7,124 @@ import ( "github.com/docker/docker/api/types/container" ) +// DatabaseType represents the type of database. +type DatabaseType string + +const ( + // DatabaseTypePostgreSQL represents PostgreSQL database. + DatabaseTypePostgreSQL DatabaseType = "postgresql" + // DatabaseTypeMySQL represents MySQL database. + DatabaseTypeMySQL DatabaseType = "mysql" + // DatabaseTypeMariaDB represents MariaDB database. + DatabaseTypeMariaDB DatabaseType = "mariadb" +) + +// String returns the string representation of the database type. +func (dt DatabaseType) String() string { + return string(dt) +} + +// IsValid checks if the database type is valid. +func (dt DatabaseType) IsValid() bool { + switch dt { + case DatabaseTypePostgreSQL, DatabaseTypeMySQL, DatabaseTypeMariaDB: + return true + default: + return false + } +} + +// DefaultPort returns the default port for the database type. +func (dt DatabaseType) DefaultPort() int { + switch dt { + case DatabaseTypePostgreSQL: + return 5432 + case DatabaseTypeMySQL: + return 3306 + case DatabaseTypeMariaDB: + return 3306 + default: + return 0 + } +} + +// DefaultVersion returns the default version for the database type. +func (dt DatabaseType) DefaultVersion() string { + switch dt { + case DatabaseTypePostgreSQL: + return "17" + case DatabaseTypeMySQL: + return "8.0" + case DatabaseTypeMariaDB: + return "11" + default: + return "" + } +} + +// DefaultDatabase returns the default database name for the database type. +func (dt DatabaseType) DefaultDatabase() string { + switch dt { + case DatabaseTypePostgreSQL: + return "postgres" + case DatabaseTypeMySQL, DatabaseTypeMariaDB: + return "mysql" + default: + return "" + } +} + +// DefaultUsername returns the default username for the database type. +func (dt DatabaseType) DefaultUsername() string { + switch dt { + case DatabaseTypePostgreSQL: + return "postgres" + case DatabaseTypeMySQL, DatabaseTypeMariaDB: + return "root" + default: + return "" + } +} + +// DatabaseInstance represents a generic database instance. +type DatabaseInstance struct { + // ID is the unique identifier for this instance. + ID string `json:"id"` + + // Type is the database type (postgresql, mysql, mariadb). + Type DatabaseType `json:"type"` + + // ContainerID is the Docker container ID. + ContainerID string `json:"container_id"` + + // Port is the host port where the database is accessible. + Port int `json:"port"` + + // Database is the name of the database. + Database string `json:"database"` + + // Username is the database username. + Username string `json:"username"` + + // Password is the database password. + Password string `json:"password"` + + // Version is the database version (e.g., "17", "8.0", "11"). + Version string `json:"version"` + + // DSN is the complete Data Source Name for connecting to the database. + DSN string `json:"dsn"` + + // CreatedAt is the timestamp when the instance was created. + CreatedAt time.Time `json:"created_at"` + + // Status represents the current status of the instance. + // Possible values: "starting", "running", "stopped", "unhealthy", "unknown" + Status string `json:"status"` +} + // PostgreSQLInstance represents a PostgreSQL database instance. +// Deprecated: Use DatabaseInstance instead. type PostgreSQLInstance struct { // ID is the unique identifier for this instance. ID string `json:"id"` @@ -41,18 +158,38 @@ type PostgreSQLInstance struct { Status string `json:"status"` } -// CreateInstanceOptions holds options for creating a new PostgreSQL instance. +// ToGeneric converts a PostgreSQLInstance to a generic DatabaseInstance. +func (p *PostgreSQLInstance) ToGeneric() *DatabaseInstance { + return &DatabaseInstance{ + ID: p.ID, + Type: DatabaseTypePostgreSQL, + ContainerID: p.ContainerID, + Port: p.Port, + Database: p.Database, + Username: p.Username, + Password: p.Password, + Version: p.Version, + DSN: p.DSN, + CreatedAt: p.CreatedAt, + Status: p.Status, + } +} + +// CreateInstanceOptions holds options for creating a new database instance. type CreateInstanceOptions struct { - // Version specifies the PostgreSQL version to use (default: "17"). + // Type specifies the database type (postgresql, mysql, mariadb). + Type DatabaseType `json:"type,omitempty"` + + // Version specifies the database version to use (defaults vary by type). Version string `json:"version,omitempty"` - // Database specifies the database name (default: "postgres"). + // Database specifies the database name (defaults vary by type). Database string `json:"database,omitempty"` - // Username specifies the PostgreSQL username (default: "postgres"). + // Username specifies the database username (defaults vary by type). Username string `json:"username,omitempty"` - // Password specifies the PostgreSQL password (auto-generated if empty). + // Password specifies the database password (auto-generated if empty). Password string `json:"password,omitempty"` } diff --git a/pkg/types/interfaces.go b/pkg/types/interfaces.go new file mode 100644 index 0000000..9f107ba --- /dev/null +++ b/pkg/types/interfaces.go @@ -0,0 +1,70 @@ +// Package types defines common interfaces used throughout the application. +package types + +import ( + "context" +) + +// DatabaseManager defines the interface for managing database instances. +type DatabaseManager interface { + // CreateInstance creates a new database instance. + CreateInstance(ctx context.Context, opts CreateInstanceOptions) (*DatabaseInstance, error) + + // ListInstances returns all database instances of this type. + ListInstances(ctx context.Context) ([]*DatabaseInstance, error) + + // GetInstance returns a specific database instance by ID. + GetInstance(ctx context.Context, id string) (*DatabaseInstance, error) + + // DropInstance removes a database instance. + DropInstance(ctx context.Context, id string) error + + // HealthCheck performs a health check on a database instance. + HealthCheck(ctx context.Context, id string) (*HealthCheckResult, error) + + // Cleanup removes all instances managed by this manager. + Cleanup(ctx context.Context) error + + // GetInstanceCount returns the number of instances managed by this manager. + GetInstanceCount() int + + // GetDatabaseType returns the database type this manager handles. + GetDatabaseType() DatabaseType +} + +// HealthCheckResult represents the result of a health check. +type HealthCheckResult struct { + // Status indicates the health status. + Status HealthStatus `json:"status"` + + // Message provides additional information about the health status. + Message string `json:"message"` + + // Duration is how long the health check took. + Duration string `json:"duration"` + + // Timestamp is when the health check was performed. + Timestamp string `json:"timestamp"` +} + +// HealthStatus represents the health status of a database instance. +type HealthStatus string + +const ( + // HealthStatusHealthy indicates the instance is healthy. + HealthStatusHealthy HealthStatus = "healthy" + + // HealthStatusUnhealthy indicates the instance is unhealthy. + HealthStatusUnhealthy HealthStatus = "unhealthy" + + // HealthStatusStarting indicates the instance is starting up. + HealthStatusStarting HealthStatus = "starting" + + // HealthStatusUnknown indicates the health status is unknown. + HealthStatusUnknown HealthStatus = "unknown" +) + +// String returns the string representation of the health status. +func (hs HealthStatus) String() string { + return string(hs) +} diff --git a/pkg/types/utils.go b/pkg/types/utils.go new file mode 100644 index 0000000..f7379d7 --- /dev/null +++ b/pkg/types/utils.go @@ -0,0 +1,111 @@ +// Package types provides utility functions for working with database types. +package types + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "strings" + + "github.com/google/uuid" +) + +// GenerateInstanceID generates a unique instance ID without dashes. +// The ID is alphanumeric and URL-safe. +func GenerateInstanceID() string { + // Generate a UUID and remove dashes + id := uuid.New().String() + return strings.ReplaceAll(id, "-", "") +} + +// GeneratePassword generates a random password of the specified length. +func GeneratePassword(length int) (string, error) { + if length <= 0 { + return "", fmt.Errorf("password length must be positive") + } + + // Generate random bytes + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + + // Encode to base64 and trim to desired length + password := base64.URLEncoding.EncodeToString(bytes) + if len(password) > length { + password = password[:length] + } + + return password, nil +} + +// ValidateCreateInstanceOptions validates and sets defaults for CreateInstanceOptions. +func ValidateCreateInstanceOptions(opts *CreateInstanceOptions) error { + // Validate database type + if opts.Type == "" { + opts.Type = DatabaseTypePostgreSQL // Default to PostgreSQL for backward compatibility + } + + if !opts.Type.IsValid() { + return fmt.Errorf("invalid database type: %s", opts.Type) + } + + // Set defaults based on database type + if opts.Version == "" { + opts.Version = opts.Type.DefaultVersion() + } + + if opts.Database == "" { + opts.Database = opts.Type.DefaultDatabase() + } + + if opts.Username == "" { + opts.Username = opts.Type.DefaultUsername() + } + + if opts.Password == "" { + var err error + opts.Password, err = GeneratePassword(16) + if err != nil { + return fmt.Errorf("failed to generate password: %w", err) + } + } + + return nil +} + +// BuildDSN builds a Data Source Name (DSN) for the given database instance. +func BuildDSN(instance *DatabaseInstance) string { + switch instance.Type { + case DatabaseTypePostgreSQL: + return fmt.Sprintf("postgres://%s:%s@localhost:%d/%s?sslmode=disable", + instance.Username, instance.Password, instance.Port, instance.Database) + case DatabaseTypeMySQL: + return fmt.Sprintf("%s:%s@tcp(localhost:%d)/%s", + instance.Username, instance.Password, instance.Port, instance.Database) + case DatabaseTypeMariaDB: + return fmt.Sprintf("%s:%s@tcp(localhost:%d)/%s", + instance.Username, instance.Password, instance.Port, instance.Database) + default: + return "" + } +} + +// GetDockerImage returns the Docker image name for the given database type and version. +func GetDockerImage(dbType DatabaseType, version string) string { + switch dbType { + case DatabaseTypePostgreSQL: + return fmt.Sprintf("postgres:%s", version) + case DatabaseTypeMySQL: + return fmt.Sprintf("mysql:%s", version) + case DatabaseTypeMariaDB: + return fmt.Sprintf("mariadb:%s", version) + default: + return "" + } +} + +// GetContainerName generates a container name for the given instance ID and database type. +func GetContainerName(instanceID string, dbType DatabaseType) string { + return fmt.Sprintf("dev-%s-mcp-%s", dbType.String(), instanceID) +} diff --git a/test/e2e/basic_workflow_test.go b/test/e2e/basic_workflow_test.go index 83f3e0f..f1c70a8 100644 --- a/test/e2e/basic_workflow_test.go +++ b/test/e2e/basic_workflow_test.go @@ -3,7 +3,9 @@ package e2e_test import ( "context" "encoding/json" + "os" "os/exec" + "runtime" "strings" "testing" "time" @@ -13,6 +15,40 @@ import ( "github.com/stokaro/dev-postgres-mcp/internal/docker" ) +// getBinaryName returns the appropriate binary name for the current OS +func getBinaryName() string { + name := "dev-postgres-mcp-e2e" + if runtime.GOOS == "windows" { + name += ".exe" + } + // Add path prefix for current directory + return "../../" + name +} + +// buildTestBinary builds the CLI binary for testing +func buildTestBinary(c *qt.C) string { + // Get the base name without path for building + baseName := "dev-postgres-mcp-e2e" + if runtime.GOOS == "windows" { + baseName += ".exe" + } + + buildCmd := exec.Command("go", "build", "-o", "../../"+baseName, "../../cmd/dev-postgres-mcp") + if err := buildCmd.Run(); err != nil { + c.Skip("Failed to build CLI binary:", err) + } + + // Return the path-prefixed name for execution + return getBinaryName() +} + +// cleanupTestBinary removes the test binary in a cross-platform way +func cleanupTestBinary(binaryName string) { + // Remove the path prefix for cleanup + baseName := strings.TrimPrefix(binaryName, "../../") + os.Remove("../../" + baseName) // Cross-platform file removal +} + // TestBasicWorkflow tests the complete end-to-end workflow of the application func TestBasicWorkflow(t *testing.T) { c := qt.New(t) @@ -30,14 +66,12 @@ func TestBasicWorkflow(t *testing.T) { } // Build the CLI binary first - buildCmd := exec.Command("go", "build", "-o", "../../dev-postgres-mcp-e2e.exe", "../../cmd/dev-postgres-mcp") - if err := buildCmd.Run(); err != nil { - c.Skip("Failed to build CLI binary:", err) - } + binaryName := buildTestBinary(c) + defer cleanupTestBinary(binaryName) c.Run("complete_workflow", func(c *qt.C) { // Step 1: Verify no instances are running initially - cmd := exec.Command("../../dev-postgres-mcp-e2e.exe", "postgres", "list", "--format", "json") + cmd := exec.Command(binaryName, "database", "list", "--format", "json") output, err := cmd.CombinedOutput() c.Assert(err, qt.IsNil) @@ -49,47 +83,47 @@ func TestBasicWorkflow(t *testing.T) { c.Assert(len(instances), qt.Equals, 0) // Step 2: Test version command - cmd = exec.Command("../../dev-postgres-mcp-e2e.exe", "version") + cmd = exec.Command(binaryName, "version") output, err = cmd.CombinedOutput() c.Assert(err, qt.IsNil) c.Assert(string(output), qt.Contains, "dev-postgres-mcp") c.Assert(string(output), qt.Contains, "Go version:") // Step 3: Test help command - cmd = exec.Command("../../dev-postgres-mcp-e2e.exe", "--help") + cmd = exec.Command(binaryName, "--help") output, err = cmd.CombinedOutput() c.Assert(err, qt.IsNil) outputStr := string(output) c.Assert(outputStr, qt.Contains, "dev-postgres-mcp") c.Assert(outputStr, qt.Contains, "mcp") - c.Assert(outputStr, qt.Contains, "postgres") + c.Assert(outputStr, qt.Contains, "database") // Step 4: Test MCP serve help - cmd = exec.Command("../../dev-postgres-mcp-e2e.exe", "mcp", "serve", "--help") + cmd = exec.Command(binaryName, "mcp", "serve", "--help") output, err = cmd.CombinedOutput() c.Assert(err, qt.IsNil) - c.Assert(string(output), qt.Contains, "Start the MCP server") + c.Assert(string(output), qt.Contains, "Start the Model Context Protocol server") - // Step 5: Test postgres commands help - cmd = exec.Command("../../dev-postgres-mcp-e2e.exe", "postgres", "--help") + // Step 5: Test database commands help + cmd = exec.Command(binaryName, "database", "--help") output, err = cmd.CombinedOutput() c.Assert(err, qt.IsNil) - c.Assert(string(output), qt.Contains, "managing PostgreSQL instances") + c.Assert(string(output), qt.Contains, "Commands for managing database instances") // Step 6: Test invalid command - cmd = exec.Command("../../dev-postgres-mcp-e2e.exe", "invalid-command") + cmd = exec.Command(binaryName, "invalid-command") output, err = cmd.CombinedOutput() c.Assert(err, qt.Not(qt.IsNil)) c.Assert(string(output), qt.Contains, "unknown command") // Step 7: Test invalid flag - cmd = exec.Command("../../dev-postgres-mcp-e2e.exe", "postgres", "list", "--invalid-flag") + cmd = exec.Command(binaryName, "database", "list", "--invalid-flag") output, err = cmd.CombinedOutput() c.Assert(err, qt.Not(qt.IsNil)) c.Assert(string(output), qt.Contains, "unknown flag") // Step 8: Test drop non-existent instance - cmd = exec.Command("../../dev-postgres-mcp-e2e.exe", "postgres", "drop", "nonexistent-id") + cmd = exec.Command(binaryName, "database", "drop", "nonexistent-id") output, err = cmd.CombinedOutput() c.Assert(err, qt.Not(qt.IsNil)) c.Assert(string(output), qt.Contains, "not found") @@ -100,7 +134,7 @@ func TestBasicWorkflow(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - cmd := exec.CommandContext(ctx, "../../dev-postgres-mcp-e2e.exe", "mcp", "serve") + cmd := exec.CommandContext(ctx, binaryName, "mcp", "serve") err := cmd.Start() c.Assert(err, qt.IsNil) @@ -119,13 +153,11 @@ func TestCLIErrorHandling(t *testing.T) { c := qt.New(t) // Build the CLI binary first - buildCmd := exec.Command("go", "build", "-o", "../../dev-postgres-mcp-e2e.exe", "../../cmd/dev-postgres-mcp") - if err := buildCmd.Run(); err != nil { - c.Skip("Failed to build CLI binary:", err) - } + binaryName := buildTestBinary(c) + defer cleanupTestBinary(binaryName) c.Run("missing_arguments", func(c *qt.C) { - cmd := exec.Command("../../dev-postgres-mcp-e2e.exe", "postgres", "drop") + cmd := exec.Command(binaryName, "database", "drop") output, err := cmd.CombinedOutput() c.Assert(err, qt.Not(qt.IsNil)) outputStr := string(output) @@ -133,7 +165,7 @@ func TestCLIErrorHandling(t *testing.T) { }) c.Run("too_many_arguments", func(c *qt.C) { - cmd := exec.Command("../../dev-postgres-mcp-e2e.exe", "postgres", "drop", "id1", "id2") + cmd := exec.Command(binaryName, "database", "drop", "id1", "id2") output, err := cmd.CombinedOutput() c.Assert(err, qt.Not(qt.IsNil)) outputStr := string(output) @@ -141,7 +173,7 @@ func TestCLIErrorHandling(t *testing.T) { }) c.Run("invalid_format", func(c *qt.C) { - cmd := exec.Command("../../dev-postgres-mcp-e2e.exe", "postgres", "list", "--format", "invalid") + cmd := exec.Command(binaryName, "database", "list", "--format", "invalid") output, err := cmd.CombinedOutput() c.Assert(err, qt.Not(qt.IsNil)) outputStr := string(output) @@ -166,13 +198,11 @@ func TestOutputFormats(t *testing.T) { } // Build the CLI binary first - buildCmd := exec.Command("go", "build", "-o", "../../dev-postgres-mcp-e2e.exe", "../../cmd/dev-postgres-mcp") - if err := buildCmd.Run(); err != nil { - c.Skip("Failed to build CLI binary:", err) - } + binaryName := buildTestBinary(c) + defer cleanupTestBinary(binaryName) c.Run("table_format", func(c *qt.C) { - cmd := exec.Command("../../dev-postgres-mcp-e2e.exe", "postgres", "list", "--format", "table") + cmd := exec.Command(binaryName, "database", "list", "--format", "table") output, err := cmd.CombinedOutput() c.Assert(err, qt.IsNil) outputStr := string(output) @@ -181,7 +211,7 @@ func TestOutputFormats(t *testing.T) { }) c.Run("json_format", func(c *qt.C) { - cmd := exec.Command("../../dev-postgres-mcp-e2e.exe", "postgres", "list", "--format", "json") + cmd := exec.Command(binaryName, "database", "list", "--format", "json") output, err := cmd.CombinedOutput() c.Assert(err, qt.IsNil) diff --git a/test/e2e/dev-postgres-mcp-e2e b/test/e2e/dev-postgres-mcp-e2e deleted file mode 100644 index acdf738..0000000 Binary files a/test/e2e/dev-postgres-mcp-e2e and /dev/null differ diff --git a/test/integration/cli_test.go b/test/integration/cli_test.go index ed4f457..26f5d48 100644 --- a/test/integration/cli_test.go +++ b/test/integration/cli_test.go @@ -2,7 +2,9 @@ package integration_test import ( "context" + "os" "os/exec" + "runtime" "strings" "testing" "time" @@ -12,6 +14,40 @@ import ( "github.com/stokaro/dev-postgres-mcp/internal/docker" ) +// getBinaryName returns the appropriate binary name for the current OS +func getBinaryName() string { + name := "dev-postgres-mcp-test" + if runtime.GOOS == "windows" { + name += ".exe" + } + // Add path prefix for current directory + return "./" + name +} + +// buildTestBinary builds the CLI binary for testing +func buildTestBinary(c *qt.C) string { + // Get the base name without path for building + baseName := "dev-postgres-mcp-test" + if runtime.GOOS == "windows" { + baseName += ".exe" + } + + buildCmd := exec.Command("go", "build", "-o", baseName, "../../cmd/dev-postgres-mcp") + if err := buildCmd.Run(); err != nil { + c.Skip("Failed to build CLI binary:", err) + } + + // Return the path-prefixed name for execution + return getBinaryName() +} + +// cleanupTestBinary removes the test binary in a cross-platform way +func cleanupTestBinary(binaryName string) { + // Remove the path prefix for cleanup + baseName := strings.TrimPrefix(binaryName, "./") + os.Remove(baseName) // Cross-platform file removal +} + func TestCLICommands(t *testing.T) { c := qt.New(t) @@ -28,37 +64,35 @@ func TestCLICommands(t *testing.T) { } // Build the CLI binary first - buildCmd := exec.Command("go", "build", "-o", "dev-postgres-mcp-test", "../../cmd/dev-postgres-mcp") - if err := buildCmd.Run(); err != nil { - c.Skip("Failed to build CLI binary:", err) - } + binaryName := buildTestBinary(c) + defer cleanupTestBinary(binaryName) c.Run("version_command", func(c *qt.C) { - cmd := exec.Command("./dev-postgres-mcp-test", "version") + cmd := exec.Command(binaryName, "version") output, err := cmd.CombinedOutput() c.Assert(err, qt.IsNil) c.Assert(string(output), qt.Contains, "dev-postgres-mcp") }) c.Run("help_command", func(c *qt.C) { - cmd := exec.Command("./dev-postgres-mcp-test", "--help") + cmd := exec.Command(binaryName, "--help") output, err := cmd.CombinedOutput() c.Assert(err, qt.IsNil) outputStr := string(output) c.Assert(outputStr, qt.Contains, "dev-postgres-mcp") c.Assert(outputStr, qt.Contains, "mcp") - c.Assert(outputStr, qt.Contains, "postgres") + c.Assert(outputStr, qt.Contains, "database") }) - c.Run("postgres_list_empty", func(c *qt.C) { - cmd := exec.Command("./dev-postgres-mcp-test", "postgres", "list", "--start-port", "21000", "--end-port", "21010") + c.Run("database_list_empty", func(c *qt.C) { + cmd := exec.Command(binaryName, "database", "list", "--start-port", "21000", "--end-port", "21010") output, err := cmd.CombinedOutput() c.Assert(err, qt.IsNil) - c.Assert(string(output), qt.Contains, "No PostgreSQL instances are currently running") + c.Assert(string(output), qt.Contains, "No database instances are currently running") }) - c.Run("postgres_list_json_format", func(c *qt.C) { - cmd := exec.Command("./dev-postgres-mcp-test", "postgres", "list", "--format", "json", "--start-port", "21000", "--end-port", "21010") + c.Run("database_list_json_format", func(c *qt.C) { + cmd := exec.Command(binaryName, "database", "list", "--format", "json", "--start-port", "21000", "--end-port", "21010") output, err := cmd.CombinedOutput() c.Assert(err, qt.IsNil) outputStr := string(output) @@ -66,26 +100,22 @@ func TestCLICommands(t *testing.T) { c.Assert(outputStr, qt.Contains, "instances") }) - c.Run("postgres_drop_nonexistent", func(c *qt.C) { - cmd := exec.Command("./dev-postgres-mcp-test", "postgres", "drop", "nonexistent-id", "--force", "--start-port", "21000", "--end-port", "21010") + c.Run("database_drop_nonexistent", func(c *qt.C) { + cmd := exec.Command(binaryName, "database", "drop", "nonexistent-id", "--force", "--start-port", "21000", "--end-port", "21010") output, err := cmd.CombinedOutput() c.Assert(err, qt.IsNotNil) c.Assert(string(output), qt.Contains, "not found") }) c.Run("mcp_serve_help", func(c *qt.C) { - cmd := exec.Command("./dev-postgres-mcp-test", "mcp", "serve", "--help") + cmd := exec.Command(binaryName, "mcp", "serve", "--help") output, err := cmd.CombinedOutput() c.Assert(err, qt.IsNil) outputStr := string(output) - c.Assert(outputStr, qt.Contains, "Start the MCP server") + c.Assert(outputStr, qt.Contains, "Start the Model Context Protocol server") c.Assert(outputStr, qt.Contains, "start-port") c.Assert(outputStr, qt.Contains, "end-port") - c.Assert(outputStr, qt.Contains, "log-level") }) - - // Clean up test binary - exec.Command("rm", "-f", "dev-postgres-mcp-test").Run() } func TestCLIFlags(t *testing.T) { @@ -104,13 +134,11 @@ func TestCLIFlags(t *testing.T) { } // Build the CLI binary first - buildCmd := exec.Command("go", "build", "-o", "dev-postgres-mcp-test", "../../cmd/dev-postgres-mcp") - if err := buildCmd.Run(); err != nil { - c.Skip("Failed to build CLI binary:", err) - } + binaryName := buildTestBinary(c) + defer cleanupTestBinary(binaryName) c.Run("invalid_format_flag", func(c *qt.C) { - cmd := exec.Command("./dev-postgres-mcp-test", "postgres", "list", "--format", "invalid", "--start-port", "21100", "--end-port", "21110") + cmd := exec.Command(binaryName, "database", "list", "--format", "invalid", "--start-port", "21100", "--end-port", "21110") output, err := cmd.CombinedOutput() c.Assert(err, qt.IsNotNil) c.Assert(string(output), qt.Contains, "unsupported format") @@ -118,7 +146,7 @@ func TestCLIFlags(t *testing.T) { c.Run("invalid_port_range", func(c *qt.C) { // Test with start port higher than end port - cmd := exec.Command("./dev-postgres-mcp-test", "postgres", "list", "--start-port", "21110", "--end-port", "21100") + cmd := exec.Command(binaryName, "database", "list", "--start-port", "21110", "--end-port", "21100") output, err := cmd.CombinedOutput() // This should either fail or handle gracefully if err == nil { @@ -127,8 +155,8 @@ func TestCLIFlags(t *testing.T) { } }) - c.Run("postgres_drop_missing_args", func(c *qt.C) { - cmd := exec.Command("./dev-postgres-mcp-test", "postgres", "drop") + c.Run("database_drop_missing_args", func(c *qt.C) { + cmd := exec.Command(binaryName, "database", "drop") output, err := cmd.CombinedOutput() c.Assert(err, qt.IsNotNil) outputStr := string(output) @@ -136,8 +164,8 @@ func TestCLIFlags(t *testing.T) { c.Assert(outputStr, qt.Contains, "accepts 1 arg") }) - c.Run("postgres_drop_too_many_args", func(c *qt.C) { - cmd := exec.Command("./dev-postgres-mcp-test", "postgres", "drop", "id1", "id2") + c.Run("database_drop_too_many_args", func(c *qt.C) { + cmd := exec.Command(binaryName, "database", "drop", "id1", "id2") output, err := cmd.CombinedOutput() c.Assert(err, qt.IsNotNil) // Should indicate too many arguments @@ -153,13 +181,11 @@ func TestCLIErrorHandling(t *testing.T) { c := qt.New(t) // Build the CLI binary first - buildCmd := exec.Command("go", "build", "-o", "dev-postgres-mcp-test", "../../cmd/dev-postgres-mcp") - if err := buildCmd.Run(); err != nil { - c.Skip("Failed to build CLI binary:", err) - } + binaryName := buildTestBinary(c) + defer cleanupTestBinary(binaryName) c.Run("unknown_command", func(c *qt.C) { - cmd := exec.Command("./dev-postgres-mcp-test", "unknown") + cmd := exec.Command(binaryName, "unknown") output, err := cmd.CombinedOutput() c.Assert(err, qt.IsNotNil) outputStr := string(output) @@ -167,7 +193,7 @@ func TestCLIErrorHandling(t *testing.T) { }) c.Run("unknown_subcommand", func(c *qt.C) { - cmd := exec.Command("./dev-postgres-mcp-test", "postgres", "unknown") + cmd := exec.Command(binaryName, "database", "unknown") output, err := cmd.CombinedOutput() // Cobra shows help for unknown subcommands instead of erroring c.Assert(err, qt.IsNil) @@ -176,7 +202,7 @@ func TestCLIErrorHandling(t *testing.T) { }) c.Run("invalid_flag", func(c *qt.C) { - cmd := exec.Command("./dev-postgres-mcp-test", "postgres", "list", "--invalid-flag") + cmd := exec.Command(binaryName, "database", "list", "--invalid-flag") output, err := cmd.CombinedOutput() c.Assert(err, qt.IsNotNil) outputStr := string(output) @@ -186,7 +212,7 @@ func TestCLIErrorHandling(t *testing.T) { // Test Docker unavailable scenario by using an invalid port range that would fail c.Run("docker_unavailable", func(c *qt.C) { // Use a port range that's likely to cause issues or be unavailable - cmd := exec.Command("./dev-postgres-mcp-test", "postgres", "list", "--start-port", "1", "--end-port", "2") + cmd := exec.Command(binaryName, "database", "list", "--start-port", "1", "--end-port", "2") output, err := cmd.CombinedOutput() // This might fail due to permission issues with low ports if err != nil { @@ -204,17 +230,15 @@ func TestCLITimeout(t *testing.T) { c := qt.New(t) // Build the CLI binary first - buildCmd := exec.Command("go", "build", "-o", "dev-postgres-mcp-test", "../../cmd/dev-postgres-mcp") - if err := buildCmd.Run(); err != nil { - c.Skip("Failed to build CLI binary:", err) - } + binaryName := buildTestBinary(c) + defer cleanupTestBinary(binaryName) c.Run("command_timeout", func(c *qt.C) { // Test that commands don't hang indefinitely ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - cmd := exec.CommandContext(ctx, "./dev-postgres-mcp-test", "postgres", "list", "--start-port", "22000", "--end-port", "22010") + cmd := exec.CommandContext(ctx, binaryName, "database", "list", "--start-port", "22000", "--end-port", "22010") output, err := cmd.CombinedOutput() // Command should complete within timeout @@ -226,9 +250,6 @@ func TestCLITimeout(t *testing.T) { c.Assert(outputStr, qt.Not(qt.Equals), "") } }) - - // Clean up test binary - exec.Command("rm", "-f", "dev-postgres-mcp-test").Run() } func TestCLIOutputFormats(t *testing.T) { @@ -247,30 +268,29 @@ func TestCLIOutputFormats(t *testing.T) { } // Build the CLI binary first - buildCmd := exec.Command("go", "build", "-o", "dev-postgres-mcp-test", "../../cmd/dev-postgres-mcp") - if err := buildCmd.Run(); err != nil { - c.Skip("Failed to build CLI binary:", err) - } + binaryName := buildTestBinary(c) + defer cleanupTestBinary(binaryName) c.Run("table_format_structure", func(c *qt.C) { - cmd := exec.Command("./dev-postgres-mcp-test", "postgres", "list", "--format", "table", "--start-port", "22100", "--end-port", "22110") + cmd := exec.Command(binaryName, "database", "list", "--format", "table", "--start-port", "22100", "--end-port", "22110") output, err := cmd.CombinedOutput() c.Assert(err, qt.IsNil) outputStr := string(output) - if strings.Contains(outputStr, "No PostgreSQL instances") { + if strings.Contains(outputStr, "No database instances") { // Empty case is fine return } // Should have table headers if there are instances - c.Assert(outputStr, qt.Contains, "INSTANCE ID") + c.Assert(outputStr, qt.Contains, "ID") c.Assert(outputStr, qt.Contains, "CONTAINER ID") + c.Assert(outputStr, qt.Contains, "TYPE") c.Assert(outputStr, qt.Contains, "PORT") }) c.Run("json_format_structure", func(c *qt.C) { - cmd := exec.Command("./dev-postgres-mcp-test", "postgres", "list", "--format", "json", "--start-port", "22100", "--end-port", "22110") + cmd := exec.Command(binaryName, "database", "list", "--format", "json", "--start-port", "22100", "--end-port", "22110") output, err := cmd.CombinedOutput() c.Assert(err, qt.IsNil) outputStr := string(output) @@ -281,7 +301,4 @@ func TestCLIOutputFormats(t *testing.T) { c.Assert(outputStr, qt.Contains, `[`) c.Assert(outputStr, qt.Contains, `]`) }) - - // Clean up test binary - exec.Command("rm", "-f", "dev-postgres-mcp-test").Run() } diff --git a/test/integration/database_integration_test.go b/test/integration/database_integration_test.go new file mode 100644 index 0000000..81f1099 --- /dev/null +++ b/test/integration/database_integration_test.go @@ -0,0 +1,288 @@ +package integration_test + +import ( + "context" + "testing" + + qt "github.com/frankban/quicktest" + + "github.com/stokaro/dev-postgres-mcp/internal/database" + "github.com/stokaro/dev-postgres-mcp/internal/docker" + "github.com/stokaro/dev-postgres-mcp/pkg/types" +) + +func TestUnifiedDatabaseManager(t *testing.T) { + c := qt.New(t) + + // Skip if Docker is not available + dockerMgr, err := docker.NewManager(20100, 20200) + if err != nil { + c.Skip("Docker not available:", err) + } + defer dockerMgr.Close() + + ctx := context.Background() + if err := dockerMgr.Ping(ctx); err != nil { + c.Skip("Docker daemon not accessible:", err) + } + + // Create unified database manager + unifiedManager := database.NewUnifiedManager(dockerMgr) + + t.Run("Create PostgreSQL instance", func(t *testing.T) { + c := qt.New(t) + + opts := types.CreateInstanceOptions{ + Type: types.DatabaseTypePostgreSQL, + Version: "17", + Database: "testdb", + Username: "testuser", + Password: "testpass", + } + + instance, err := unifiedManager.CreateInstance(ctx, opts) + c.Assert(err, qt.IsNil) + c.Assert(instance, qt.IsNotNil) + c.Assert(instance.Type, qt.Equals, types.DatabaseTypePostgreSQL) + c.Assert(instance.Version, qt.Equals, "17") + c.Assert(instance.Database, qt.Equals, "testdb") + c.Assert(instance.Username, qt.Equals, "testuser") + c.Assert(instance.Password, qt.Equals, "testpass") + c.Assert(instance.Port, qt.Not(qt.Equals), 0) + c.Assert(instance.DSN, qt.Not(qt.Equals), "") + + // Clean up + defer func() { + err := unifiedManager.DropInstance(ctx, instance.ID) + c.Assert(err, qt.IsNil) + }() + + // Test health check + health, err := unifiedManager.HealthCheck(ctx, instance.ID) + c.Assert(err, qt.IsNil) + c.Assert(health, qt.IsNotNil) + c.Assert(health.Status, qt.Not(qt.Equals), types.HealthStatusUnknown) + }) + + t.Run("Create MySQL instance", func(t *testing.T) { + c := qt.New(t) + + opts := types.CreateInstanceOptions{ + Type: types.DatabaseTypeMySQL, + Version: "8.0", + Database: "testdb", + Username: "root", + Password: "testpass", + } + + instance, err := unifiedManager.CreateInstance(ctx, opts) + c.Assert(err, qt.IsNil) + c.Assert(instance, qt.IsNotNil) + c.Assert(instance.Type, qt.Equals, types.DatabaseTypeMySQL) + c.Assert(instance.Version, qt.Equals, "8.0") + c.Assert(instance.Database, qt.Equals, "testdb") + c.Assert(instance.Username, qt.Equals, "root") + c.Assert(instance.Password, qt.Equals, "testpass") + c.Assert(instance.Port, qt.Not(qt.Equals), 0) + c.Assert(instance.DSN, qt.Not(qt.Equals), "") + + // Clean up + defer func() { + err := unifiedManager.DropInstance(ctx, instance.ID) + c.Assert(err, qt.IsNil) + }() + + // Test health check + health, err := unifiedManager.HealthCheck(ctx, instance.ID) + c.Assert(err, qt.IsNil) + c.Assert(health, qt.IsNotNil) + c.Assert(health.Status, qt.Not(qt.Equals), types.HealthStatusUnknown) + }) + + t.Run("Create MariaDB instance", func(t *testing.T) { + c := qt.New(t) + + opts := types.CreateInstanceOptions{ + Type: types.DatabaseTypeMariaDB, + Version: "11", + Database: "testdb", + Username: "root", + Password: "testpass", + } + + instance, err := unifiedManager.CreateInstance(ctx, opts) + c.Assert(err, qt.IsNil) + c.Assert(instance, qt.IsNotNil) + c.Assert(instance.Type, qt.Equals, types.DatabaseTypeMariaDB) + c.Assert(instance.Version, qt.Equals, "11") + c.Assert(instance.Database, qt.Equals, "testdb") + c.Assert(instance.Username, qt.Equals, "root") + c.Assert(instance.Password, qt.Equals, "testpass") + c.Assert(instance.Port, qt.Not(qt.Equals), 0) + c.Assert(instance.DSN, qt.Not(qt.Equals), "") + + // Clean up + defer func() { + err := unifiedManager.DropInstance(ctx, instance.ID) + c.Assert(err, qt.IsNil) + }() + + // Test health check + health, err := unifiedManager.HealthCheck(ctx, instance.ID) + c.Assert(err, qt.IsNil) + c.Assert(health, qt.IsNotNil) + c.Assert(health.Status, qt.Not(qt.Equals), types.HealthStatusUnknown) + }) + + t.Run("List instances by type", func(t *testing.T) { + c := qt.New(t) + + // Create instances of different types + pgOpts := types.CreateInstanceOptions{ + Type: types.DatabaseTypePostgreSQL, + Database: "pgtest", + } + pgInstance, err := unifiedManager.CreateInstance(ctx, pgOpts) + c.Assert(err, qt.IsNil) + defer unifiedManager.DropInstance(ctx, pgInstance.ID) + + mysqlOpts := types.CreateInstanceOptions{ + Type: types.DatabaseTypeMySQL, + Database: "mysqltest", + } + mysqlInstance, err := unifiedManager.CreateInstance(ctx, mysqlOpts) + c.Assert(err, qt.IsNil) + defer unifiedManager.DropInstance(ctx, mysqlInstance.ID) + + // List all instances + allInstances, err := unifiedManager.ListInstances(ctx) + c.Assert(err, qt.IsNil) + c.Assert(len(allInstances), qt.Equals, 2) + + // List PostgreSQL instances only + pgInstances, err := unifiedManager.ListInstancesByType(ctx, types.DatabaseTypePostgreSQL) + c.Assert(err, qt.IsNil) + c.Assert(len(pgInstances), qt.Equals, 1) + c.Assert(pgInstances[0].Type, qt.Equals, types.DatabaseTypePostgreSQL) + + // List MySQL instances only + mysqlInstances, err := unifiedManager.ListInstancesByType(ctx, types.DatabaseTypeMySQL) + c.Assert(err, qt.IsNil) + c.Assert(len(mysqlInstances), qt.Equals, 1) + c.Assert(mysqlInstances[0].Type, qt.Equals, types.DatabaseTypeMySQL) + + // List MariaDB instances (should be empty) + mariadbInstances, err := unifiedManager.ListInstancesByType(ctx, types.DatabaseTypeMariaDB) + c.Assert(err, qt.IsNil) + c.Assert(len(mariadbInstances), qt.Equals, 0) + }) + + t.Run("Get instance by ID", func(t *testing.T) { + c := qt.New(t) + + opts := types.CreateInstanceOptions{ + Type: types.DatabaseTypePostgreSQL, + Database: "gettest", + } + instance, err := unifiedManager.CreateInstance(ctx, opts) + c.Assert(err, qt.IsNil) + defer unifiedManager.DropInstance(ctx, instance.ID) + + // Get by full ID + retrieved, err := unifiedManager.GetInstance(ctx, instance.ID) + c.Assert(err, qt.IsNil) + c.Assert(retrieved.ID, qt.Equals, instance.ID) + c.Assert(retrieved.Type, qt.Equals, instance.Type) + + // Get by partial ID (first 8 characters) + partialID := instance.ID[:8] + retrieved, err = unifiedManager.GetInstance(ctx, partialID) + c.Assert(err, qt.IsNil) + c.Assert(retrieved.ID, qt.Equals, instance.ID) + }) + + t.Run("Drop instance", func(t *testing.T) { + c := qt.New(t) + + opts := types.CreateInstanceOptions{ + Type: types.DatabaseTypePostgreSQL, + Database: "droptest", + } + instance, err := unifiedManager.CreateInstance(ctx, opts) + c.Assert(err, qt.IsNil) + + // Verify instance exists + _, err = unifiedManager.GetInstance(ctx, instance.ID) + c.Assert(err, qt.IsNil) + + // Drop instance + err = unifiedManager.DropInstance(ctx, instance.ID) + c.Assert(err, qt.IsNil) + + // Verify instance no longer exists + _, err = unifiedManager.GetInstance(ctx, instance.ID) + c.Assert(err, qt.IsNotNil) + }) + + t.Run("Cleanup all instances", func(t *testing.T) { + c := qt.New(t) + + // Create multiple instances + for i := 0; i < 3; i++ { + opts := types.CreateInstanceOptions{ + Type: types.DatabaseTypePostgreSQL, + Database: "cleanuptest", + } + _, err := unifiedManager.CreateInstance(ctx, opts) + c.Assert(err, qt.IsNil) + } + + // Verify instances exist + instances, err := unifiedManager.ListInstances(ctx) + c.Assert(err, qt.IsNil) + c.Assert(len(instances), qt.Equals, 3) + + // Cleanup all + err = unifiedManager.Cleanup(ctx) + c.Assert(err, qt.IsNil) + + // Verify no instances remain + instances, err = unifiedManager.ListInstances(ctx) + c.Assert(err, qt.IsNil) + c.Assert(len(instances), qt.Equals, 0) + }) + + t.Run("Invalid database type", func(t *testing.T) { + c := qt.New(t) + + opts := types.CreateInstanceOptions{ + Type: "invalid", + } + _, err := unifiedManager.CreateInstance(ctx, opts) + c.Assert(err, qt.IsNotNil) + }) + + t.Run("Instance count", func(t *testing.T) { + c := qt.New(t) + + // Start with clean state + err := unifiedManager.Cleanup(ctx) + c.Assert(err, qt.IsNil) + + // Verify count is 0 + count := unifiedManager.GetInstanceCount() + c.Assert(count, qt.Equals, 0) + + // Create an instance + opts := types.CreateInstanceOptions{ + Type: types.DatabaseTypePostgreSQL, + } + instance, err := unifiedManager.CreateInstance(ctx, opts) + c.Assert(err, qt.IsNil) + defer unifiedManager.DropInstance(ctx, instance.ID) + + // Verify count is 1 + count = unifiedManager.GetInstanceCount() + c.Assert(count, qt.Equals, 1) + }) +} diff --git a/test/integration/docker_integration_test.go b/test/integration/docker_integration_test.go deleted file mode 100644 index 4c7e5ce..0000000 --- a/test/integration/docker_integration_test.go +++ /dev/null @@ -1,305 +0,0 @@ -package integration_test - -import ( - "context" - "database/sql" - "fmt" - "testing" - "time" - - qt "github.com/frankban/quicktest" - "github.com/testcontainers/testcontainers-go" - "github.com/testcontainers/testcontainers-go/wait" - - "github.com/stokaro/dev-postgres-mcp/internal/docker" - "github.com/stokaro/dev-postgres-mcp/internal/postgres" - "github.com/stokaro/dev-postgres-mcp/pkg/types" -) - -func TestPostgreSQLInstanceIntegration(t *testing.T) { - c := qt.New(t) - - // Skip if Docker is not available - dockerMgr, err := docker.NewManager(23000, 23010) - if err != nil { - c.Skip("Docker not available:", err) - } - defer dockerMgr.Close() - - ctx := context.Background() - if err := dockerMgr.Ping(ctx); err != nil { - c.Skip("Docker daemon not accessible:", err) - } - - // Create PostgreSQL manager - postgresManager := postgres.NewManager(dockerMgr) - - c.Run("create_and_connect_to_instance", func(c *qt.C) { - // Create instance - opts := types.CreateInstanceOptions{ - Version: "17", - Database: "testdb", - Username: "testuser", - Password: "testpass", - } - - instance, err := postgresManager.CreateInstance(ctx, opts) - c.Assert(err, qt.IsNil) - c.Assert(instance, qt.IsNotNil) - c.Assert(instance.ID, qt.Not(qt.Equals), "") - c.Assert(instance.Port >= 23000, qt.IsTrue) - c.Assert(instance.Port <= 23010, qt.IsTrue) - c.Assert(instance.DSN, qt.Contains, "testuser") - c.Assert(instance.DSN, qt.Contains, "testpass") - c.Assert(instance.DSN, qt.Contains, "testdb") - - // Clean up - defer func() { - err := postgresManager.DropInstance(ctx, instance.ID) - c.Assert(err, qt.IsNil) - }() - - // Wait a moment for the instance to be fully ready - time.Sleep(5 * time.Second) - - // Test database connection - db, err := sql.Open("postgres", instance.DSN) - c.Assert(err, qt.IsNil) - defer db.Close() - - // Set connection timeout - db.SetConnMaxLifetime(10 * time.Second) - db.SetMaxOpenConns(1) - - // Test connection - err = db.Ping() - c.Assert(err, qt.IsNil) - - // Test basic query - var result int - err = db.QueryRow("SELECT 1").Scan(&result) - c.Assert(err, qt.IsNil) - c.Assert(result, qt.Equals, 1) - - // Test database creation - _, err = db.Exec("CREATE TABLE test_table (id SERIAL PRIMARY KEY, name TEXT)") - c.Assert(err, qt.IsNil) - - // Test data insertion - _, err = db.Exec("INSERT INTO test_table (name) VALUES ($1)", "test_value") - c.Assert(err, qt.IsNil) - - // Test data retrieval - var name string - err = db.QueryRow("SELECT name FROM test_table WHERE id = 1").Scan(&name) - c.Assert(err, qt.IsNil) - c.Assert(name, qt.Equals, "test_value") - }) - - c.Run("multiple_instances", func(c *qt.C) { - // Create multiple instances - var instances []*types.PostgreSQLInstance - - for i := 0; i < 3; i++ { - opts := types.CreateInstanceOptions{ - Version: "17", - Database: fmt.Sprintf("testdb%d", i), - Username: "testuser", - Password: "testpass", - } - - instance, err := postgresManager.CreateInstance(ctx, opts) - c.Assert(err, qt.IsNil) - instances = append(instances, instance) - } - - // Clean up - defer func() { - for _, instance := range instances { - _ = postgresManager.DropInstance(ctx, instance.ID) - } - }() - - // Verify all instances are different - for i := 0; i < len(instances); i++ { - for j := i + 1; j < len(instances); j++ { - c.Assert(instances[i].ID, qt.Not(qt.Equals), instances[j].ID) - c.Assert(instances[i].Port, qt.Not(qt.Equals), instances[j].Port) - c.Assert(instances[i].ContainerID, qt.Not(qt.Equals), instances[j].ContainerID) - } - } - - // List instances - listedInstances, err := postgresManager.ListInstances(ctx) - c.Assert(err, qt.IsNil) - c.Assert(len(listedInstances) >= 3, qt.IsTrue) - - // Verify all created instances are in the list - instanceIDs := make(map[string]bool) - for _, instance := range listedInstances { - instanceIDs[instance.ID] = true - } - - for _, instance := range instances { - c.Assert(instanceIDs[instance.ID], qt.IsTrue) - } - }) - - c.Run("instance_lifecycle", func(c *qt.C) { - // Create instance - opts := types.CreateInstanceOptions{ - Version: "17", - Database: "lifecycledb", - Username: "lifecycleuser", - Password: "lifecyclepass", - } - - instance, err := postgresManager.CreateInstance(ctx, opts) - c.Assert(err, qt.IsNil) - - // Get instance - retrievedInstance, err := postgresManager.GetInstance(ctx, instance.ID) - c.Assert(err, qt.IsNil) - c.Assert(retrievedInstance.ID, qt.Equals, instance.ID) - c.Assert(retrievedInstance.Port, qt.Equals, instance.Port) - c.Assert(retrievedInstance.Database, qt.Equals, instance.Database) - - // Health check - health, err := postgresManager.HealthCheck(ctx, instance.ID) - c.Assert(err, qt.IsNil) - c.Assert(health, qt.IsNotNil) - - // Drop instance - err = postgresManager.DropInstance(ctx, instance.ID) - c.Assert(err, qt.IsNil) - - // Verify instance is gone - _, err = postgresManager.GetInstance(ctx, instance.ID) - c.Assert(err, qt.IsNotNil) - }) -} - -func TestPostgreSQLVersions(t *testing.T) { - c := qt.New(t) - - // Skip if Docker is not available - dockerMgr, err := docker.NewManager(23100, 23110) - if err != nil { - c.Skip("Docker not available:", err) - } - defer dockerMgr.Close() - - ctx := context.Background() - if err := dockerMgr.Ping(ctx); err != nil { - c.Skip("Docker daemon not accessible:", err) - } - - postgresManager := postgres.NewManager(dockerMgr) - - versions := []string{"17", "16", "15"} - - for _, version := range versions { - c.Run(fmt.Sprintf("postgresql_%s", version), func(c *qt.C) { - opts := types.CreateInstanceOptions{ - Version: version, - Database: "versiontest", - Username: "testuser", - Password: "testpass", - } - - instance, err := postgresManager.CreateInstance(ctx, opts) - c.Assert(err, qt.IsNil) - c.Assert(instance.Version, qt.Equals, version) - - // Clean up - defer func() { - _ = postgresManager.DropInstance(ctx, instance.ID) - }() - - // Wait for instance to be ready - time.Sleep(10 * time.Second) - - // Test connection - db, err := sql.Open("postgres", instance.DSN) - c.Assert(err, qt.IsNil) - defer db.Close() - - db.SetConnMaxLifetime(10 * time.Second) - db.SetMaxOpenConns(1) - - err = db.Ping() - c.Assert(err, qt.IsNil) - - // Verify PostgreSQL version - var pgVersion string - err = db.QueryRow("SELECT version()").Scan(&pgVersion) - c.Assert(err, qt.IsNil) - c.Assert(pgVersion, qt.Contains, "PostgreSQL") - c.Assert(pgVersion, qt.Contains, version) - }) - } -} - -func TestDockerContainerManagement(t *testing.T) { - c := qt.New(t) - - // Skip if Docker is not available - dockerMgr, err := docker.NewManager(23200, 23210) - if err != nil { - c.Skip("Docker not available:", err) - } - defer dockerMgr.Close() - - ctx := context.Background() - if err := dockerMgr.Ping(ctx); err != nil { - c.Skip("Docker daemon not accessible:", err) - } - - c.Run("container_with_testcontainers", func(c *qt.C) { - // Use testcontainers to create a PostgreSQL container for comparison - req := testcontainers.ContainerRequest{ - Image: "postgres:17", - ExposedPorts: []string{"5432/tcp"}, - Env: map[string]string{ - "POSTGRES_DB": "testdb", - "POSTGRES_USER": "testuser", - "POSTGRES_PASSWORD": "testpass", - }, - WaitingFor: wait.ForListeningPort("5432/tcp").WithStartupTimeout(60 * time.Second), - } - - container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) - if err != nil { - c.Skip("Failed to start testcontainer:", err) - } - defer container.Terminate(ctx) - - // Get container details - host, err := container.Host(ctx) - c.Assert(err, qt.IsNil) - - port, err := container.MappedPort(ctx, "5432") - c.Assert(err, qt.IsNil) - - // Test connection - dsn := fmt.Sprintf("postgres://testuser:testpass@%s:%s/testdb?sslmode=disable", host, port.Port()) - db, err := sql.Open("postgres", dsn) - c.Assert(err, qt.IsNil) - defer db.Close() - - db.SetConnMaxLifetime(10 * time.Second) - db.SetMaxOpenConns(1) - - err = db.Ping() - c.Assert(err, qt.IsNil) - - // Test basic functionality - var result int - err = db.QueryRow("SELECT 1").Scan(&result) - c.Assert(err, qt.IsNil) - c.Assert(result, qt.Equals, 1) - }) -} diff --git a/test/integration/mcp_test.go b/test/integration/mcp_test.go deleted file mode 100644 index 6aa68a6..0000000 --- a/test/integration/mcp_test.go +++ /dev/null @@ -1,299 +0,0 @@ -package integration_test - -import ( - "context" - "testing" - "time" - - qt "github.com/frankban/quicktest" - mcplib "github.com/mark3labs/mcp-go/mcp" - - "github.com/stokaro/dev-postgres-mcp/internal/docker" - "github.com/stokaro/dev-postgres-mcp/internal/mcp" - "github.com/stokaro/dev-postgres-mcp/internal/postgres" - "github.com/stokaro/dev-postgres-mcp/pkg/types" -) - -// Helper function to call MCP tools -func callTool(ctx context.Context, toolHandler *mcp.ToolHandler, name string, arguments map[string]any) (*mcplib.CallToolResult, error) { - request := mcplib.CallToolRequest{ - Params: mcplib.CallToolParams{ - Name: name, - Arguments: arguments, - }, - } - return toolHandler.HandleTool(ctx, request) -} - -// Helper function to get text content from result -func getTextContent(result *mcplib.CallToolResult, index int) string { - if len(result.Content) <= index { - return "" - } - if textContent, ok := mcplib.AsTextContent(result.Content[index]); ok { - return textContent.Text - } - return "" -} - -func TestMCPToolHandler(t *testing.T) { - c := qt.New(t) - - // Skip if Docker is not available - dockerMgr, err := docker.NewManager(20000, 20010) - if err != nil { - c.Skip("Docker not available:", err) - } - defer dockerMgr.Close() - - ctx := context.Background() - if err := dockerMgr.Ping(ctx); err != nil { - c.Skip("Docker daemon not accessible:", err) - } - - // Create PostgreSQL manager and tool handler - postgresManager := postgres.NewManager(dockerMgr) - toolHandler := mcp.NewToolHandler(postgresManager) - - // Test create_postgres_instance tool - c.Run("create_postgres_instance", func(c *qt.C) { - arguments := map[string]any{ - "version": "17", - "database": "testdb", - "username": "testuser", - "password": "testpass", - } - - result, err := callTool(ctx, toolHandler, "create_postgres_instance", arguments) - c.Assert(err, qt.IsNil) - c.Assert(result.IsError, qt.IsFalse) - c.Assert(len(result.Content) > 0, qt.IsTrue) - textContent := getTextContent(result, 0) - c.Assert(textContent, qt.Contains, "PostgreSQL instance created successfully") - - // Clean up - we'll get the instance ID from the list - defer func() { - instances, _ := postgresManager.ListInstances(ctx) - for _, instance := range instances { - _ = postgresManager.DropInstance(ctx, instance.ID) - } - }() - }) - - c.Run("list_postgres_instances", func(c *qt.C) { - // First create an instance - opts := types.CreateInstanceOptions{ - Version: "17", - Database: "testdb", - Username: "testuser", - Password: "testpass", - } - - instance, err := postgresManager.CreateInstance(ctx, opts) - c.Assert(err, qt.IsNil) - defer postgresManager.DropInstance(ctx, instance.ID) - - // Test list tool - result, err := callTool(ctx, toolHandler, "list_postgres_instances", map[string]any{}) - c.Assert(err, qt.IsNil) - c.Assert(result.IsError, qt.IsFalse) - c.Assert(len(result.Content) > 0, qt.IsTrue) - textContent := getTextContent(result, 0) - c.Assert(textContent, qt.Contains, "Found 1 PostgreSQL instance") - }) - - c.Run("get_postgres_instance", func(c *qt.C) { - // First create an instance - opts := types.CreateInstanceOptions{ - Version: "17", - Database: "testdb", - Username: "testuser", - Password: "testpass", - } - - instance, err := postgresManager.CreateInstance(ctx, opts) - c.Assert(err, qt.IsNil) - defer postgresManager.DropInstance(ctx, instance.ID) - - // Test get tool - arguments := map[string]any{ - "instance_id": instance.ID, - } - - result, err := callTool(ctx, toolHandler, "get_postgres_instance", arguments) - c.Assert(err, qt.IsNil) - c.Assert(result.IsError, qt.IsFalse) - c.Assert(len(result.Content) > 0, qt.IsTrue) - textContent := getTextContent(result, 0) - c.Assert(textContent, qt.Contains, "PostgreSQL instance details") - c.Assert(textContent, qt.Contains, instance.ID) - }) - - c.Run("health_check_postgres", func(c *qt.C) { - // First create an instance - opts := types.CreateInstanceOptions{ - Version: "17", - Database: "testdb", - Username: "testuser", - Password: "testpass", - } - - instance, err := postgresManager.CreateInstance(ctx, opts) - c.Assert(err, qt.IsNil) - defer postgresManager.DropInstance(ctx, instance.ID) - - // Wait a moment for the instance to be fully ready - time.Sleep(2 * time.Second) - - // Test health check tool - arguments := map[string]any{ - "instance_id": instance.ID, - } - - result, err := callTool(ctx, toolHandler, "health_check_postgres", arguments) - c.Assert(err, qt.IsNil) - c.Assert(result.IsError, qt.IsFalse) - c.Assert(len(result.Content) > 0, qt.IsTrue) - textContent := getTextContent(result, 0) - c.Assert(textContent, qt.Contains, "Health check results") - }) - - c.Run("drop_postgres_instance", func(c *qt.C) { - // First create an instance - opts := types.CreateInstanceOptions{ - Version: "17", - Database: "testdb", - Username: "testuser", - Password: "testpass", - } - - instance, err := postgresManager.CreateInstance(ctx, opts) - c.Assert(err, qt.IsNil) - - // Test drop tool - arguments := map[string]any{ - "instance_id": instance.ID, - } - - result, err := callTool(ctx, toolHandler, "drop_postgres_instance", arguments) - c.Assert(err, qt.IsNil) - c.Assert(result.IsError, qt.IsFalse) - c.Assert(len(result.Content) > 0, qt.IsTrue) - textContent := getTextContent(result, 0) - c.Assert(textContent, qt.Contains, "successfully dropped") - - // Verify instance is gone - _, err = postgresManager.GetInstance(ctx, instance.ID) - c.Assert(err, qt.IsNotNil) - }) -} - -func TestMCPToolHandlerErrors(t *testing.T) { - c := qt.New(t) - - // Skip if Docker is not available - dockerMgr, err := docker.NewManager(20100, 20110) - if err != nil { - c.Skip("Docker not available:", err) - } - defer dockerMgr.Close() - - ctx := context.Background() - if err := dockerMgr.Ping(ctx); err != nil { - c.Skip("Docker daemon not accessible:", err) - } - - postgresManager := postgres.NewManager(dockerMgr) - toolHandler := mcp.NewToolHandler(postgresManager) - - c.Run("unknown_tool", func(c *qt.C) { - result, err := callTool(ctx, toolHandler, "unknown_tool", map[string]any{}) - c.Assert(err, qt.IsNil) - c.Assert(result.IsError, qt.IsTrue) - c.Assert(getTextContent(result, 0), qt.Contains, "Unknown tool") - }) - - c.Run("get_instance_missing_id", func(c *qt.C) { - result, err := callTool(ctx, toolHandler, "get_postgres_instance", map[string]any{}) - c.Assert(err, qt.IsNil) - c.Assert(result.IsError, qt.IsTrue) - c.Assert(getTextContent(result, 0), qt.Contains, "instance_id is required") - }) - - c.Run("get_instance_not_found", func(c *qt.C) { - arguments := map[string]any{ - "instance_id": "nonexistent-id", - } - - result, err := callTool(ctx, toolHandler, "get_postgres_instance", arguments) - c.Assert(err, qt.IsNil) - c.Assert(result.IsError, qt.IsTrue) - c.Assert(getTextContent(result, 0), qt.Contains, "Failed to get PostgreSQL instance") - }) - - c.Run("drop_instance_missing_id", func(c *qt.C) { - result, err := callTool(ctx, toolHandler, "drop_postgres_instance", map[string]any{}) - c.Assert(err, qt.IsNil) - c.Assert(result.IsError, qt.IsTrue) - c.Assert(getTextContent(result, 0), qt.Contains, "instance_id is required") - }) - - c.Run("drop_instance_not_found", func(c *qt.C) { - arguments := map[string]any{ - "instance_id": "nonexistent-id", - } - - result, err := callTool(ctx, toolHandler, "drop_postgres_instance", arguments) - c.Assert(err, qt.IsNil) - c.Assert(result.IsError, qt.IsTrue) - c.Assert(getTextContent(result, 0), qt.Contains, "Failed to drop PostgreSQL instance") - }) - - c.Run("health_check_missing_id", func(c *qt.C) { - result, err := callTool(ctx, toolHandler, "health_check_postgres", map[string]any{}) - c.Assert(err, qt.IsNil) - c.Assert(result.IsError, qt.IsTrue) - c.Assert(getTextContent(result, 0), qt.Contains, "instance_id is required") - }) -} - -func TestMCPToolsDefinition(t *testing.T) { - c := qt.New(t) - - // Skip if Docker is not available - dockerMgr, err := docker.NewManager(20200, 20210) - if err != nil { - c.Skip("Docker not available:", err) - } - defer dockerMgr.Close() - - postgresManager := postgres.NewManager(dockerMgr) - toolHandler := mcp.NewToolHandler(postgresManager) - - tools := toolHandler.GetTools() - c.Assert(len(tools), qt.Equals, 5) - - expectedTools := []string{ - "create_postgres_instance", - "list_postgres_instances", - "get_postgres_instance", - "drop_postgres_instance", - "health_check_postgres", - } - - toolNames := make([]string, len(tools)) - for i, tool := range tools { - toolNames[i] = tool.Name - } - - for _, expectedTool := range expectedTools { - c.Assert(toolNames, qt.Contains, expectedTool) - } - - // Verify tool schemas - for _, tool := range tools { - c.Assert(tool.Name, qt.Not(qt.Equals), "") - c.Assert(tool.Description, qt.Not(qt.Equals), "") - c.Assert(tool.InputSchema.Type, qt.Equals, "object") - } -} diff --git a/test/integration/mcp_unified_test.go b/test/integration/mcp_unified_test.go new file mode 100644 index 0000000..5162106 --- /dev/null +++ b/test/integration/mcp_unified_test.go @@ -0,0 +1,242 @@ +package integration_test + +import ( + "context" + "testing" + "time" + + qt "github.com/frankban/quicktest" + mcplib "github.com/mark3labs/mcp-go/mcp" + + "github.com/stokaro/dev-postgres-mcp/internal/database" + "github.com/stokaro/dev-postgres-mcp/internal/docker" + "github.com/stokaro/dev-postgres-mcp/internal/mcp" + "github.com/stokaro/dev-postgres-mcp/pkg/types" +) + +func TestUnifiedMCPTools(t *testing.T) { + c := qt.New(t) + + // Skip if Docker is not available + dockerMgr, err := docker.NewManager(20300, 20400) + if err != nil { + c.Skip("Docker not available:", err) + } + defer dockerMgr.Close() + + ctx := context.Background() + if err := dockerMgr.Ping(ctx); err != nil { + c.Skip("Docker daemon not accessible:", err) + } + + // Create unified database manager and tool handler + unifiedManager := database.NewUnifiedManager(dockerMgr) + toolHandler := mcp.NewToolHandler(unifiedManager) + + t.Run("Unified tools definition", func(t *testing.T) { + c := qt.New(t) + + tools := toolHandler.GetTools() + c.Assert(len(tools), qt.Equals, 5) // 5 unified tools + + expectedTools := []string{ + "create_database_instance", + "list_database_instances", + "get_database_instance", + "drop_database_instance", + "health_check_database", + } + + toolNames := make([]string, len(tools)) + for i, tool := range tools { + toolNames[i] = tool.Name + } + + for _, expectedTool := range expectedTools { + c.Assert(toolNames, qt.Contains, expectedTool) + } + }) + + t.Run("Create database instance tool", func(t *testing.T) { + c := qt.New(t) + + // Test PostgreSQL creation + result, err := callTool(ctx, toolHandler, "create_database_instance", map[string]any{ + "type": "postgresql", + "version": "17", + "database": "testdb", + "username": "testuser", + "password": "testpass", + }) + c.Assert(err, qt.IsNil) + c.Assert(result, qt.IsNotNil) + + content := getTextContent(result, 0) + c.Assert(content, qt.Contains, "Database instance created successfully") + c.Assert(content, qt.Contains, "postgresql") + + // Clean up - extract instance ID from response + defer func() { + instances, _ := unifiedManager.ListInstancesByType(ctx, types.DatabaseTypePostgreSQL) + for _, instance := range instances { + unifiedManager.DropInstance(ctx, instance.ID) + } + }() + }) + + t.Run("List database instances tool", func(t *testing.T) { + c := qt.New(t) + + // Create a test instance first + opts := types.CreateInstanceOptions{ + Type: types.DatabaseTypePostgreSQL, + Database: "listtest", + } + instance, err := unifiedManager.CreateInstance(ctx, opts) + c.Assert(err, qt.IsNil) + defer unifiedManager.DropInstance(ctx, instance.ID) + + // Test listing all instances + result, err := callTool(ctx, toolHandler, "list_database_instances", map[string]any{}) + c.Assert(err, qt.IsNil) + c.Assert(result, qt.IsNotNil) + + content := getTextContent(result, 0) + c.Assert(content, qt.Contains, "Database instances") + c.Assert(content, qt.Contains, instance.ID) + + // Test filtering by type + result, err = callTool(ctx, toolHandler, "list_database_instances", map[string]any{ + "type": "postgresql", + }) + c.Assert(err, qt.IsNil) + c.Assert(result, qt.IsNotNil) + + content = getTextContent(result, 0) + c.Assert(content, qt.Contains, "postgresql") + }) + + t.Run("Get database instance tool", func(t *testing.T) { + c := qt.New(t) + + // Create a test instance first + opts := types.CreateInstanceOptions{ + Type: types.DatabaseTypePostgreSQL, + Database: "gettest", + } + instance, err := unifiedManager.CreateInstance(ctx, opts) + c.Assert(err, qt.IsNil) + defer unifiedManager.DropInstance(ctx, instance.ID) + + // Test getting instance details + result, err := callTool(ctx, toolHandler, "get_database_instance", map[string]any{ + "instance_id": instance.ID, + }) + c.Assert(err, qt.IsNil) + c.Assert(result, qt.IsNotNil) + + content := getTextContent(result, 0) + c.Assert(content, qt.Contains, "Database instance details") + c.Assert(content, qt.Contains, instance.ID) + c.Assert(content, qt.Contains, "postgresql") + }) + + t.Run("Health check database tool", func(t *testing.T) { + c := qt.New(t) + + // Create a test instance first + opts := types.CreateInstanceOptions{ + Type: types.DatabaseTypePostgreSQL, + Database: "healthtest", + } + instance, err := unifiedManager.CreateInstance(ctx, opts) + c.Assert(err, qt.IsNil) + defer unifiedManager.DropInstance(ctx, instance.ID) + + // Wait a bit for the instance to be ready + time.Sleep(5 * time.Second) + + // Test health check + result, err := callTool(ctx, toolHandler, "health_check_database", map[string]any{ + "instance_id": instance.ID, + }) + c.Assert(err, qt.IsNil) + c.Assert(result, qt.IsNotNil) + + content := getTextContent(result, 0) + c.Assert(content, qt.Contains, "Health check results") + c.Assert(content, qt.Contains, instance.ID) + }) + + t.Run("Drop database instance tool", func(t *testing.T) { + c := qt.New(t) + + // Create a test instance first + opts := types.CreateInstanceOptions{ + Type: types.DatabaseTypePostgreSQL, + Database: "droptest", + } + instance, err := unifiedManager.CreateInstance(ctx, opts) + c.Assert(err, qt.IsNil) + + // Test dropping the instance + result, err := callTool(ctx, toolHandler, "drop_database_instance", map[string]any{ + "instance_id": instance.ID, + }) + c.Assert(err, qt.IsNil) + c.Assert(result, qt.IsNotNil) + + content := getTextContent(result, 0) + c.Assert(content, qt.Contains, "Database instance dropped") + c.Assert(content, qt.Contains, instance.ID) + + // Verify instance is gone + _, err = unifiedManager.GetInstance(ctx, instance.ID) + c.Assert(err, qt.IsNotNil) + }) + + t.Run("Error handling", func(t *testing.T) { + c := qt.New(t) + + // Test unknown tool + result, err := callTool(ctx, toolHandler, "unknown_tool", map[string]any{}) + c.Assert(err, qt.IsNil) + c.Assert(result.IsError, qt.IsTrue) + c.Assert(getTextContent(result, 0), qt.Contains, "Unknown tool") + + // Test missing instance ID + result, err = callTool(ctx, toolHandler, "get_database_instance", map[string]any{}) + c.Assert(err, qt.IsNil) + c.Assert(result.IsError, qt.IsTrue) + c.Assert(getTextContent(result, 0), qt.Contains, "instance_id parameter is required") + + // Test nonexistent instance + result, err = callTool(ctx, toolHandler, "get_database_instance", map[string]any{ + "instance_id": "nonexistent", + }) + c.Assert(err, qt.IsNil) + c.Assert(result.IsError, qt.IsTrue) + c.Assert(getTextContent(result, 0), qt.Contains, "Failed to get database instance") + }) +} + +// Helper functions from the original test file +func callTool(ctx context.Context, handler *mcp.ToolHandler, name string, arguments map[string]any) (*mcplib.CallToolResult, error) { + request := mcplib.CallToolRequest{ + Params: mcplib.CallToolParams{ + Name: name, + Arguments: arguments, + }, + } + return handler.HandleTool(ctx, request) +} + +func getTextContent(result *mcplib.CallToolResult, index int) string { + if index >= len(result.Content) { + return "" + } + if textContent, ok := result.Content[index].(mcplib.TextContent); ok { + return textContent.Text + } + return "" +} diff --git a/test/unit/database_test.go b/test/unit/database_test.go new file mode 100644 index 0000000..e40c988 --- /dev/null +++ b/test/unit/database_test.go @@ -0,0 +1,314 @@ +package unit_test + +import ( + "testing" + + qt "github.com/frankban/quicktest" + + "github.com/stokaro/dev-postgres-mcp/pkg/types" +) + +func TestDatabaseType(t *testing.T) { + t.Run("Valid database types", func(t *testing.T) { + c := qt.New(t) + + validTypes := []types.DatabaseType{ + types.DatabaseTypePostgreSQL, + types.DatabaseTypeMySQL, + types.DatabaseTypeMariaDB, + } + + for _, dbType := range validTypes { + c.Assert(dbType.IsValid(), qt.IsTrue, qt.Commentf("Database type %s should be valid", dbType)) + } + }) + + t.Run("Invalid database types", func(t *testing.T) { + c := qt.New(t) + + invalidTypes := []types.DatabaseType{ + "invalid", + "", + "postgres", // Should be "postgresql" + "mongo", + } + + for _, dbType := range invalidTypes { + c.Assert(dbType.IsValid(), qt.IsFalse, qt.Commentf("Database type %s should be invalid", dbType)) + } + }) + + t.Run("Default ports", func(t *testing.T) { + c := qt.New(t) + + tests := []struct { + dbType types.DatabaseType + expectedPort int + }{ + {types.DatabaseTypePostgreSQL, 5432}, + {types.DatabaseTypeMySQL, 3306}, + {types.DatabaseTypeMariaDB, 3306}, + } + + for _, test := range tests { + c.Assert(test.dbType.DefaultPort(), qt.Equals, test.expectedPort, + qt.Commentf("Database type %s should have default port %d", test.dbType, test.expectedPort)) + } + }) + + t.Run("Default versions", func(t *testing.T) { + c := qt.New(t) + + tests := []struct { + dbType types.DatabaseType + expectedVersion string + }{ + {types.DatabaseTypePostgreSQL, "17"}, + {types.DatabaseTypeMySQL, "8.0"}, + {types.DatabaseTypeMariaDB, "11"}, + } + + for _, test := range tests { + c.Assert(test.dbType.DefaultVersion(), qt.Equals, test.expectedVersion, + qt.Commentf("Database type %s should have default version %s", test.dbType, test.expectedVersion)) + } + }) + + t.Run("Default databases", func(t *testing.T) { + c := qt.New(t) + + tests := []struct { + dbType types.DatabaseType + expectedDatabase string + }{ + {types.DatabaseTypePostgreSQL, "postgres"}, + {types.DatabaseTypeMySQL, "mysql"}, + {types.DatabaseTypeMariaDB, "mysql"}, + } + + for _, test := range tests { + c.Assert(test.dbType.DefaultDatabase(), qt.Equals, test.expectedDatabase, + qt.Commentf("Database type %s should have default database %s", test.dbType, test.expectedDatabase)) + } + }) + + t.Run("Default usernames", func(t *testing.T) { + c := qt.New(t) + + tests := []struct { + dbType types.DatabaseType + expectedUsername string + }{ + {types.DatabaseTypePostgreSQL, "postgres"}, + {types.DatabaseTypeMySQL, "root"}, + {types.DatabaseTypeMariaDB, "root"}, + } + + for _, test := range tests { + c.Assert(test.dbType.DefaultUsername(), qt.Equals, test.expectedUsername, + qt.Commentf("Database type %s should have default username %s", test.dbType, test.expectedUsername)) + } + }) +} + +func TestGenerateInstanceID(t *testing.T) { + t.Run("Generate unique IDs", func(t *testing.T) { + c := qt.New(t) + + // Generate multiple IDs and ensure they're unique + ids := make(map[string]bool) + for i := 0; i < 100; i++ { + id := types.GenerateInstanceID() + c.Assert(id, qt.Not(qt.Equals), "", qt.Commentf("Generated ID should not be empty")) + c.Assert(ids[id], qt.IsFalse, qt.Commentf("Generated ID %s should be unique", id)) + ids[id] = true + } + }) + + t.Run("ID format", func(t *testing.T) { + c := qt.New(t) + + id := types.GenerateInstanceID() + + // Should not contain dashes + c.Assert(id, qt.Not(qt.Contains), "-", qt.Commentf("Generated ID should not contain dashes")) + + // Should be alphanumeric + for _, char := range id { + c.Assert((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9'), + qt.IsTrue, qt.Commentf("Character %c should be alphanumeric", char)) + } + + // Should have reasonable length (UUID without dashes is 32 characters) + c.Assert(len(id), qt.Equals, 32, qt.Commentf("Generated ID should be 32 characters long")) + }) +} + +func TestGeneratePassword(t *testing.T) { + t.Run("Generate passwords of different lengths", func(t *testing.T) { + c := qt.New(t) + + lengths := []int{8, 16, 32, 64} + for _, length := range lengths { + password, err := types.GeneratePassword(length) + c.Assert(err, qt.IsNil, qt.Commentf("Should generate password of length %d", length)) + c.Assert(len(password), qt.Equals, length, qt.Commentf("Password should have length %d", length)) + } + }) + + t.Run("Generate unique passwords", func(t *testing.T) { + c := qt.New(t) + + passwords := make(map[string]bool) + for i := 0; i < 100; i++ { + password, err := types.GeneratePassword(16) + c.Assert(err, qt.IsNil) + c.Assert(passwords[password], qt.IsFalse, qt.Commentf("Password %s should be unique", password)) + passwords[password] = true + } + }) + + t.Run("Invalid length", func(t *testing.T) { + c := qt.New(t) + + _, err := types.GeneratePassword(0) + c.Assert(err, qt.IsNotNil, qt.Commentf("Should return error for zero length")) + + _, err = types.GeneratePassword(-1) + c.Assert(err, qt.IsNotNil, qt.Commentf("Should return error for negative length")) + }) +} + +func TestValidateCreateInstanceOptions(t *testing.T) { + t.Run("Valid options with defaults", func(t *testing.T) { + c := qt.New(t) + + opts := &types.CreateInstanceOptions{} + err := types.ValidateCreateInstanceOptions(opts) + c.Assert(err, qt.IsNil) + + // Should set defaults + c.Assert(opts.Type, qt.Equals, types.DatabaseTypePostgreSQL) + c.Assert(opts.Version, qt.Equals, "17") + c.Assert(opts.Database, qt.Equals, "postgres") + c.Assert(opts.Username, qt.Equals, "postgres") + c.Assert(opts.Password, qt.Not(qt.Equals), "", qt.Commentf("Password should be generated")) + }) + + t.Run("Valid options with MySQL", func(t *testing.T) { + c := qt.New(t) + + opts := &types.CreateInstanceOptions{ + Type: types.DatabaseTypeMySQL, + } + err := types.ValidateCreateInstanceOptions(opts) + c.Assert(err, qt.IsNil) + + // Should set MySQL defaults + c.Assert(opts.Type, qt.Equals, types.DatabaseTypeMySQL) + c.Assert(opts.Version, qt.Equals, "8.0") + c.Assert(opts.Database, qt.Equals, "mysql") + c.Assert(opts.Username, qt.Equals, "root") + }) + + t.Run("Invalid database type", func(t *testing.T) { + c := qt.New(t) + + opts := &types.CreateInstanceOptions{ + Type: "invalid", + } + err := types.ValidateCreateInstanceOptions(opts) + c.Assert(err, qt.IsNotNil, qt.Commentf("Should return error for invalid database type")) + }) +} + +func TestBuildDSN(t *testing.T) { + t.Run("PostgreSQL DSN", func(t *testing.T) { + c := qt.New(t) + + instance := &types.DatabaseInstance{ + Type: types.DatabaseTypePostgreSQL, + Username: "postgres", + Password: "secret", + Port: 5432, + Database: "testdb", + } + + dsn := types.BuildDSN(instance) + expected := "postgres://postgres:secret@localhost:5432/testdb?sslmode=disable" + c.Assert(dsn, qt.Equals, expected) + }) + + t.Run("MySQL DSN", func(t *testing.T) { + c := qt.New(t) + + instance := &types.DatabaseInstance{ + Type: types.DatabaseTypeMySQL, + Username: "root", + Password: "secret", + Port: 3306, + Database: "testdb", + } + + dsn := types.BuildDSN(instance) + expected := "root:secret@tcp(localhost:3306)/testdb" + c.Assert(dsn, qt.Equals, expected) + }) + + t.Run("MariaDB DSN", func(t *testing.T) { + c := qt.New(t) + + instance := &types.DatabaseInstance{ + Type: types.DatabaseTypeMariaDB, + Username: "root", + Password: "secret", + Port: 3306, + Database: "testdb", + } + + dsn := types.BuildDSN(instance) + expected := "root:secret@tcp(localhost:3306)/testdb" + c.Assert(dsn, qt.Equals, expected) + }) +} + +func TestGetDockerImage(t *testing.T) { + tests := []struct { + dbType types.DatabaseType + version string + expectedImage string + }{ + {types.DatabaseTypePostgreSQL, "17", "postgres:17"}, + {types.DatabaseTypePostgreSQL, "16", "postgres:16"}, + {types.DatabaseTypeMySQL, "8.0", "mysql:8.0"}, + {types.DatabaseTypeMySQL, "5.7", "mysql:5.7"}, + {types.DatabaseTypeMariaDB, "11", "mariadb:11"}, + {types.DatabaseTypeMariaDB, "10.6", "mariadb:10.6"}, + } + + for _, test := range tests { + c := qt.New(t) + image := types.GetDockerImage(test.dbType, test.version) + c.Assert(image, qt.Equals, test.expectedImage, + qt.Commentf("Database type %s version %s should have image %s", test.dbType, test.version, test.expectedImage)) + } +} + +func TestGetContainerName(t *testing.T) { + tests := []struct { + instanceID string + dbType types.DatabaseType + expectedName string + }{ + {"abc123", types.DatabaseTypePostgreSQL, "dev-postgresql-mcp-abc123"}, + {"def456", types.DatabaseTypeMySQL, "dev-mysql-mcp-def456"}, + {"ghi789", types.DatabaseTypeMariaDB, "dev-mariadb-mcp-ghi789"}, + } + + for _, test := range tests { + c := qt.New(t) + name := types.GetContainerName(test.instanceID, test.dbType) + c.Assert(name, qt.Equals, test.expectedName, + qt.Commentf("Instance %s of type %s should have container name %s", test.instanceID, test.dbType, test.expectedName)) + } +} diff --git a/test/unit/docker_test.go b/test/unit/docker_test.go index 5c0502f..020e8b9 100644 --- a/test/unit/docker_test.go +++ b/test/unit/docker_test.go @@ -224,7 +224,6 @@ func TestDockerManagerCreation(t *testing.T) { defer manager.Close() c.Assert(manager, qt.IsNotNil) - c.Assert(manager.PostgreSQL(), qt.IsNotNil) c.Assert(manager.GetClient(), qt.IsNotNil) } diff --git a/test/unit/postgres_test.go b/test/unit/postgres_test.go deleted file mode 100644 index c015e60..0000000 --- a/test/unit/postgres_test.go +++ /dev/null @@ -1,412 +0,0 @@ -package unit_test - -import ( - "testing" - - qt "github.com/frankban/quicktest" - - "github.com/stokaro/dev-postgres-mcp/internal/postgres" - "github.com/stokaro/dev-postgres-mcp/pkg/types" -) - -func TestDSNGeneration(t *testing.T) { - c := qt.New(t) - - tests := []struct { - name string - config postgres.DSNConfig - expected string - }{ - { - name: "basic DSN", - config: postgres.DSNConfig{ - Host: "localhost", - Port: 5432, - Database: "testdb", - Username: "testuser", - Password: "testpass", - SSLMode: "disable", - }, - expected: "postgres://testuser:testpass@localhost:5432/testdb?sslmode=disable", - }, - { - name: "DSN with custom port", - config: postgres.DSNConfig{ - Host: "localhost", - Port: 15432, - Database: "mydb", - Username: "postgres", - Password: "secret", - SSLMode: "require", - }, - expected: "postgres://postgres:secret@localhost:15432/mydb?sslmode=require", - }, - { - name: "DSN with special characters", - config: postgres.DSNConfig{ - Host: "localhost", - Port: 5432, - Database: "test@db", - Username: "user+name", - Password: "pass word", - SSLMode: "disable", - }, - expected: "postgres://user%2Bname:pass+word@localhost:5432/test%40db?sslmode=disable", - }, - { - name: "DSN with options", - config: postgres.DSNConfig{ - Host: "localhost", - Port: 5432, - Database: "testdb", - Username: "testuser", - Password: "testpass", - SSLMode: "disable", - Options: map[string]string{ - "connect_timeout": "10", - "application_name": "test_app", - }, - }, - expected: "postgres://testuser:testpass@localhost:5432/testdb?application_name=test_app&connect_timeout=10&sslmode=disable", - }, - } - - for _, tt := range tests { - c.Run(tt.name, func(c *qt.C) { - dsn := postgres.GenerateDSN(tt.config) - c.Assert(dsn, qt.Equals, tt.expected) - }) - } -} - -func TestDSNParsing(t *testing.T) { - c := qt.New(t) - - tests := []struct { - name string - dsn string - expected postgres.DSNConfig - hasError bool - }{ - { - name: "basic DSN parsing", - dsn: "postgres://testuser:testpass@localhost:5432/testdb?sslmode=disable", - expected: postgres.DSNConfig{ - Host: "localhost", - Port: 5432, - Database: "testdb", - Username: "testuser", - Password: "testpass", - SSLMode: "disable", - Options: map[string]string{}, - }, - hasError: false, - }, - { - name: "DSN with custom port", - dsn: "postgres://postgres:secret@localhost:15432/mydb?sslmode=require", - expected: postgres.DSNConfig{ - Host: "localhost", - Port: 15432, - Database: "mydb", - Username: "postgres", - Password: "secret", - SSLMode: "require", - Options: map[string]string{}, - }, - hasError: false, - }, - { - name: "DSN with options", - dsn: "postgres://user:pass@localhost:5432/db?sslmode=disable&connect_timeout=10&application_name=test", - expected: postgres.DSNConfig{ - Host: "localhost", - Port: 5432, - Database: "db", - Username: "user", - Password: "pass", - SSLMode: "disable", - Options: map[string]string{ - "connect_timeout": "10", - "application_name": "test", - }, - }, - hasError: false, - }, - { - name: "invalid DSN", - dsn: "invalid://dsn", - expected: postgres.DSNConfig{}, - hasError: true, - }, - { - name: "malformed URL", - dsn: "not-a-url", - expected: postgres.DSNConfig{}, - hasError: true, - }, - } - - for _, tt := range tests { - c.Run(tt.name, func(c *qt.C) { - config, err := postgres.ParseDSN(tt.dsn) - - if tt.hasError { - c.Assert(err, qt.IsNotNil) - return - } - - c.Assert(err, qt.IsNil) - c.Assert(config.Host, qt.Equals, tt.expected.Host) - c.Assert(config.Port, qt.Equals, tt.expected.Port) - c.Assert(config.Database, qt.Equals, tt.expected.Database) - c.Assert(config.Username, qt.Equals, tt.expected.Username) - c.Assert(config.Password, qt.Equals, tt.expected.Password) - c.Assert(config.SSLMode, qt.Equals, tt.expected.SSLMode) - c.Assert(len(config.Options), qt.Equals, len(tt.expected.Options)) - - for key, expectedValue := range tt.expected.Options { - c.Assert(config.Options[key], qt.Equals, expectedValue) - } - }) - } -} - -func TestDSNRoundTrip(t *testing.T) { - c := qt.New(t) - - originalConfig := postgres.DSNConfig{ - Host: "localhost", - Port: 15432, - Database: "testdb", - Username: "testuser", - Password: "testpass", - SSLMode: "require", - Options: map[string]string{ - "connect_timeout": "10", - }, - } - - // Generate DSN from config - dsn := postgres.GenerateDSN(originalConfig) - - // Parse DSN back to config - parsedConfig, err := postgres.ParseDSN(dsn) - c.Assert(err, qt.IsNil) - - // Compare configs - c.Assert(parsedConfig.Host, qt.Equals, originalConfig.Host) - c.Assert(parsedConfig.Port, qt.Equals, originalConfig.Port) - c.Assert(parsedConfig.Database, qt.Equals, originalConfig.Database) - c.Assert(parsedConfig.Username, qt.Equals, originalConfig.Username) - c.Assert(parsedConfig.Password, qt.Equals, originalConfig.Password) - c.Assert(parsedConfig.SSLMode, qt.Equals, originalConfig.SSLMode) - c.Assert(len(parsedConfig.Options), qt.Equals, len(originalConfig.Options)) - - for key, expectedValue := range originalConfig.Options { - c.Assert(parsedConfig.Options[key], qt.Equals, expectedValue) - } -} - -func TestBuildLocalDSN(t *testing.T) { - c := qt.New(t) - - dsn := postgres.BuildLocalDSN(15432, "testdb", "testuser", "testpass") - expected := "postgres://testuser:testpass@localhost:15432/testdb?sslmode=disable" - - c.Assert(dsn, qt.Equals, expected) -} - -func TestMaskPassword(t *testing.T) { - c := qt.New(t) - - tests := []struct { - name string - dsn string - expected string - }{ - { - name: "mask password in DSN", - dsn: "postgres://user:secret@localhost:5432/db", - expected: "postgres://user:%2A%2A%2A%2A@localhost:5432/db?sslmode=disable", - }, - { - name: "no password to mask", - dsn: "postgres://user@localhost:5432/db", - expected: "postgres://user:@localhost:5432/db?sslmode=disable", - }, - { - name: "invalid DSN", - dsn: "not-a-dsn", - expected: "not-a-dsn", - }, - } - - for _, tt := range tests { - c.Run(tt.name, func(c *qt.C) { - masked := postgres.MaskPassword(tt.dsn) - c.Assert(masked, qt.Equals, tt.expected) - }) - } -} - -func TestGetDatabaseFromDSN(t *testing.T) { - c := qt.New(t) - - tests := []struct { - name string - dsn string - expected string - hasError bool - }{ - { - name: "extract database name", - dsn: "postgres://user:pass@localhost:5432/mydb", - expected: "mydb", - hasError: false, - }, - { - name: "invalid DSN", - dsn: "invalid", - expected: "", - hasError: true, - }, - } - - for _, tt := range tests { - c.Run(tt.name, func(c *qt.C) { - db, err := postgres.GetDatabaseFromDSN(tt.dsn) - - if tt.hasError { - c.Assert(err, qt.IsNotNil) - return - } - - c.Assert(err, qt.IsNil) - c.Assert(db, qt.Equals, tt.expected) - }) - } -} - -func TestGetHostPortFromDSN(t *testing.T) { - c := qt.New(t) - - host, port, err := postgres.GetHostPortFromDSN("postgres://user:pass@localhost:15432/db") - c.Assert(err, qt.IsNil) - c.Assert(host, qt.Equals, "localhost") - c.Assert(port, qt.Equals, 15432) -} - -func TestGetCredentialsFromDSN(t *testing.T) { - c := qt.New(t) - - username, password, err := postgres.GetCredentialsFromDSN("postgres://testuser:testpass@localhost:5432/db") - c.Assert(err, qt.IsNil) - c.Assert(username, qt.Equals, "testuser") - c.Assert(password, qt.Equals, "testpass") -} - -func TestCreateInstanceOptions(t *testing.T) { - c := qt.New(t) - - // Test default values - opts := types.CreateInstanceOptions{} - c.Assert(opts.Version, qt.Equals, "") - c.Assert(opts.Database, qt.Equals, "") - c.Assert(opts.Username, qt.Equals, "") - c.Assert(opts.Password, qt.Equals, "") - - // Test with values - opts = types.CreateInstanceOptions{ - Version: "16", - Database: "mydb", - Username: "myuser", - Password: "mypass", - } - c.Assert(opts.Version, qt.Equals, "16") - c.Assert(opts.Database, qt.Equals, "mydb") - c.Assert(opts.Username, qt.Equals, "myuser") - c.Assert(opts.Password, qt.Equals, "mypass") -} - -func TestPartialIDMatching(t *testing.T) { - c := qt.New(t) - - // Create mock instances for testing - instances := []*types.PostgreSQLInstance{ - { - ID: "abcd1234-5678-90ef-ghij-klmnopqrstuv", - ContainerID: "container1", - Port: 5432, - Database: "db1", - Username: "user1", - Version: "17", - Status: "running", - }, - { - ID: "abcd5678-1234-90ef-ghij-klmnopqrstuv", - ContainerID: "container2", - Port: 5433, - Database: "db2", - Username: "user2", - Version: "17", - Status: "running", - }, - { - ID: "efgh1234-5678-90ef-ghij-klmnopqrstuv", - ContainerID: "container3", - Port: 5434, - Database: "db3", - Username: "user3", - Version: "17", - Status: "running", - }, - } - - c.Run("exact_match", func(c *qt.C) { - fullID := "abcd1234-5678-90ef-ghij-klmnopqrstuv" - var match *types.PostgreSQLInstance - for _, instance := range instances { - if instance.ID == fullID { - match = instance - break - } - } - c.Assert(match, qt.IsNotNil) - c.Assert(match.ID, qt.Equals, fullID) - }) - - c.Run("partial_match_unique", func(c *qt.C) { - partialID := "efgh" - var matches []*types.PostgreSQLInstance - for _, instance := range instances { - if len(instance.ID) >= len(partialID) && instance.ID[:len(partialID)] == partialID { - matches = append(matches, instance) - } - } - c.Assert(len(matches), qt.Equals, 1) - c.Assert(matches[0].Database, qt.Equals, "db3") - }) - - c.Run("partial_match_ambiguous", func(c *qt.C) { - partialID := "abcd" - var matches []*types.PostgreSQLInstance - for _, instance := range instances { - if len(instance.ID) >= len(partialID) && instance.ID[:len(partialID)] == partialID { - matches = append(matches, instance) - } - } - c.Assert(len(matches), qt.Equals, 2) - }) - - c.Run("no_match", func(c *qt.C) { - partialID := "xyz" - var matches []*types.PostgreSQLInstance - for _, instance := range instances { - if len(instance.ID) >= len(partialID) && instance.ID[:len(partialID)] == partialID { - matches = append(matches, instance) - } - } - c.Assert(len(matches), qt.Equals, 0) - }) -}