From 620c09057a44036525d7a0e03d9d452fe504407d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Sat, 8 Nov 2025 18:04:33 +0100 Subject: [PATCH 01/19] feature: Add error handling package with custom error types and utility functions --- pkg/errors/errors.go | 75 +++++++++++ pkg/errors/errors_test.go | 256 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 331 insertions(+) create mode 100644 pkg/errors/errors.go create mode 100644 pkg/errors/errors_test.go diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go new file mode 100644 index 0000000..70735e0 --- /dev/null +++ b/pkg/errors/errors.go @@ -0,0 +1,75 @@ +package errors + +import ( + "fmt" +) + +// Error codes +const ( + ErrConfigInvalid = "ERR_CONFIG_INVALID" + ErrConfigNotFound = "ERR_CONFIG_NOT_FOUND" + ErrServiceTimeout = "ERR_SERVICE_TIMEOUT" + ErrServiceFailed = "ERR_SERVICE_FAILED" + ErrServiceNotReady = "ERR_SERVICE_NOT_READY" + ErrServiceNotRunning = "ERR_SERVICE_NOT_RUNNING" + ErrTestFailed = "ERR_TEST_FAILED" + ErrDockerConnection = "ERR_DOCKER_CONNECTION" + ErrDockerFailed = "ERR_DOCKER_FAILED" + ErrContainerFailed = "ERR_CONTAINER_FAILED" + ErrProcessFailed = "ERR_PROCESS_FAILED" + ErrInvalidArgument = "ERR_INVALID_ARGUMENT" +) + +// GTError represents a gtool error with code and context +type GTError struct { + Code string + Message string + Cause error + Context map[string]interface{} +} + +// Error implements the error interface +func (e *GTError) Error() string { + if e.Cause != nil { + return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Cause) + } + return fmt.Sprintf("[%s] %s", e.Code, e.Message) +} + +// Unwrap returns the underlying error +func (e *GTError) Unwrap() error { + return e.Cause +} + +// New creates a new GTError +func New(code, message string) *GTError { + return >Error{ + Code: code, + Message: message, + Context: make(map[string]interface{}), + } +} + +// Wrap wraps an error with a GTError +func Wrap(err error, code, message string) *GTError { + return >Error{ + Code: code, + Message: message, + Cause: err, + Context: make(map[string]interface{}), + } +} + +// WithContext adds context to the error +func (e *GTError) WithContext(key string, value interface{}) *GTError { + e.Context[key] = value + return e +} + +// Is checks if an error matches a code +func Is(err error, code string) bool { + if gtErr, ok := err.(*GTError); ok { + return gtErr.Code == code + } + return false +} diff --git a/pkg/errors/errors_test.go b/pkg/errors/errors_test.go new file mode 100644 index 0000000..790c7c4 --- /dev/null +++ b/pkg/errors/errors_test.go @@ -0,0 +1,256 @@ +package errors + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNew(t *testing.T) { + tests := []struct { + name string + code string + message string + }{ + { + name: "simple error", + code: ErrConfigInvalid, + message: "invalid configuration", + }, + { + name: "service error", + code: ErrServiceFailed, + message: "service failed to start", + }, + { + name: "test error", + code: ErrTestFailed, + message: "test execution failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := New(tt.code, tt.message) + + assert.NotNil(t, err) + assert.Equal(t, tt.code, err.Code) + assert.Equal(t, tt.message, err.Message) + assert.Nil(t, err.Cause) + assert.NotNil(t, err.Context) + assert.Empty(t, err.Context) + }) + } +} + +func TestWrap(t *testing.T) { + originalErr := errors.New("original error") + + tests := []struct { + name string + err error + code string + message string + }{ + { + name: "wrap with config error", + err: originalErr, + code: ErrConfigInvalid, + message: "failed to load config", + }, + { + name: "wrap with service error", + err: originalErr, + code: ErrServiceFailed, + message: "service startup failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wrapped := Wrap(tt.err, tt.code, tt.message) + + assert.NotNil(t, wrapped) + assert.Equal(t, tt.code, wrapped.Code) + assert.Equal(t, tt.message, wrapped.Message) + assert.Equal(t, tt.err, wrapped.Cause) + }) + } +} + +func TestGTError_Error(t *testing.T) { + tests := []struct { + name string + err *GTError + expected string + }{ + { + name: "error without cause", + err: >Error{ + Code: ErrConfigInvalid, + Message: "invalid config", + }, + expected: "[ERR_CONFIG_INVALID] invalid config", + }, + { + name: "error with cause", + err: >Error{ + Code: ErrConfigInvalid, + Message: "invalid config", + Cause: errors.New("file not found"), + }, + expected: "[ERR_CONFIG_INVALID] invalid config: file not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.err.Error() + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGTError_Unwrap(t *testing.T) { + originalErr := errors.New("original") + wrapped := Wrap(originalErr, ErrServiceFailed, "service failed") + + unwrapped := wrapped.Unwrap() + assert.Equal(t, originalErr, unwrapped) +} + +func TestGTError_WithContext(t *testing.T) { + err := New(ErrServiceFailed, "service failed") + + result := err.WithContext("service", "couchbase") + assert.Equal(t, err, result) // Should return same instance + assert.Equal(t, "couchbase", err.Context["service"]) + + // Add multiple context values + err.WithContext("port", 8091) + err.WithContext("timeout", "30s") + + assert.Equal(t, 3, len(err.Context)) + assert.Equal(t, 8091, err.Context["port"]) + assert.Equal(t, "30s", err.Context["timeout"]) +} + +func TestIs(t *testing.T) { + tests := []struct { + name string + err error + code string + expected bool + }{ + { + name: "matching code", + err: New(ErrConfigInvalid, "invalid"), + code: ErrConfigInvalid, + expected: true, + }, + { + name: "non-matching code", + err: New(ErrConfigInvalid, "invalid"), + code: ErrServiceFailed, + expected: false, + }, + { + name: "wrapped error matching", + err: Wrap(errors.New("cause"), ErrTestFailed, "test failed"), + code: ErrTestFailed, + expected: true, + }, + { + name: "non-GTError", + err: errors.New("standard error"), + code: ErrConfigInvalid, + expected: false, + }, + { + name: "nil error", + err: nil, + code: ErrConfigInvalid, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Is(tt.err, tt.code) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestErrorCodes(t *testing.T) { + // Test that all error codes are unique + codes := []string{ + ErrConfigInvalid, + ErrConfigNotFound, + ErrServiceTimeout, + ErrServiceFailed, + ErrServiceNotReady, + ErrTestFailed, + ErrDockerConnection, + ErrContainerFailed, + ErrProcessFailed, + ErrInvalidArgument, + } + + seen := make(map[string]bool) + for _, code := range codes { + assert.False(t, seen[code], "duplicate error code: %s", code) + seen[code] = true + } + + assert.Equal(t, len(codes), len(seen)) +} + +func ExampleNew() { + err := New(ErrConfigInvalid, "configuration is missing required field") + fmt.Println(err.Error()) + // Output: [ERR_CONFIG_INVALID] configuration is missing required field +} + +func ExampleWrap() { + originalErr := errors.New("file not found") + err := Wrap(originalErr, ErrConfigNotFound, "failed to load configuration") + fmt.Println(err.Error()) + // Output: [ERR_CONFIG_NOT_FOUND] failed to load configuration: file not found +} + +func ExampleGTError_WithContext() { + err := New(ErrServiceFailed, "service failed to start") + err.WithContext("service", "couchbase") + err.WithContext("port", 8091) + + fmt.Printf("Code: %s, Message: %s, Service: %s\n", + err.Code, err.Message, err.Context["service"]) + // Output: Code: ERR_SERVICE_FAILED, Message: service failed to start, Service: couchbase +} + +func BenchmarkNew(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = New(ErrConfigInvalid, "test error") + } +} + +func BenchmarkWrap(b *testing.B) { + originalErr := errors.New("original") + b.ResetTimer() + + for i := 0; i < b.N; i++ { + _ = Wrap(originalErr, ErrServiceFailed, "wrapped error") + } +} + +func BenchmarkWithContext(b *testing.B) { + err := New(ErrServiceFailed, "service failed") + b.ResetTimer() + + for i := 0; i < b.N; i++ { + err.WithContext("iteration", i) + } +} From 77cffb9fc90a25e62498e32c638f082777a3cbd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Sat, 8 Nov 2025 18:04:48 +0100 Subject: [PATCH 02/19] feature: Refactor configuration management commands and add generate command --- internal/cli/config.go | 142 ------------------------------ internal/cli/config/config.go | 118 +++++++++++++++++++++++++ internal/cli/generate/generate.go | 96 ++++++++++++++++++++ 3 files changed, 214 insertions(+), 142 deletions(-) delete mode 100644 internal/cli/config.go create mode 100644 internal/cli/config/config.go create mode 100644 internal/cli/generate/generate.go diff --git a/internal/cli/config.go b/internal/cli/config.go deleted file mode 100644 index 019b59d..0000000 --- a/internal/cli/config.go +++ /dev/null @@ -1,142 +0,0 @@ -package cli - -import ( - "encoding/json" - "fmt" - - "github.com/oswaldo-montano/gtool/pkg/config" - "github.com/spf13/cobra" - "gopkg.in/yaml.v3" - - coreConfig "github.com/oswaldo-montano/gtool/internal/core/config" -) - -var configCmd = &cobra.Command{ - Use: "config", - Short: "Configuration management", - Long: `Validate, show, and manage configuration files.`, -} - -var configValidateCmd = &cobra.Command{ - Use: "validate", - Short: "Validate configuration file", - Long: `Validate the configuration file against the schema and business rules.`, - RunE: func(cmd *cobra.Command, args []string) error { - configPath, _ := cmd.Flags().GetString("config") - - loader := coreConfig.NewLoader() - var cfg *config.Config - var err error - - if configPath != "" { - cfg, err = loader.Load(configPath) - } else { - cfg, err = loader.LoadFromPath() - } - - if err != nil { - fmt.Printf("❌ Failed to load configuration: %v\n", err) - return err - } - - fmt.Printf("✓ Configuration loaded successfully\n") - - validator := coreConfig.NewValidator() - if err := validator.Validate(cfg); err != nil { - fmt.Printf("❌ Validation failed:\n%v\n", err) - return err - } - - fmt.Printf("✓ Configuration is valid\n") - fmt.Printf("\nSummary:\n") - fmt.Printf(" Version: %s\n", cfg.Version) - fmt.Printf(" Technology: %s\n", cfg.AppTechnology) - fmt.Printf(" Test Launcher: %s\n", cfg.TestLauncher) - fmt.Printf(" Mock Services: %d configured\n", len(cfg.ThirdParty.Mocks)) - - return nil - }, -} - -var configShowCmd = &cobra.Command{ - Use: "show", - Short: "Show current configuration", - Long: `Display the current configuration with all resolved values.`, - RunE: func(cmd *cobra.Command, args []string) error { - configPath, _ := cmd.Flags().GetString("config") - format, _ := cmd.Flags().GetString("format") - - loader := coreConfig.NewLoader() - var cfg *config.Config - var err error - - if configPath != "" { - cfg, err = loader.Load(configPath) - } else { - cfg, err = loader.LoadFromPath() - } - - if err != nil { - fmt.Printf("Failed to load configuration: %v\n", err) - return err - } - - switch format { - case "json": - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) - } - fmt.Println(string(data)) - case "yaml": - data, err := yaml.Marshal(cfg) - if err != nil { - return fmt.Errorf("failed to marshal YAML: %w", err) - } - fmt.Println(string(data)) - default: - return fmt.Errorf("unsupported format: %s (use 'json' or 'yaml')", format) - } - - return nil - }, -} - -var configInitCmd = &cobra.Command{ - Use: "init", - Short: "Initialize a new configuration", - Long: `Create a new configuration file from a template.`, - RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Initializing configuration - No implementation yet") - return nil - }, -} - -var configMigrateCmd = &cobra.Command{ - Use: "migrate", - Short: "Migrate configuration from component", - Long: `Migrate an existing component-config.yml to gtool format.`, - RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Migrating configuration - No implementation yet") - return nil - }, -} - -func init() { - // Show flags - configShowCmd.Flags().String("format", "yaml", "Output format (yaml, json)") - - // Init flags - configInitCmd.Flags().String("template", "golang", "Template to use (golang, nodejs, generic)") - - // Migrate flags - configMigrateCmd.Flags().String("from", "component-config.yml", "Source configuration file") - - // Add subcommands - configCmd.AddCommand(configValidateCmd) - configCmd.AddCommand(configShowCmd) - configCmd.AddCommand(configInitCmd) - configCmd.AddCommand(configMigrateCmd) - - rootCmd.AddCommand(configCmd) -} diff --git a/internal/cli/config/config.go b/internal/cli/config/config.go new file mode 100644 index 0000000..c262c54 --- /dev/null +++ b/internal/cli/config/config.go @@ -0,0 +1,118 @@ +package config + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + coreConfig "github.com/oswaldo-montano/gtool/internal/core/config" + "github.com/oswaldo-montano/gtool/pkg/config" +) + +// NewConfigCmd creates the config command +func NewConfigCmd(configFile *string) *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Configuration management", + Long: `Validate, show, and manage configuration files.`, + } + + // Add subcommands + cmd.AddCommand(newConfigValidateCmd(configFile)) + cmd.AddCommand(newConfigShowCmd(configFile)) + + return cmd +} + +func newConfigValidateCmd(configFile *string) *cobra.Command { + return &cobra.Command{ + Use: "validate", + Short: "Validate configuration file", + Long: `Validate the configuration file against the schema and business rules.`, + RunE: func(cmd *cobra.Command, args []string) error { + loader := coreConfig.NewLoader() + var cfg *config.Config + var err error + + // Use the config file from the global flag + if configFile != nil && *configFile != "" { + cfg, err = loader.Load(*configFile) + } else { + cfg, err = loader.LoadFromPath() + } + + if err != nil { + fmt.Printf("❌ Failed to load configuration: %v\n", err) + return err + } + + fmt.Printf("✓ Configuration loaded successfully\n") + + validator := coreConfig.NewValidator() + if err := validator.Validate(cfg); err != nil { + fmt.Printf("❌ Validation failed:\n%v\n", err) + return err + } + + fmt.Printf("✓ Configuration is valid\n") + fmt.Printf("\nSummary:\n") + fmt.Printf(" Version: %s\n", cfg.Version) + fmt.Printf(" Technology: %s\n", cfg.AppTechnology) + fmt.Printf(" Test Launcher: %s\n", cfg.TestLauncher) + fmt.Printf(" Mock Services: %d configured\n", len(cfg.ThirdParty.Mocks)) + + return nil + }, + } +} + +func newConfigShowCmd(configFile *string) *cobra.Command { + cmd := &cobra.Command{ + Use: "show", + Short: "Show current configuration", + Long: `Display the current configuration with all resolved values.`, + RunE: func(cmd *cobra.Command, args []string) error { + format, _ := cmd.Flags().GetString("format") + + loader := coreConfig.NewLoader() + var cfg *config.Config + var err error + + // Use the config file from the global flag + if configFile != nil && *configFile != "" { + cfg, err = loader.Load(*configFile) + } else { + cfg, err = loader.LoadFromPath() + } + + if err != nil { + fmt.Printf("Failed to load configuration: %v\n", err) + return err + } + + var output []byte + + switch format { + case "json": + output, err = json.MarshalIndent(cfg, "", " ") + case "yml": + output, err = yaml.Marshal(cfg) + default: + return fmt.Errorf("unsupported format: %s (use 'json' or 'yml')", format) + } + + if err != nil { + return fmt.Errorf("failed to marshal configuration: %w", err) + } + + fmt.Println(string(output)) + return nil + }, + } + + cmd.Flags().StringP("format", "f", "yml", "output format (yml or json)") + + return cmd +} diff --git a/internal/cli/generate/generate.go b/internal/cli/generate/generate.go new file mode 100644 index 0000000..539c776 --- /dev/null +++ b/internal/cli/generate/generate.go @@ -0,0 +1,96 @@ +package generate + +import ( + _ "embed" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +//go:embed templates/component-config.yml +var configTemplate string + +var ( + outputFile string + force bool +) + +// NewGenerateCmd creates the generate command +func NewGenerateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "generate", + Aliases: []string{"g"}, + Short: "Generate code and configuration files", + Long: `Generate various files for your project. + +Available generators: + - config: Generate a basic component-config.yml file + +Examples: + gtool generate config # Generate component-config.yml + gtool g config # Short form + gtool g config -o my-config.yml # Custom output file + gtool g config --force # Overwrite existing file`, + } + + // Add subcommands + cmd.AddCommand(newGenerateConfigCmd()) + + return cmd +} + +// newGenerateConfigCmd creates the config subcommand +func newGenerateConfigCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Generate a basic configuration file", + Long: `Generate a basic component-config.yml file with examples. + +The generated file includes: + - Basic structure with all sections + - Comments explaining each option + - Example values for PostgreSQL + - Commented examples for other services + +Examples: + gtool generate config # Create component-config.yml + gtool g config # Short form + gtool g config -o my-config.yml # Custom filename + gtool g config --force # Overwrite if exists`, + RunE: runGenerateConfig, + } + + // Add flags + cmd.Flags().StringVarP(&outputFile, "output", "o", "component-config.yml", "output file path") + cmd.Flags().BoolVarP(&force, "force", "f", false, "overwrite existing file") + + return cmd +} + +func runGenerateConfig(cmd *cobra.Command, args []string) error { + // Check if file exists + if _, err := os.Stat(outputFile); err == nil && !force { + return fmt.Errorf("file %s already exists. Use --force to overwrite", outputFile) + } + + // Get absolute path + absPath, err := filepath.Abs(outputFile) + if err != nil { + return fmt.Errorf("failed to resolve path: %w", err) + } + + // Write file using embedded template + if err := os.WriteFile(outputFile, []byte(configTemplate), 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + fmt.Printf("✅ Configuration file created: %s\n\n", absPath) + fmt.Println("Next steps:") + fmt.Println(" 1. Edit the configuration file to match your needs") + fmt.Println(" 2. Validate: gtool config validate") + fmt.Println(" 3. Start services: gtool s up") + + return nil +} From 6e0595e819aeb7cf562d7f67a32541b122a14c3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Sat, 8 Nov 2025 18:04:59 +0100 Subject: [PATCH 03/19] feature: Add subcommands for generate, config, and services in gtool CLI --- internal/cli/root.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/cli/root.go b/internal/cli/root.go index 8201214..3057afc 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -9,6 +9,10 @@ import ( "github.com/spf13/cobra" "github.com/spf13/viper" "go.uber.org/zap" + + "github.com/oswaldo-montano/gtool/internal/cli/config" + "github.com/oswaldo-montano/gtool/internal/cli/generate" + "github.com/oswaldo-montano/gtool/internal/cli/services" ) var ( @@ -43,6 +47,11 @@ func init() { checkError(viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))) checkError(viper.BindPFlag("config", rootCmd.PersistentFlags().Lookup("config"))) + + // Register subcommands + rootCmd.AddCommand(generate.NewGenerateCmd()) + rootCmd.AddCommand(services.NewServicesCmd(&cfgFile)) + rootCmd.AddCommand(config.NewConfigCmd(&cfgFile)) } func initLogger() { From de3992dc953b920e199f0319dfae1a6858142cd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Sat, 8 Nov 2025 18:26:04 +0100 Subject: [PATCH 04/19] feature: Implement plugin registry with service, launcher, and executor management --- internal/plugin/registry.go | 107 ++++++++++ internal/plugin/registry_test.go | 331 +++++++++++++++++++++++++++++++ 2 files changed, 438 insertions(+) create mode 100644 internal/plugin/registry.go create mode 100644 internal/plugin/registry_test.go diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go new file mode 100644 index 0000000..28ceac2 --- /dev/null +++ b/internal/plugin/registry.go @@ -0,0 +1,107 @@ +package plugin + +import ( + "fmt" + "sync" +) + +type Registry struct { + services map[string]ServicePlugin + launchers map[string]AppLauncher + executors map[string]TestExecutor + mu sync.RWMutex +} + +func NewRegistry() *Registry { + return &Registry{ + services: make(map[string]ServicePlugin), + launchers: make(map[string]AppLauncher), + executors: make(map[string]TestExecutor), + } +} + +func (r *Registry) RegisterService(plugin ServicePlugin) error { + r.mu.Lock() + defer r.mu.Unlock() + + name := plugin.Name() + if _, exists := r.services[name]; exists { + return fmt.Errorf("service plugin %s already registered", name) + } + + r.services[name] = plugin + return nil +} + +func (r *Registry) GetService(name string) (ServicePlugin, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + plugin, exists := r.services[name] + if !exists { + return nil, fmt.Errorf("service plugin %s not found", name) + } + + return plugin, nil +} + +func (r *Registry) ListServices() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + names := make([]string, 0, len(r.services)) + for name := range r.services { + names = append(names, name) + } + return names +} + +func (r *Registry) RegisterLauncher(plugin AppLauncher) error { + r.mu.Lock() + defer r.mu.Unlock() + + tech := plugin.Technology() + if _, exists := r.launchers[tech]; exists { + return fmt.Errorf("app launcher %s already registered", tech) + } + + r.launchers[tech] = plugin + return nil +} + +func (r *Registry) GetLauncher(technology string) (AppLauncher, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + plugin, exists := r.launchers[technology] + if !exists { + return nil, fmt.Errorf("app launcher %s not found", technology) + } + + return plugin, nil +} + +func (r *Registry) RegisterExecutor(plugin TestExecutor) error { + r.mu.Lock() + defer r.mu.Unlock() + + framework := plugin.Framework() + if _, exists := r.executors[framework]; exists { + return fmt.Errorf("test executor %s already registered", framework) + } + + r.executors[framework] = plugin + return nil +} + +func (r *Registry) GetExecutor(framework string) (TestExecutor, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + plugin, exists := r.executors[framework] + if !exists { + return nil, fmt.Errorf("test executor %s not found", framework) + } + + return plugin, nil +} diff --git a/internal/plugin/registry_test.go b/internal/plugin/registry_test.go new file mode 100644 index 0000000..628e2b8 --- /dev/null +++ b/internal/plugin/registry_test.go @@ -0,0 +1,331 @@ +package plugin + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockServicePlugin struct { + name string +} + +func (m *mockServicePlugin) Name() string { return m.name } +func (m *mockServicePlugin) Launch(ctx context.Context, config map[string]interface{}) error { + return nil +} +func (m *mockServicePlugin) IsReady(ctx context.Context) (bool, error) { return true, nil } +func (m *mockServicePlugin) Stop(ctx context.Context) error { return nil } +func (m *mockServicePlugin) GetConnectionInfo() (*ConnectionInfo, error) { + return &ConnectionInfo{Host: "localhost", Port: 8080}, nil +} +func (m *mockServicePlugin) GetLogs(ctx context.Context, opts *LogOptions) ([]string, error) { + return []string{"log1", "log2"}, nil +} + +type mockAppLauncher struct { + tech string +} + +func (m *mockAppLauncher) Technology() string { return m.tech } +func (m *mockAppLauncher) Launch(ctx context.Context, config *AppConfig) error { return nil } +func (m *mockAppLauncher) IsReady(ctx context.Context) (bool, error) { return true, nil } +func (m *mockAppLauncher) Stop(ctx context.Context) error { return nil } +func (m *mockAppLauncher) Restart(ctx context.Context) error { return nil } +func (m *mockAppLauncher) GetPID() (int, error) { return 1234, nil } + +type mockTestExecutor struct { + framework string +} + +func (m *mockTestExecutor) Framework() string { return m.framework } +func (m *mockTestExecutor) Execute(ctx context.Context, config *TestConfig) (*TestResult, error) { + return &TestResult{Total: 10, Passed: 10}, nil +} +func (m *mockTestExecutor) Cancel(ctx context.Context) error { return nil } +func (m *mockTestExecutor) GetProgress(ctx context.Context) (*TestProgress, error) { + return &TestProgress{Current: 5, Total: 10}, nil +} + +func TestNewRegistry(t *testing.T) { + registry := NewRegistry() + assert.NotNil(t, registry) + assert.NotNil(t, registry.services) + assert.NotNil(t, registry.launchers) + assert.NotNil(t, registry.executors) +} + +func TestRegistry_RegisterService(t *testing.T) { + registry := NewRegistry() + + t.Run("register new service", func(t *testing.T) { + mock := &mockServicePlugin{name: "couchbase"} + err := registry.RegisterService(mock) + assert.NoError(t, err) + }) + + t.Run("register duplicate service", func(t *testing.T) { + mock := &mockServicePlugin{name: "couchbase"} + err := registry.RegisterService(mock) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already registered") + }) + + t.Run("register multiple services", func(t *testing.T) { + mocks := []*mockServicePlugin{ + {name: "kafka"}, + {name: "postgresql"}, + {name: "pubsub"}, + } + + for _, mock := range mocks { + err := registry.RegisterService(mock) + assert.NoError(t, err) + } + }) +} + +func TestRegistry_GetService(t *testing.T) { + registry := NewRegistry() + mock := &mockServicePlugin{name: "mountebank"} + registry.RegisterService(mock) + + t.Run("get existing service", func(t *testing.T) { + service, err := registry.GetService("mountebank") + assert.NoError(t, err) + assert.NotNil(t, service) + assert.Equal(t, "mountebank", service.Name()) + }) + + t.Run("get non-existent service", func(t *testing.T) { + service, err := registry.GetService("nonexistent") + assert.Error(t, err) + assert.Nil(t, service) + assert.Contains(t, err.Error(), "not found") + }) +} + +func TestRegistry_ListServices(t *testing.T) { + registry := NewRegistry() + + t.Run("list empty registry", func(t *testing.T) { + services := registry.ListServices() + assert.NotNil(t, services) + assert.Empty(t, services) + }) + + t.Run("list multiple services", func(t *testing.T) { + mocks := []*mockServicePlugin{ + {name: "couchbase"}, + {name: "kafka"}, + {name: "postgresql"}, + } + + for _, mock := range mocks { + registry.RegisterService(mock) + } + + services := registry.ListServices() + assert.Len(t, services, 3) + assert.Contains(t, services, "couchbase") + assert.Contains(t, services, "kafka") + assert.Contains(t, services, "postgresql") + }) +} + +func TestRegistry_RegisterLauncher(t *testing.T) { + registry := NewRegistry() + + t.Run("register new launcher", func(t *testing.T) { + mock := &mockAppLauncher{tech: "golang"} + err := registry.RegisterLauncher(mock) + assert.NoError(t, err) + }) + + t.Run("register duplicate launcher", func(t *testing.T) { + mock := &mockAppLauncher{tech: "golang"} + err := registry.RegisterLauncher(mock) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already registered") + }) + + t.Run("register multiple launchers", func(t *testing.T) { + launchers := []*mockAppLauncher{ + {tech: "nodejs"}, + {tech: "generic"}, + } + + for _, launcher := range launchers { + err := registry.RegisterLauncher(launcher) + assert.NoError(t, err) + } + }) +} + +func TestRegistry_GetLauncher(t *testing.T) { + registry := NewRegistry() + mock := &mockAppLauncher{tech: "nodejs"} + registry.RegisterLauncher(mock) + + t.Run("get existing launcher", func(t *testing.T) { + launcher, err := registry.GetLauncher("nodejs") + assert.NoError(t, err) + assert.NotNil(t, launcher) + assert.Equal(t, "nodejs", launcher.Technology()) + }) + + t.Run("get non-existent launcher", func(t *testing.T) { + launcher, err := registry.GetLauncher("rust") + assert.Error(t, err) + assert.Nil(t, launcher) + assert.Contains(t, err.Error(), "not found") + }) +} + +func TestRegistry_RegisterExecutor(t *testing.T) { + registry := NewRegistry() + + t.Run("register new executor", func(t *testing.T) { + mock := &mockTestExecutor{framework: "karate"} + err := registry.RegisterExecutor(mock) + assert.NoError(t, err) + }) + + t.Run("register duplicate executor", func(t *testing.T) { + mock := &mockTestExecutor{framework: "karate"} + err := registry.RegisterExecutor(mock) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already registered") + }) + + t.Run("register multiple executors", func(t *testing.T) { + mock := &mockTestExecutor{framework: "cypress"} + err := registry.RegisterExecutor(mock) + assert.NoError(t, err) + }) +} + +func TestRegistry_GetExecutor(t *testing.T) { + registry := NewRegistry() + mock := &mockTestExecutor{framework: "test-launcher-back"} + registry.RegisterExecutor(mock) + + t.Run("get existing executor", func(t *testing.T) { + executor, err := registry.GetExecutor("test-launcher-back") + assert.NoError(t, err) + assert.NotNil(t, executor) + assert.Equal(t, "test-launcher-back", executor.Framework()) + }) + + t.Run("get non-existent executor", func(t *testing.T) { + executor, err := registry.GetExecutor("jest") + assert.Error(t, err) + assert.Nil(t, executor) + assert.Contains(t, err.Error(), "not found") + }) +} + +func TestRegistry_ConcurrentAccess(t *testing.T) { + registry := NewRegistry() + + // Test concurrent service registration and retrieval + t.Run("concurrent service operations", func(t *testing.T) { + done := make(chan bool, 10) + + // Concurrent registrations + for i := 0; i < 5; i++ { + go func(id int) { + mock := &mockServicePlugin{name: fmt.Sprintf("service%d", id)} + registry.RegisterService(mock) + done <- true + }(i) + } + + // Concurrent retrievals + for i := 0; i < 5; i++ { + go func(id int) { + registry.GetService(fmt.Sprintf("service%d", id)) + done <- true + }(i) + } + + // Wait for all goroutines + for i := 0; i < 10; i++ { + <-done + } + + // Verify all services were registered + services := registry.ListServices() + assert.GreaterOrEqual(t, len(services), 5) + }) +} + +func TestRegistry_FullWorkflow(t *testing.T) { + registry := NewRegistry() + + // Register all types of plugins + service := &mockServicePlugin{name: "couchbase"} + launcher := &mockAppLauncher{tech: "golang"} + executor := &mockTestExecutor{framework: "karate"} + + err := registry.RegisterService(service) + require.NoError(t, err) + + err = registry.RegisterLauncher(launcher) + require.NoError(t, err) + + err = registry.RegisterExecutor(executor) + require.NoError(t, err) + + // Retrieve and verify + retrievedService, err := registry.GetService("couchbase") + require.NoError(t, err) + assert.Equal(t, service, retrievedService) + + retrievedLauncher, err := registry.GetLauncher("golang") + require.NoError(t, err) + assert.Equal(t, launcher, retrievedLauncher) + + retrievedExecutor, err := registry.GetExecutor("karate") + require.NoError(t, err) + assert.Equal(t, executor, retrievedExecutor) +} + +func BenchmarkRegistry_RegisterService(b *testing.B) { + registry := NewRegistry() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + mock := &mockServicePlugin{name: fmt.Sprintf("service%d", i)} + registry.RegisterService(mock) + } +} + +func BenchmarkRegistry_GetService(b *testing.B) { + registry := NewRegistry() + mock := &mockServicePlugin{name: "test"} + registry.RegisterService(mock) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + registry.GetService("test") + } +} + +func BenchmarkRegistry_ListServices(b *testing.B) { + registry := NewRegistry() + + // Register 100 services + for i := 0; i < 100; i++ { + mock := &mockServicePlugin{name: fmt.Sprintf("service%d", i)} + registry.RegisterService(mock) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + registry.ListServices() + } +} From ecffe65a7130002729932e40cfcf4d5ace3aed44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Sat, 8 Nov 2025 18:26:21 +0100 Subject: [PATCH 05/19] feature: Add configuration types and default implementation for gtool --- pkg/config/types.go | 70 ++++++++++++++ pkg/config/types_test.go | 193 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 pkg/config/types.go create mode 100644 pkg/config/types_test.go diff --git a/pkg/config/types.go b/pkg/config/types.go new file mode 100644 index 0000000..730b613 --- /dev/null +++ b/pkg/config/types.go @@ -0,0 +1,70 @@ +package config + +import "time" + +type Config struct { + Version string `yaml:"version" json:"version"` + AppTechnology string `yaml:"app-technology" json:"app-technology"` + AppConfig AppConfig `yaml:"app-config" json:"app-config"` + TestLauncher string `yaml:"test-launcher" json:"test-launcher"` + TestConfig TestConfig `yaml:"test-config" json:"test-config"` + ThirdParty ThirdPartyConfig `yaml:"third-party" json:"third-party"` + Orchestration OrchestrationConfig `yaml:"orchestration" json:"orchestration"` + Observability ObservabilityConfig `yaml:"observability" json:"observability"` +} + +type AppConfig struct { + BinaryName string `yaml:"binary-name" json:"binary-name"` + BinaryPath string `yaml:"binary-path" json:"binary-path"` + DockerImage string `yaml:"docker-image" json:"docker-image"` + Port int `yaml:"port" json:"port"` + Environment map[string]string `yaml:"environment" json:"environment"` +} + +type TestConfig struct { + Tags string `yaml:"tags" json:"tags"` + FeaturesPath string `yaml:"features-path" json:"features-path"` + ReportsPath string `yaml:"reports-path" json:"reports-path"` + Parallel bool `yaml:"parallel" json:"parallel"` +} + +type ThirdPartyConfig struct { + Mocks []string `yaml:"mocks" json:"mocks"` + MockConfig map[string]interface{} `yaml:"mock-config" json:"mock-config"` +} + +type OrchestrationConfig struct { + ParallelMocks bool `yaml:"parallel-mocks" json:"parallel-mocks"` + StartupTimeout time.Duration `yaml:"startup-timeout" json:"startup-timeout"` + HealthCheckInterval time.Duration `yaml:"health-check-interval" json:"health-check-interval"` + HealthCheckRetries int `yaml:"health-check-retries" json:"health-check-retries"` + CleanupOnFailure bool `yaml:"cleanup-on-failure" json:"cleanup-on-failure"` + PreserveLogs bool `yaml:"preserve-logs" json:"preserve-logs"` +} + +type ObservabilityConfig struct { + StructuredLogs bool `yaml:"structured-logs" json:"structured-logs"` + LogLevel string `yaml:"log-level" json:"log-level"` + MetricsEnabled bool `yaml:"metrics-enabled" json:"metrics-enabled"` + ReportFormat string `yaml:"report-format" json:"report-format"` +} + +func DefaultConfig() *Config { + return &Config{ + Version: "v1", + Orchestration: OrchestrationConfig{ + ParallelMocks: true, + StartupTimeout: 180 * time.Second, + HealthCheckInterval: 3 * time.Second, + HealthCheckRetries: 60, + CleanupOnFailure: true, + PreserveLogs: true, + }, + Observability: ObservabilityConfig{ + StructuredLogs: false, + LogLevel: "info", + MetricsEnabled: true, + ReportFormat: "text", + }, + } +} diff --git a/pkg/config/types_test.go b/pkg/config/types_test.go new file mode 100644 index 0000000..24bc2a7 --- /dev/null +++ b/pkg/config/types_test.go @@ -0,0 +1,193 @@ +package config + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + + assert.NotNil(t, cfg) + assert.Equal(t, "v1", cfg.Version) + assert.True(t, cfg.Orchestration.ParallelMocks) + assert.Equal(t, 180*time.Second, cfg.Orchestration.StartupTimeout) + assert.Equal(t, 3*time.Second, cfg.Orchestration.HealthCheckInterval) + assert.Equal(t, 60, cfg.Orchestration.HealthCheckRetries) + assert.True(t, cfg.Orchestration.CleanupOnFailure) + assert.True(t, cfg.Orchestration.PreserveLogs) + assert.False(t, cfg.Observability.StructuredLogs) + assert.Equal(t, "info", cfg.Observability.LogLevel) + assert.True(t, cfg.Observability.MetricsEnabled) + assert.Equal(t, "text", cfg.Observability.ReportFormat) +} + +func TestConfigStructure(t *testing.T) { + cfg := &Config{ + Version: "v1", + AppTechnology: "golang", + AppConfig: AppConfig{ + BinaryName: "myapp", + BinaryPath: "./bin", + Port: 8080, + Environment: map[string]string{ + "LOG_LEVEL": "debug", + }, + }, + TestLauncher: "test-launcher-back", + TestConfig: TestConfig{ + Tags: "@smoke", + FeaturesPath: "./features", + ReportsPath: "./reports", + Parallel: true, + }, + ThirdParty: ThirdPartyConfig{ + Mocks: []string{"couchbase", "pubsub"}, + MockConfig: map[string]interface{}{ + "couchbase": map[string]interface{}{ + "bucket": "test", + }, + }, + }, + Orchestration: OrchestrationConfig{ + ParallelMocks: true, + StartupTimeout: 180 * time.Second, + HealthCheckInterval: 3 * time.Second, + HealthCheckRetries: 60, + CleanupOnFailure: true, + PreserveLogs: true, + }, + Observability: ObservabilityConfig{ + StructuredLogs: true, + LogLevel: "debug", + MetricsEnabled: true, + ReportFormat: "json", + }, + } + + // Test basic fields + assert.Equal(t, "v1", cfg.Version) + assert.Equal(t, "golang", cfg.AppTechnology) + assert.Equal(t, "test-launcher-back", cfg.TestLauncher) + + // Test app config + assert.Equal(t, "myapp", cfg.AppConfig.BinaryName) + assert.Equal(t, "./bin", cfg.AppConfig.BinaryPath) + assert.Equal(t, 8080, cfg.AppConfig.Port) + assert.Equal(t, "debug", cfg.AppConfig.Environment["LOG_LEVEL"]) + + // Test test config + assert.Equal(t, "@smoke", cfg.TestConfig.Tags) + assert.Equal(t, "./features", cfg.TestConfig.FeaturesPath) + assert.Equal(t, "./reports", cfg.TestConfig.ReportsPath) + assert.True(t, cfg.TestConfig.Parallel) + + // Test third party + assert.Len(t, cfg.ThirdParty.Mocks, 2) + assert.Contains(t, cfg.ThirdParty.Mocks, "couchbase") + assert.Contains(t, cfg.ThirdParty.Mocks, "pubsub") + + // Test orchestration + assert.True(t, cfg.Orchestration.ParallelMocks) + assert.Equal(t, 180*time.Second, cfg.Orchestration.StartupTimeout) + assert.Equal(t, 3*time.Second, cfg.Orchestration.HealthCheckInterval) + assert.Equal(t, 60, cfg.Orchestration.HealthCheckRetries) + + // Test observability + assert.True(t, cfg.Observability.StructuredLogs) + assert.Equal(t, "debug", cfg.Observability.LogLevel) + assert.True(t, cfg.Observability.MetricsEnabled) + assert.Equal(t, "json", cfg.Observability.ReportFormat) +} + +func TestAppConfig(t *testing.T) { + appCfg := AppConfig{ + BinaryName: "test-app", + BinaryPath: "/usr/local/bin", + DockerImage: "myapp:latest", + Port: 3000, + Environment: map[string]string{ + "ENV": "test", + "LOG_LEVEL": "info", + }, + } + + assert.Equal(t, "test-app", appCfg.BinaryName) + assert.Equal(t, "/usr/local/bin", appCfg.BinaryPath) + assert.Equal(t, "myapp:latest", appCfg.DockerImage) + assert.Equal(t, 3000, appCfg.Port) + assert.Len(t, appCfg.Environment, 2) +} + +func TestTestConfig(t *testing.T) { + testCfg := TestConfig{ + Tags: "@integration", + FeaturesPath: "/app/features", + ReportsPath: "/app/reports", + Parallel: false, + } + + assert.Equal(t, "@integration", testCfg.Tags) + assert.Equal(t, "/app/features", testCfg.FeaturesPath) + assert.Equal(t, "/app/reports", testCfg.ReportsPath) + assert.False(t, testCfg.Parallel) +} + +func TestThirdPartyConfig(t *testing.T) { + thirdParty := ThirdPartyConfig{ + Mocks: []string{"kafka", "postgresql"}, + MockConfig: map[string]interface{}{ + "kafka": map[string]interface{}{ + "topics": []string{"events", "logs"}, + }, + "postgresql": map[string]interface{}{ + "database": "testdb", + }, + }, + } + + assert.Len(t, thirdParty.Mocks, 2) + assert.Len(t, thirdParty.MockConfig, 2) + assert.Contains(t, thirdParty.MockConfig, "kafka") + assert.Contains(t, thirdParty.MockConfig, "postgresql") +} + +func TestOrchestrationConfig(t *testing.T) { + orch := OrchestrationConfig{ + ParallelMocks: false, + StartupTimeout: 300 * time.Second, + HealthCheckInterval: 5 * time.Second, + HealthCheckRetries: 30, + CleanupOnFailure: false, + PreserveLogs: false, + } + + assert.False(t, orch.ParallelMocks) + assert.Equal(t, 300*time.Second, orch.StartupTimeout) + assert.Equal(t, 5*time.Second, orch.HealthCheckInterval) + assert.Equal(t, 30, orch.HealthCheckRetries) + assert.False(t, orch.CleanupOnFailure) + assert.False(t, orch.PreserveLogs) +} + +func TestObservabilityConfig(t *testing.T) { + obs := ObservabilityConfig{ + StructuredLogs: true, + LogLevel: "warn", + MetricsEnabled: false, + ReportFormat: "html", + } + + assert.True(t, obs.StructuredLogs) + assert.Equal(t, "warn", obs.LogLevel) + assert.False(t, obs.MetricsEnabled) + assert.Equal(t, "html", obs.ReportFormat) +} + +func BenchmarkDefaultConfig(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = DefaultConfig() + } +} From 1027b838d93e481102b08fa32defde792dcb791b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Sat, 8 Nov 2025 18:27:01 +0100 Subject: [PATCH 06/19] feature: Define plugin interfaces for service, app launcher, and test executor --- internal/plugin/interface.go | 85 ++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 internal/plugin/interface.go diff --git a/internal/plugin/interface.go b/internal/plugin/interface.go new file mode 100644 index 0000000..d7a3f30 --- /dev/null +++ b/internal/plugin/interface.go @@ -0,0 +1,85 @@ +package plugin + +import ( + "context" + "time" +) + +type ServicePlugin interface { + Name() string + Launch(ctx context.Context, config map[string]interface{}) error + IsReady(ctx context.Context) (bool, error) + Stop(ctx context.Context) error + GetConnectionInfo() (*ConnectionInfo, error) + GetLogs(ctx context.Context, opts *LogOptions) ([]string, error) +} + +type AppLauncher interface { + Technology() string + Launch(ctx context.Context, config *AppConfig) error + IsReady(ctx context.Context) (bool, error) + Stop(ctx context.Context) error + Restart(ctx context.Context) error + GetPID() (int, error) +} + +type TestExecutor interface { + Framework() string + Execute(ctx context.Context, config *TestConfig) (*TestResult, error) + Cancel(ctx context.Context) error + GetProgress(ctx context.Context) (*TestProgress, error) +} + +type ConnectionInfo struct { + Host string + Port int + Protocol string + Metadata map[string]string +} + +type LogOptions struct { + Since time.Time + Until time.Time + Tail int + Follow bool +} + +type AppConfig struct { + BinaryName string + BinaryPath string + DockerImage string + Port int + Environment map[string]string + WorkDir string +} + +type TestConfig struct { + Tags []string + FeaturesPath string + ReportsPath string + Parallel bool + Environment map[string]string +} + +type TestResult struct { + Total int + Passed int + Failed int + Skipped int + Duration time.Duration + ReportURL string + Failures []TestFailure +} + +type TestFailure struct { + Name string + Feature string + Message string + Stack string +} + +type TestProgress struct { + Current int + Total int + Running string +} From c92f766666b5ed0c4812a505927863bc8f3e4ff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Sat, 8 Nov 2025 18:27:39 +0100 Subject: [PATCH 07/19] feature: Add services management commands for mock services in gtool --- internal/cli/services/services.go | 411 ++++++++++++++++++++++++++++++ 1 file changed, 411 insertions(+) create mode 100644 internal/cli/services/services.go diff --git a/internal/cli/services/services.go b/internal/cli/services/services.go new file mode 100644 index 0000000..62548ac --- /dev/null +++ b/internal/cli/services/services.go @@ -0,0 +1,411 @@ +package services + +import ( + "context" + "fmt" + "os" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + "go.uber.org/zap" + + coreConfig "github.com/oswaldo-montano/gtool/internal/core/config" + "github.com/oswaldo-montano/gtool/internal/core/mock" + "github.com/oswaldo-montano/gtool/internal/infra/docker" + "github.com/oswaldo-montano/gtool/internal/plugin" + pluginServices "github.com/oswaldo-montano/gtool/internal/plugin/services" + "github.com/oswaldo-montano/gtool/pkg/config" + "github.com/oswaldo-montano/gtool/pkg/logger" +) + +var ( + followLogs bool + allLogs bool + tailLines int + cfgFile string +) + +func NewServicesCmd(configFile *string) *cobra.Command { + cmd := &cobra.Command{ + Use: "services", + Aliases: []string{"s"}, + Short: "Manage mock services (PostgreSQL, Kafka, etc.)", + Long: `Manage mock services lifecycle. + +Services are Docker containers that provide dependencies for testing: + - PostgreSQL + - Couchbase + - Kafka + - Mountebank + - Pub/Sub + - GCS + +Examples: + gtool services up # Start all configured services + gtool s up postgresql # Start only PostgreSQL + gtool s down # Stop all services + gtool s status # Show services status + gtool s logs postgresql # View PostgreSQL logs`, + } + + // Store reference to config file + if configFile != nil { + cfgFile = *configFile + } + + // Create subcommands + upCmd := newServicesUpCmd() + downCmd := newServicesDownCmd() + statusCmd := newServicesStatusCmd() + logsCmd := newServicesLogsCmd() + + // Add subcommands + cmd.AddCommand(upCmd, downCmd, statusCmd, logsCmd) + + return cmd +} + +func newServicesUpCmd() *cobra.Command { + return &cobra.Command{ + Use: "up [service...]", + Short: "Start mock services", + Long: `Start one or more mock services. + +If no services are specified, starts all services defined in the configuration file. + +Examples: + gtool services up # Start all services from config + gtool s up postgresql # Start only PostgreSQL + gtool s up postgresql kafka # Start PostgreSQL and Kafka + gtool s up --config my-config.yml # Use specific config file`, + RunE: runServicesUp, + } +} + +func newServicesDownCmd() *cobra.Command { + return &cobra.Command{ + Use: "down [service...]", + Short: "Stop mock services", + Long: `Stop one or more running mock services. + +If no services are specified, stops all running services. + +Examples: + gtool services down # Stop all services + gtool s down postgresql # Stop only PostgreSQL + gtool s down postgresql kafka # Stop PostgreSQL and Kafka`, + RunE: runServicesDown, + } +} + +func newServicesStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Aliases: []string{"ps"}, + Short: "Show services status", + Long: `Display the status of all managed services. + +Shows which services are running, stopped, or in error state. + +Examples: + gtool services status # Show all services + gtool s status # Short form + gtool s ps # Docker-like alias`, + RunE: runServicesStatus, + } +} + +func newServicesLogsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "logs [service]", + Short: "View service logs", + Long: `Display logs from a service. + +If no service is specified, shows logs from all services. + +Examples: + gtool services logs postgresql # View PostgreSQL logs + gtool s logs postgresql -f # Follow PostgreSQL logs + gtool s logs --all # View all services logs + gtool s logs postgresql --tail 100 # Last 100 lines`, + RunE: runServicesLogs, + } + + // Add flags + cmd.Flags().BoolVarP(&followLogs, "follow", "f", false, "follow log output") + cmd.Flags().BoolVar(&allLogs, "all", false, "show logs from all services") + cmd.Flags().IntVar(&tailLines, "tail", 100, "number of lines to show from the end of the logs") + + return cmd +} + +func runServicesUp(cmd *cobra.Command, args []string) error { + ctx := context.Background() + log := logger.Default() + defer log.Sync() + + cfg, err := loadConfigOrDefault(cfgFile) + if err != nil { + return fmt.Errorf("failed to load configuration: %w", err) + } + + dockerClient, err := docker.NewClient(log.Logger) + if err != nil { + return fmt.Errorf("failed to create Docker client: %w", err) + } + defer dockerClient.Close() + + if err := dockerClient.Ping(ctx); err != nil { + return fmt.Errorf("Docker daemon not available: %w", err) + } + + registry := plugin.NewRegistry() + if err := pluginServices.RegisterAll(registry, dockerClient, log.Logger); err != nil { + return fmt.Errorf("failed to register plugins: %w", err) + } + + mockManager := mock.NewManager(registry, log.Logger, cfg.Orchestration, dockerClient) + + servicesToStart := args + if len(servicesToStart) == 0 { + servicesToStart = cfg.ThirdParty.Mocks + } + + if len(servicesToStart) == 0 { + return fmt.Errorf("no services specified and no services configured") + } + + log.Info("starting services", zap.Strings("services", servicesToStart)) + + for _, serviceName := range servicesToStart { + fmt.Printf("🚀 Starting %s...\n", serviceName) + serviceConfig := cfg.ThirdParty.MockConfig[serviceName] + var configMap map[string]interface{} + if serviceConfig == nil { + configMap = make(map[string]interface{}) + } else { + var ok bool + configMap, ok = serviceConfig.(map[string]interface{}) + if !ok { + return fmt.Errorf("invalid configuration for service %s", serviceName) + } + } + + if err := mockManager.Start(ctx, serviceName, configMap); err != nil { + return fmt.Errorf("failed to start %s: %w", serviceName, err) + } + + fmt.Printf("✅ %s started successfully\n", serviceName) + } + + fmt.Printf("\n✨ All services started!\n\n") + fmt.Printf("Use 'gtool s status' to check services status\n") + fmt.Printf("Use 'gtool s logs ' to view logs\n") + fmt.Printf("Use 'gtool s down' to stop all services\n") + + return nil +} + +func runServicesDown(cmd *cobra.Command, args []string) error { + ctx := context.Background() + log := logger.Default() + defer log.Sync() + + cfg, err := loadConfigOrDefault(cfgFile) + if err != nil { + return fmt.Errorf("failed to load configuration: %w", err) + } + + dockerClient, err := docker.NewClient(log.Logger) + if err != nil { + return fmt.Errorf("failed to create Docker client: %w", err) + } + defer dockerClient.Close() + + registry := plugin.NewRegistry() + if err := pluginServices.RegisterAll(registry, dockerClient, log.Logger); err != nil { + return fmt.Errorf("failed to register plugins: %w", err) + } + + mockManager := mock.NewManager(registry, log.Logger, cfg.Orchestration, dockerClient) + servicesToStop := args + if len(servicesToStop) == 0 { + servicesToStop = mockManager.ListRunning() + } + + if len(servicesToStop) == 0 { + fmt.Println("No services to stop") + return nil + } + + log.Info("stopping services", zap.Strings("services", servicesToStop)) + + // Stop services + for _, serviceName := range servicesToStop { + fmt.Printf("🛑 Stopping %s...\n", serviceName) + + if err := mockManager.Stop(ctx, serviceName); err != nil { + fmt.Printf("⚠️ Failed to stop %s: %v\n", serviceName, err) + continue + } + + fmt.Printf("✅ %s stopped\n", serviceName) + } + + fmt.Printf("\n✨ Services stopped\n") + + return nil +} + +func runServicesStatus(cmd *cobra.Command, args []string) error { + ctx := context.Background() + log := zap.NewNop() // Silent logger for status + + cfg, err := loadConfigOrDefault(cfgFile) + if err != nil { + return fmt.Errorf("failed to load configuration: %w", err) + } + + dockerClient, err := docker.NewClient(log) + if err != nil { + return fmt.Errorf("failed to create Docker client: %w", err) + } + defer dockerClient.Close() + + registry := plugin.NewRegistry() + if err := pluginServices.RegisterAll(registry, dockerClient, log); err != nil { + return fmt.Errorf("failed to register plugins: %w", err) + } + + mockManager := mock.NewManager(registry, log, cfg.Orchestration, dockerClient) + + statuses := mockManager.GetAllStatuses(ctx) + + if len(statuses) == 0 { + fmt.Println("No services found") + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + fmt.Fprintln(w, "SERVICE\tSTATUS\tUPTIME\tPORT") + fmt.Fprintln(w, "-------\t------\t------\t----") + + for _, status := range statuses { + uptimeStr := "-" + if status.Uptime > 0 { + uptimeStr = formatDuration(status.Uptime) + } + + portStr := "-" + if status.Port > 0 { + portStr = fmt.Sprintf("%d", status.Port) + } + + statusIcon := "●" + statusColor := status.Status + if status.Status == "running" { + statusColor = "running ✓" + } else if status.Status == "stopped" { + statusColor = "stopped ●" + } else if status.Status == "error" { + statusColor = "error ✗" + } + + fmt.Fprintf(w, "%s\t%s %s\t%s\t%s\n", + status.Name, statusIcon, statusColor, uptimeStr, portStr) + } + + w.Flush() + + return nil +} + +func runServicesLogs(cmd *cobra.Command, args []string) error { + ctx := context.Background() + log := zap.NewNop() // Silent logger for status + + if len(args) == 0 && !allLogs { + return fmt.Errorf("please specify a service or use --all flag") + } + + cfg, err := loadConfigOrDefault(cfgFile) + if err != nil { + return fmt.Errorf("failed to load configuration: %w", err) + } + + dockerClient, err := docker.NewClient(log) + if err != nil { + return fmt.Errorf("failed to create Docker client: %w", err) + } + defer dockerClient.Close() + + registry := plugin.NewRegistry() + if err := pluginServices.RegisterAll(registry, dockerClient, log); err != nil { + return fmt.Errorf("failed to register plugins: %w", err) + } + + mockManager := mock.NewManager(registry, log, cfg.Orchestration, dockerClient) + + servicesToLog := args + if allLogs { + servicesToLog = mockManager.ListRunning() + } + + if len(servicesToLog) == 0 { + return fmt.Errorf("no running services found") + } + + for _, serviceName := range servicesToLog { + logs, err := mockManager.GetLogs(ctx, serviceName, &plugin.LogOptions{ + Tail: tailLines, + Follow: followLogs, + }) + + if err != nil { + fmt.Printf("Failed to get logs for %s: %v\n", serviceName, err) + continue + } + + if len(servicesToLog) > 1 { + fmt.Printf("\n=== %s ===\n", serviceName) + } + + for _, line := range logs { + fmt.Println(line) + } + } + + return nil +} + +func formatDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dm", int(d.Minutes())) + } + if d < 24*time.Hour { + return fmt.Sprintf("%dh", int(d.Hours())) + } + return fmt.Sprintf("%dd", int(d.Hours()/24)) +} + +func loadConfigOrDefault(cfgFile string) (*config.Config, error) { + if cfgFile != "" { + cfg, err := coreConfig.LoadConfig(cfgFile) + if err != nil { + return nil, err + } + return cfg, nil + } + + cfg, err := coreConfig.LoadConfig("") + if err != nil { + fmt.Println("ℹ️ No configuration file found, using defaults") + return config.DefaultConfig(), nil + } + + return cfg, nil +} From 7a8980cf1dd1dfd6208a9ed45bcf7b47f7a30cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Sat, 8 Nov 2025 18:27:51 +0100 Subject: [PATCH 08/19] feature: Add configuration validator for gtool --- internal/core/config/validator.go | 187 ++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 internal/core/config/validator.go diff --git a/internal/core/config/validator.go b/internal/core/config/validator.go new file mode 100644 index 0000000..708f7be --- /dev/null +++ b/internal/core/config/validator.go @@ -0,0 +1,187 @@ +package config + +import ( + "fmt" + "os" + + "github.com/oswaldo-montano/gtool/pkg/config" + gtErrors "github.com/oswaldo-montano/gtool/pkg/errors" +) + +var ( + supportedVersions = []string{"v1"} + supportedTechnologies = []string{"golang", "nodejs", "generic"} + supportedLaunchers = []string{"test-launcher-back", "test-launcher-front"} + supportedMocks = []string{"mountebank", "couchbase", "postgresql", "kafka", "pubsub", "gcs"} +) + +type Validator struct { + checkPaths bool +} + +func NewValidator() *Validator { + return &Validator{ + checkPaths: true, + } +} + +func (v *Validator) Validate(cfg *config.Config) error { + var errors []string + + if err := v.validateVersion(cfg); err != nil { + errors = append(errors, err.Error()) + } + + if err := v.validateAppTechnology(cfg); err != nil { + errors = append(errors, err.Error()) + } + + if err := v.validateTestLauncher(cfg); err != nil { + errors = append(errors, err.Error()) + } + + if err := v.validateAppConfig(cfg); err != nil { + errors = append(errors, err.Error()) + } + + if err := v.validateTestConfig(cfg); err != nil { + errors = append(errors, err.Error()) + } + + if err := v.validateMockServices(cfg); err != nil { + errors = append(errors, err.Error()) + } + + if err := v.validateOrchestration(cfg); err != nil { + errors = append(errors, err.Error()) + } + + if len(errors) > 0 { + return gtErrors.New(gtErrors.ErrConfigInvalid, + fmt.Sprintf("configuration validation failed:\n - %s", joinErrors(errors))) + } + + return nil +} + +func (v *Validator) validateVersion(cfg *config.Config) error { + if cfg.Version == "" { + return fmt.Errorf("version is required") + } + + if !contains(supportedVersions, cfg.Version) { + return fmt.Errorf("unsupported version '%s'. Supported: %v", cfg.Version, supportedVersions) + } + + return nil +} + +func (v *Validator) validateAppTechnology(cfg *config.Config) error { + if cfg.AppTechnology == "" { + return fmt.Errorf("app-technology is required") + } + + if !contains(supportedTechnologies, cfg.AppTechnology) { + return fmt.Errorf("unsupported app-technology '%s'. Supported: %v", cfg.AppTechnology, supportedTechnologies) + } + + return nil +} + +func (v *Validator) validateTestLauncher(cfg *config.Config) error { + if cfg.TestLauncher == "" { + return fmt.Errorf("test-launcher is required") + } + + if !contains(supportedLaunchers, cfg.TestLauncher) { + return fmt.Errorf("unsupported test-launcher '%s'. Supported: %v", cfg.TestLauncher, supportedLaunchers) + } + + return nil +} + +func (v *Validator) validateAppConfig(cfg *config.Config) error { + appCfg := cfg.AppConfig + + if appCfg.DockerImage == "" { + if cfg.AppTechnology == "golang" { + if appCfg.BinaryName == "" { + return fmt.Errorf("app-config.binary-name is required for golang technology") + } + } + } + + if appCfg.Port < 0 || appCfg.Port > 65535 { + return fmt.Errorf("app-config.port must be between 0 and 65535") + } + + return nil +} + +func (v *Validator) validateTestConfig(cfg *config.Config) error { + testCfg := cfg.TestConfig + + if v.checkPaths { + if testCfg.FeaturesPath != "" { + if _, err := os.Stat(testCfg.FeaturesPath); os.IsNotExist(err) { + return fmt.Errorf("test-config.features-path does not exist: %s", testCfg.FeaturesPath) + } + } + } + + return nil +} + +func (v *Validator) validateMockServices(cfg *config.Config) error { + for _, mock := range cfg.ThirdParty.Mocks { + if !contains(supportedMocks, mock) { + return fmt.Errorf("unsupported mock service '%s'. Supported: %v", mock, supportedMocks) + } + } + + // Validate mock-specific configurations + + return nil +} + +func (v *Validator) validateOrchestration(cfg *config.Config) error { + orch := cfg.Orchestration + + if orch.StartupTimeout <= 0 { + return fmt.Errorf("orchestration.startup-timeout must be positive") + } + + if orch.HealthCheckInterval <= 0 { + return fmt.Errorf("orchestration.health-check-interval must be positive") + } + + if orch.HealthCheckRetries <= 0 { + return fmt.Errorf("orchestration.health-check-retries must be positive") + } + + return nil +} + +func (v *Validator) SetCheckPaths(check bool) { + v.checkPaths = check +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +func joinErrors(errors []string) string { + result := "" + for i, err := range errors { + if i > 0 { + result += "\n - " + } + result += err + } + return result +} From d37b8c988558e5468326d4d55d13e5b25bf3ed82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Sat, 8 Nov 2025 18:28:02 +0100 Subject: [PATCH 09/19] feature: Implement service registration for PostgreSQL plugin --- internal/plugin/services/init.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 internal/plugin/services/init.go diff --git a/internal/plugin/services/init.go b/internal/plugin/services/init.go new file mode 100644 index 0000000..48efefa --- /dev/null +++ b/internal/plugin/services/init.go @@ -0,0 +1,19 @@ +package services + +import ( + "go.uber.org/zap" + + "github.com/oswaldo-montano/gtool/internal/infra/docker" + "github.com/oswaldo-montano/gtool/internal/plugin" + "github.com/oswaldo-montano/gtool/internal/plugin/services/postgresql" +) + +func RegisterAll(registry *plugin.Registry, dockerClient *docker.Client, logger *zap.Logger) error { + postgresPlugin := postgresql.NewPostgreSQLPlugin(dockerClient, logger) + if err := registry.RegisterService(postgresPlugin); err != nil { + return err + } + + logger.Info("all service plugins registered successfully") + return nil +} From 8a182966bd23c448680217fb18b47f0dec72011f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Sat, 8 Nov 2025 18:28:14 +0100 Subject: [PATCH 10/19] feature: Implement Docker client for container management and PostgreSQL integration tests --- internal/infra/docker/client.go | 429 ++++++++++++++++++ .../services/postgresql/integration_test.go | 163 +++++++ 2 files changed, 592 insertions(+) create mode 100644 internal/infra/docker/client.go create mode 100644 internal/plugin/services/postgresql/integration_test.go diff --git a/internal/infra/docker/client.go b/internal/infra/docker/client.go new file mode 100644 index 0000000..7d64628 --- /dev/null +++ b/internal/infra/docker/client.go @@ -0,0 +1,429 @@ +package docker + +import ( + "bytes" + "context" + "fmt" + "io" + "time" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/client" + "github.com/docker/docker/pkg/stdcopy" + "github.com/docker/go-connections/nat" + "go.uber.org/zap" + + gtErrors "github.com/oswaldo-montano/gtool/pkg/errors" +) + +type Client struct { + cli *client.Client + logger *zap.Logger +} + +type ContainerConfig struct { + Image string + Name string + Env []string + PortBindings map[string]string + Mounts []Mount + NetworkMode string + AutoRemove bool + Labels map[string]string +} + +type Mount struct { + Type string + Source string + Target string + ReadOnly bool +} + +type ExecConfig struct { + Cmd []string + AttachStdout bool + AttachStderr bool + WorkingDir string + Env []string +} + +func NewClient(logger *zap.Logger) (*Client, error) { + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to create Docker client") + } + + if logger == nil { + logger = zap.NewNop() + } + + return &Client{ + cli: cli, + logger: logger, + }, nil +} + +func (c *Client) Close() error { + return c.cli.Close() +} + +func (c *Client) PullImage(ctx context.Context, imageName string) error { + c.logger.Info("pulling Docker image", zap.String("image", imageName)) + + reader, err := c.cli.ImagePull(ctx, imageName, image.PullOptions{}) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, fmt.Sprintf("failed to pull image %s", imageName)) + } + defer reader.Close() + + _, err = io.Copy(io.Discard, reader) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to read pull response") + } + + c.logger.Info("successfully pulled image", zap.String("image", imageName)) + return nil +} + +func (c *Client) CreateContainer(ctx context.Context, config *ContainerConfig) (string, error) { + c.logger.Info("creating container", + zap.String("image", config.Image), + zap.String("name", config.Name)) + + // Build port bindings + portBindings := nat.PortMap{} + exposedPorts := nat.PortSet{} + for containerPort, hostPort := range config.PortBindings { + port, err := nat.NewPort("tcp", containerPort) + if err != nil { + return "", gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "invalid port specification") + } + exposedPorts[port] = struct{}{} + portBindings[port] = []nat.PortBinding{ + { + HostIP: "0.0.0.0", + HostPort: hostPort, + }, + } + } + + // Build mounts + var mounts []mount.Mount + for _, m := range config.Mounts { + mounts = append(mounts, mount.Mount{ + Type: mount.Type(m.Type), + Source: m.Source, + Target: m.Target, + ReadOnly: m.ReadOnly, + }) + } + + // Create container + containerConfig := &container.Config{ + Image: config.Image, + Env: config.Env, + ExposedPorts: exposedPorts, + Labels: config.Labels, + } + + hostConfig := &container.HostConfig{ + PortBindings: portBindings, + Mounts: mounts, + AutoRemove: config.AutoRemove, + } + + if config.NetworkMode != "" { + hostConfig.NetworkMode = container.NetworkMode(config.NetworkMode) + } + + resp, err := c.cli.ContainerCreate( + ctx, + containerConfig, + hostConfig, + &network.NetworkingConfig{}, + nil, + config.Name, + ) + if err != nil { + return "", gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to create container") + } + + c.logger.Info("container created", + zap.String("containerID", resp.ID), + zap.String("name", config.Name)) + + return resp.ID, nil +} + +func (c *Client) StartContainer(ctx context.Context, containerID string) error { + c.logger.Info("starting container", zap.String("containerID", containerID)) + + if err := c.cli.ContainerStart(ctx, containerID, container.StartOptions{}); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to start container") + } + + c.logger.Info("container started", zap.String("containerID", containerID)) + return nil +} + +func (c *Client) StopContainer(ctx context.Context, containerID string, timeout *int) error { + c.logger.Info("stopping container", zap.String("containerID", containerID)) + + stopTimeout := 10 + if timeout != nil { + stopTimeout = *timeout + } + + if err := c.cli.ContainerStop(ctx, containerID, container.StopOptions{ + Timeout: &stopTimeout, + }); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to stop container") + } + + c.logger.Info("container stopped", zap.String("containerID", containerID)) + return nil +} + +func (c *Client) RemoveContainer(ctx context.Context, containerID string, force bool) error { + c.logger.Info("removing container", + zap.String("containerID", containerID), + zap.Bool("force", force)) + + err := c.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{ + Force: force, + RemoveVolumes: true, + }) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to remove container") + } + + c.logger.Info("container removed", zap.String("containerID", containerID)) + return nil +} + +func (c *Client) GetContainerLogs(ctx context.Context, containerID string, tail int) (string, error) { + options := container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + Tail: fmt.Sprintf("%d", tail), + } + + reader, err := c.cli.ContainerLogs(ctx, containerID, options) + if err != nil { + return "", gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to get container logs") + } + defer reader.Close() + + // Docker logs come with 8-byte headers, use stdcopy to demultiplex + var stdout, stderr bytes.Buffer + written, err := stdcopy.StdCopy(&stdout, &stderr, reader) + + c.logger.Debug("log demultiplex result", + zap.Int64("bytes_written", written), + zap.Int("stdout_len", stdout.Len()), + zap.Int("stderr_len", stderr.Len()), + zap.Error(err)) + + // If stdcopy didn't read anything or failed, fall back to raw read + if err != nil || written == 0 { + if err != nil { + c.logger.Warn("failed to demultiplex logs, reading raw", zap.Error(err)) + } else { + c.logger.Debug("stdcopy read 0 bytes, trying raw read") + } + + // Reopen reader + reader2, err2 := c.cli.ContainerLogs(ctx, containerID, options) + if err2 != nil { + return "", gtErrors.Wrap(err2, gtErrors.ErrDockerFailed, "failed to get container logs (retry)") + } + defer reader2.Close() + + logs, err3 := io.ReadAll(reader2) + if err3 != nil { + return "", gtErrors.Wrap(err3, gtErrors.ErrDockerFailed, "failed to read container logs") + } + c.logger.Debug("raw read result", zap.Int("bytes", len(logs))) + return string(logs), nil + } + + // Combine stdout and stderr + combined := stdout.String() + stderr.String() + c.logger.Debug("combined logs length", zap.Int("length", len(combined))) + return combined, nil +} + +func (c *Client) ListContainersByLabels(ctx context.Context, labels map[string]string) ([]types.Container, error) { + // Build filter string + filters := filters.NewArgs() + for key, value := range labels { + filters.Add("label", fmt.Sprintf("%s=%s", key, value)) + } + + c.logger.Debug("listing containers by labels", zap.Any("labels", labels)) + + containers, err := c.cli.ContainerList(ctx, container.ListOptions{ + All: true, + Filters: filters, + }) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list containers") + } + + c.logger.Debug("found containers", zap.Int("count", len(containers))) + return containers, nil +} + +func (c *Client) ExecInContainer(ctx context.Context, containerID string, config *ExecConfig) (string, error) { + c.logger.Info("executing command in container", + zap.String("containerID", containerID), + zap.Strings("cmd", config.Cmd)) + + execConfig := types.ExecConfig{ + AttachStdout: config.AttachStdout, + AttachStderr: config.AttachStderr, + Cmd: config.Cmd, + WorkingDir: config.WorkingDir, + Env: config.Env, + } + + execID, err := c.cli.ContainerExecCreate(ctx, containerID, execConfig) + if err != nil { + return "", gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to create exec instance") + } + + resp, err := c.cli.ContainerExecAttach(ctx, execID.ID, types.ExecStartCheck{}) + if err != nil { + return "", gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to attach to exec instance") + } + defer resp.Close() + + output, err := io.ReadAll(resp.Reader) + if err != nil { + return "", gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to read exec output") + } + + // Check exec exit code + inspectResp, err := c.cli.ContainerExecInspect(ctx, execID.ID) + if err != nil { + return string(output), gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to inspect exec instance") + } + + if inspectResp.ExitCode != 0 { + return string(output), gtErrors.New(gtErrors.ErrDockerFailed, + fmt.Sprintf("command exited with code %d: %s", inspectResp.ExitCode, string(output))) + } + + return string(output), nil +} + +func (c *Client) WaitForContainer(ctx context.Context, containerID string, condition container.WaitCondition) error { + c.logger.Info("waiting for container", + zap.String("containerID", containerID), + zap.String("condition", string(condition))) + + statusCh, errCh := c.cli.ContainerWait(ctx, containerID, condition) + + select { + case err := <-errCh: + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "error waiting for container") + } + case <-statusCh: + c.logger.Info("container reached desired state", zap.String("containerID", containerID)) + case <-ctx.Done(): + return gtErrors.New(gtErrors.ErrDockerFailed, "context cancelled while waiting for container") + } + + return nil +} + +func (c *Client) InspectContainer(ctx context.Context, containerID string) (*types.ContainerJSON, error) { + inspect, err := c.cli.ContainerInspect(ctx, containerID) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to inspect container") + } + return &inspect, nil +} + +func (c *Client) IsContainerRunning(ctx context.Context, containerID string) (bool, error) { + inspect, err := c.InspectContainer(ctx, containerID) + if err != nil { + return false, err + } + return inspect.State.Running, nil +} + +func (c *Client) Ping(ctx context.Context) error { + _, err := c.cli.Ping(ctx) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to ping Docker daemon") + } + return nil +} + +func (c *Client) CopyToContainer(ctx context.Context, containerID, targetPath string, content io.Reader) error { + c.logger.Info("copying to container", + zap.String("containerID", containerID), + zap.String("targetPath", targetPath)) + + err := c.cli.CopyToContainer(ctx, containerID, targetPath, content, types.CopyToContainerOptions{}) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to copy to container") + } + + return nil +} + +func (c *Client) WaitForHealthy(ctx context.Context, containerID string, interval time.Duration, retries int) error { + c.logger.Info("waiting for container to be healthy", + zap.String("containerID", containerID), + zap.Duration("interval", interval), + zap.Int("retries", retries)) + + for i := 0; i < retries; i++ { + inspect, err := c.InspectContainer(ctx, containerID) + if err != nil { + return err + } + + if inspect.State.Running { + // If no health check is defined, just check if running + if inspect.State.Health == nil { + c.logger.Info("container is running (no health check defined)", + zap.String("containerID", containerID)) + return nil + } + + // Check health status + if inspect.State.Health.Status == "healthy" { + c.logger.Info("container is healthy", zap.String("containerID", containerID)) + return nil + } + + c.logger.Debug("container not healthy yet", + zap.String("containerID", containerID), + zap.String("status", inspect.State.Health.Status), + zap.Int("attempt", i+1)) + } else { + c.logger.Debug("container not running", + zap.String("containerID", containerID), + zap.Int("attempt", i+1)) + } + + select { + case <-ctx.Done(): + return gtErrors.New(gtErrors.ErrDockerFailed, "context cancelled while waiting for healthy state") + case <-time.After(interval): + // Continue to next attempt + } + } + + return gtErrors.New(gtErrors.ErrDockerFailed, + fmt.Sprintf("container did not become healthy after %d attempts", retries)) +} diff --git a/internal/plugin/services/postgresql/integration_test.go b/internal/plugin/services/postgresql/integration_test.go new file mode 100644 index 0000000..0920731 --- /dev/null +++ b/internal/plugin/services/postgresql/integration_test.go @@ -0,0 +1,163 @@ +//go:build integration +// +build integration + +package postgresql + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/oswaldo-montano/gtool/internal/infra/docker" + "github.com/oswaldo-montano/gtool/pkg/logger" +) + +func TestPostgreSQLIntegration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + log := logger.NewDevelopment() + defer log.Sync() + + dockerClient, err := docker.NewClient(log) + require.NoError(t, err, "Failed to create Docker client") + defer dockerClient.Close() + + ctx := context.Background() + err = dockerClient.Ping(ctx) + require.NoError(t, err, "Docker daemon not available") + + plugin := NewPostgreSQLPlugin(dockerClient, log) + require.NotNil(t, plugin) + + config := map[string]interface{}{ + "image": "postgres:16-alpine", + "port": "15432", + "user": "integrationtest", + "password": "testpass123", + "database": "testdb", + "scripts-path": "../../../../test/component/mocks-data/postgresql", + } + + log.Info("launching PostgreSQL for integration test") + err = plugin.Launch(ctx, config) + require.NoError(t, err, "Failed to launch PostgreSQL") + + defer func() { + log.Info("cleaning up PostgreSQL container") + if err := plugin.Stop(ctx); err != nil { + t.Logf("Failed to stop PostgreSQL: %v", err) + } + }() + + t.Run("IsReady", func(t *testing.T) { + ready, err := plugin.IsReady(ctx) + require.NoError(t, err) + assert.True(t, ready, "PostgreSQL should be ready") + }) + + t.Run("GetConnectionInfo", func(t *testing.T) { + connInfo, err := plugin.GetConnectionInfo() + require.NoError(t, err) + assert.NotNil(t, connInfo) + assert.Equal(t, "localhost", connInfo.Host) + assert.Equal(t, 15432, connInfo.Port) + assert.Equal(t, "postgresql", connInfo.Protocol) + assert.Equal(t, "integrationtest", connInfo.Metadata["user"]) + assert.Equal(t, "testpass123", connInfo.Metadata["password"]) + assert.Equal(t, "testdb", connInfo.Metadata["database"]) + }) + + t.Run("GetLogs", func(t *testing.T) { + logs, err := plugin.GetLogs(ctx, nil) + require.NoError(t, err) + assert.NotEmpty(t, logs, "Should have logs") + }) + + t.Run("VerifyScripts", func(t *testing.T) { + time.Sleep(2 * time.Second) + + output, err := dockerClient.ExecInContainer(ctx, plugin.containerID, &docker.ExecConfig{ + Cmd: []string{ + "psql", + "-U", "integrationtest", + "-d", "testdb", + "-c", "SELECT COUNT(*) FROM users;", + }, + AttachStdout: true, + AttachStderr: true, + Env: []string{ + "PGPASSWORD=testpass123", + }, + }) + require.NoError(t, err, "Failed to query users table: %s", output) + assert.Contains(t, output, "2", "Should have 2 users from init script") + + output, err = dockerClient.ExecInContainer(ctx, plugin.containerID, &docker.ExecConfig{ + Cmd: []string{ + "psql", + "-U", "integrationtest", + "-d", "testdb", + "-c", "SELECT COUNT(*) FROM products;", + }, + AttachStdout: true, + AttachStderr: true, + Env: []string{ + "PGPASSWORD=testpass123", + }, + }) + require.NoError(t, err, "Failed to query products table: %s", output) + assert.Contains(t, output, "2", "Should have 2 products from init script") + }) + + t.Run("Stop", func(t *testing.T) { + err := plugin.Stop(ctx) + require.NoError(t, err, "Failed to stop PostgreSQL") + + running, err := dockerClient.IsContainerRunning(ctx, plugin.containerID) + if plugin.containerID != "" { + require.Error(t, err) + } + assert.False(t, running, "Container should not be running") + }) +} + +func TestPostgreSQLIntegrationWithoutScripts(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + log := zap.NewNop() + + dockerClient, err := docker.NewClient(log) + require.NoError(t, err) + defer dockerClient.Close() + + ctx := context.Background() + + plugin := NewPostgreSQLPlugin(dockerClient, log) + + config := map[string]interface{}{ + "port": "25432", + "user": "testuser", + "password": "testpass", + "database": "testdb", + } + + err = plugin.Launch(ctx, config) + require.NoError(t, err) + + defer plugin.Stop(ctx) + + ready, err := plugin.IsReady(ctx) + require.NoError(t, err) + assert.True(t, ready) + + err = plugin.Stop(ctx) + require.NoError(t, err) +} From 8e37c3234872ebc6abfd7b24a57c2c46375f8671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Sat, 8 Nov 2025 18:28:33 +0100 Subject: [PATCH 11/19] feature: Add initial configuration file for GTOOL component testing pipeline --- .../generate/templates/component-config.yml | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 internal/cli/generate/templates/component-config.yml diff --git a/internal/cli/generate/templates/component-config.yml b/internal/cli/generate/templates/component-config.yml new file mode 100644 index 0000000..cfdb870 --- /dev/null +++ b/internal/cli/generate/templates/component-config.yml @@ -0,0 +1,132 @@ +# GTOOL Configuration File +# This file configures the component testing pipeline +# Documentation: https://github.com/oswaldo-montano/gtool + +# ═══════════════════════════════════════════════════════════ +# VERSION +# ═══════════════════════════════════════════════════════════ +version: v1 + +# ═══════════════════════════════════════════════════════════ +# APPLICATION CONFIGURATION +# ═══════════════════════════════════════════════════════════ +# Technology: golang, nodejs, generic +app-technology: golang + +app-config: + # Binary or application name + binary-name: myapp + + # Path to the binary (for golang/generic) + binary-path: ./bin/myapp + + # Docker image (alternative to binary) + # docker-image: myapp:latest + + # Application port + port: 8080 + + # Environment variables for the application + environment: + LOG_LEVEL: debug + # DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres + +# ═══════════════════════════════════════════════════════════ +# TEST CONFIGURATION +# ═══════════════════════════════════════════════════════════ +# Test launcher: test-launcher-back (Karate), test-launcher-front (Cypress) +test-launcher: test-launcher-back + +test-config: + # Test tags to run (e.g., "@smoke", "@integration") + tags: "@integration" + + # Path to feature files + features-path: ./test/features + + # Path for test reports + reports-path: ./test/reports + + # Run tests in parallel + parallel: false + +# ═══════════════════════════════════════════════════════════ +# THIRD-PARTY SERVICES (MOCKS) +# ═══════════════════════════════════════════════════════════ +third-party: + # List of services to start + # Available: postgresql, couchbase, kafka, mountebank, pubsub, gcs + mocks: + - postgresql + # - mountebank + # - kafka + + # Configuration for each service + mock-config: + # PostgreSQL configuration + postgresql: + image: postgres:16-alpine + port: 5432 + user: postgres + password: postgres + database: postgres + # Path to SQL initialization scripts + scripts-path: ./test/component/mocks-data/postgresql + + # Mountebank configuration (commented example) + # mountebank: + # port: 2525 + # imposters-path: ./test/component/mocks-data/mountebank + + # Couchbase configuration (commented example) + # couchbase: + # image: couchbase:community-7.2.0 + # bucket: test-bucket + # username: Administrator + # password: password + + # Kafka configuration (commented example) + # kafka: + # image: confluentinc/cp-kafka:latest + # port: 9092 + # topics: + # - test-topic + # - events-topic + +# ═══════════════════════════════════════════════════════════ +# ORCHESTRATION SETTINGS +# ═══════════════════════════════════════════════════════════ +orchestration: + # Start mocks in parallel (faster) or sequential (safer) + parallel-mocks: true + + # Maximum time to wait for services to start + startup-timeout: 180s + + # Interval between health checks + health-check-interval: 3s + + # Maximum number of health check retries + health-check-retries: 60 + + # Clean up services if startup fails + cleanup-on-failure: true + + # Preserve logs after cleanup + preserve-logs: true + +# ═══════════════════════════════════════════════════════════ +# OBSERVABILITY +# ═══════════════════════════════════════════════════════════ +observability: + # Use structured JSON logs + structured-logs: false + + # Log level: debug, info, warn, error + log-level: info + + # Enable metrics collection + metrics-enabled: true + + # Report format: text, json + report-format: text From ce6729b4fbb4a62cb5362129fbf0148a7a5f2d3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Sat, 8 Nov 2025 18:29:30 +0100 Subject: [PATCH 12/19] feature: Add logger implementation with configurable levels and context support --- pkg/logger/logger.go | 46 ++++++++++++ pkg/logger/logger_test.go | 146 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 pkg/logger/logger.go create mode 100644 pkg/logger/logger_test.go diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..c6faa3d --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,46 @@ +package logger + +import ( + "context" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +type Logger struct { + *zap.Logger +} + +func New(level string, structured bool) (*Logger, error) { + var config zap.Config + + if structured { + config = zap.NewProductionConfig() + } else { + config = zap.NewDevelopmentConfig() + config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + } + + lvl, err := zapcore.ParseLevel(level) + if err != nil { + return nil, err + } + config.Level = zap.NewAtomicLevelAt(lvl) + + zapLogger, err := config.Build() + if err != nil { + return nil, err + } + + return &Logger{Logger: zapLogger}, nil +} + +func (l *Logger) WithContext(ctx context.Context, fields ...zap.Field) *zap.Logger { + // TODO: Extract fields from context (trace ID, request ID, etc.) + return l.Logger.With(fields...) +} + +func Default() *Logger { + logger, _ := New("info", false) + return logger +} diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go new file mode 100644 index 0000000..4d79d06 --- /dev/null +++ b/pkg/logger/logger_test.go @@ -0,0 +1,146 @@ +package logger + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestNew(t *testing.T) { + tests := []struct { + name string + level string + structured bool + wantErr bool + }{ + { + name: "valid info level structured", + level: "info", + structured: true, + wantErr: false, + }, + { + name: "valid debug level", + level: "debug", + structured: false, + wantErr: false, + }, + { + name: "valid warn level", + level: "warn", + structured: true, + wantErr: false, + }, + { + name: "valid error level", + level: "error", + structured: false, + wantErr: false, + }, + { + name: "invalid level", + level: "invalid", + structured: true, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logger, err := New(tt.level, tt.structured) + + if tt.wantErr { + assert.Error(t, err) + assert.Nil(t, logger) + } else { + assert.NoError(t, err) + assert.NotNil(t, logger) + assert.NotNil(t, logger.Logger) + } + }) + } +} + +func TestDefault(t *testing.T) { + logger := Default() + assert.NotNil(t, logger) + assert.NotNil(t, logger.Logger) +} + +func TestWithContext(t *testing.T) { + logger, err := New("info", true) + require.NoError(t, err) + require.NotNil(t, logger) + + ctx := context.Background() + field1 := zap.String("key1", "value1") + field2 := zap.Int("key2", 42) + + contextLogger := logger.WithContext(ctx, field1, field2) + assert.NotNil(t, contextLogger) +} + +func TestLoggerLevels(t *testing.T) { + levels := []string{"debug", "info", "warn", "error"} + + for _, level := range levels { + t.Run(level, func(t *testing.T) { + logger, err := New(level, true) + require.NoError(t, err) + require.NotNil(t, logger) + + assert.NotPanics(t, func() { + logger.Debug("debug message") + logger.Info("info message") + logger.Warn("warn message") + logger.Error("error message") + }) + }) + } +} + +func TestLoggerStructuredVsPlain(t *testing.T) { + t.Run("structured logger", func(t *testing.T) { + logger, err := New("info", true) + require.NoError(t, err) + require.NotNil(t, logger) + + assert.NotPanics(t, func() { + logger.Info("test message", zap.String("key", "value")) + }) + }) + + t.Run("plain logger", func(t *testing.T) { + logger, err := New("info", false) + require.NoError(t, err) + require.NotNil(t, logger) + + assert.NotPanics(t, func() { + logger.Info("test message", zap.String("key", "value")) + }) + }) +} + +func BenchmarkNew(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = New("info", true) + } +} + +func BenchmarkDefault(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = Default() + } +} + +func BenchmarkLogging(b *testing.B) { + logger, _ := New("info", true) + b.ResetTimer() + + for i := 0; i < b.N; i++ { + logger.Info("benchmark message", zap.Int("iteration", i)) + } +} From 111ea2e55306a594948c8292c20af981e8bf3215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Sat, 8 Nov 2025 18:29:44 +0100 Subject: [PATCH 13/19] feature: Implement PostgreSQL service plugin with Docker integration --- .../plugin/services/postgresql/postgresql.go | 467 ++++++++++++++++++ .../services/postgresql/postgresql_test.go | 254 ++++++++++ 2 files changed, 721 insertions(+) create mode 100644 internal/plugin/services/postgresql/postgresql.go create mode 100644 internal/plugin/services/postgresql/postgresql_test.go diff --git a/internal/plugin/services/postgresql/postgresql.go b/internal/plugin/services/postgresql/postgresql.go new file mode 100644 index 0000000..7a72361 --- /dev/null +++ b/internal/plugin/services/postgresql/postgresql.go @@ -0,0 +1,467 @@ +package postgresql + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/docker/docker/api/types" + "github.com/oswaldo-montano/gtool/internal/infra/docker" + "github.com/oswaldo-montano/gtool/internal/plugin" + gtErrors "github.com/oswaldo-montano/gtool/pkg/errors" + "go.uber.org/zap" +) + +const ( + defaultImage = "postgres:16-alpine" + defaultPort = "5432" + defaultUser = "postgres" + defaultPassword = "postgres" + defaultDatabase = "postgres" + containerNamePrefix = "gtool-postgresql" +) + +// PostgreSQLPlugin implements the ServicePlugin interface for PostgreSQL +type PostgreSQLPlugin struct { + docker *docker.Client + logger *zap.Logger + containerID string + config *PostgreSQLConfig +} + +// PostgreSQLConfig holds PostgreSQL-specific configuration +type PostgreSQLConfig struct { + Image string `json:"image"` + Port string `json:"port"` + User string `json:"user"` + Password string `json:"password"` + Database string `json:"database"` + ScriptsPath string `json:"scripts-path"` + ContainerName string `json:"container-name"` +} + +// NewPostgreSQLPlugin creates a new PostgreSQL service plugin +func NewPostgreSQLPlugin(dockerClient *docker.Client, logger *zap.Logger) *PostgreSQLPlugin { + if logger == nil { + logger = zap.NewNop() + } + + return &PostgreSQLPlugin{ + docker: dockerClient, + logger: logger, + } +} + +// Name returns the service identifier +func (p *PostgreSQLPlugin) Name() string { + return "postgresql" +} + +// Launch starts the PostgreSQL service with given configuration +func (p *PostgreSQLPlugin) Launch(ctx context.Context, config map[string]interface{}) error { + p.logger.Info("launching PostgreSQL service") + + // Parse configuration + cfg, err := p.parseConfig(config) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrInvalidArgument, "failed to parse PostgreSQL configuration") + } + p.config = cfg + + // Pull image + p.logger.Info("pulling PostgreSQL image", zap.String("image", cfg.Image)) + if err := p.docker.PullImage(ctx, cfg.Image); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to pull PostgreSQL image") + } + + // Create container + containerConfig := &docker.ContainerConfig{ + Image: cfg.Image, + Name: cfg.ContainerName, + Env: []string{ + fmt.Sprintf("POSTGRES_USER=%s", cfg.User), + fmt.Sprintf("POSTGRES_PASSWORD=%s", cfg.Password), + fmt.Sprintf("POSTGRES_DB=%s", cfg.Database), + }, + PortBindings: map[string]string{ + "5432": cfg.Port, + }, + Labels: map[string]string{ + "managed-by": "gtool", + "service": "postgresql", + }, + } + + p.logger.Info("creating PostgreSQL container", + zap.String("name", cfg.ContainerName), + zap.String("port", cfg.Port)) + + containerID, err := p.docker.CreateContainer(ctx, containerConfig) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to create PostgreSQL container") + } + p.containerID = containerID + + // Start container + p.logger.Info("starting PostgreSQL container", zap.String("containerID", containerID)) + if err := p.docker.StartContainer(ctx, containerID); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to start PostgreSQL container") + } + + // Wait for PostgreSQL to be ready + p.logger.Info("waiting for PostgreSQL to be ready") + if err := p.waitForReady(ctx); err != nil { + // Cleanup on failure + _ = p.Stop(ctx) + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, "PostgreSQL did not become ready") + } + + // Execute SQL scripts if provided + if cfg.ScriptsPath != "" { + p.logger.Info("executing SQL scripts", zap.String("path", cfg.ScriptsPath)) + if err := p.executeScripts(ctx, cfg.ScriptsPath); err != nil { + // Don't fail if scripts fail, just log the error + p.logger.Error("failed to execute SQL scripts", + zap.Error(err), + zap.String("path", cfg.ScriptsPath)) + } + } + + p.logger.Info("PostgreSQL service launched successfully", + zap.String("containerID", containerID), + zap.String("port", cfg.Port)) + + return nil +} + +// IsReady checks if the PostgreSQL service is ready to accept connections +func (p *PostgreSQLPlugin) IsReady(ctx context.Context) (bool, error) { + if p.containerID == "" { + return false, gtErrors.New(gtErrors.ErrServiceNotRunning, "PostgreSQL container not started") + } + + // Check if container is running + running, err := p.docker.IsContainerRunning(ctx, p.containerID) + if err != nil { + return false, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to check container status") + } + + if !running { + return false, nil + } + + // Check if PostgreSQL is ready by executing pg_isready + output, err := p.docker.ExecInContainer(ctx, p.containerID, &docker.ExecConfig{ + Cmd: []string{"pg_isready", "-U", p.config.User, "-d", p.config.Database}, + AttachStdout: true, + AttachStderr: true, + }) + + if err != nil { + p.logger.Debug("PostgreSQL not ready yet", zap.String("output", output)) + return false, nil + } + + return true, nil +} + +// Stop terminates the PostgreSQL service +func (p *PostgreSQLPlugin) Stop(ctx context.Context) error { + // If no containerID, try to find container by labels + if p.containerID == "" { + // If no Docker client, nothing to stop + if p.docker == nil { + p.logger.Debug("no container ID and no Docker client") + return nil + } + + p.logger.Info("no container ID, searching by labels") + + containers, err := p.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": "postgresql", + }) + + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list PostgreSQL containers") + } + + if len(containers) == 0 { + p.logger.Warn("no PostgreSQL containers found to stop") + return nil + } + + // Stop all matching containers + for _, container := range containers { + p.containerID = container.ID + p.logger.Info("found PostgreSQL container", + zap.String("containerID", container.ID), + zap.Strings("names", container.Names)) + + if err := p.stopContainer(ctx); err != nil { + p.logger.Error("failed to stop container", zap.Error(err), zap.String("containerID", container.ID)) + } + } + + return nil + } + + return p.stopContainer(ctx) +} + +// stopContainer stops and removes a specific container +func (p *PostgreSQLPlugin) stopContainer(ctx context.Context) error { + p.logger.Info("stopping PostgreSQL service", zap.String("containerID", p.containerID)) + + // Stop container + timeout := 10 + if err := p.docker.StopContainer(ctx, p.containerID, &timeout); err != nil { + p.logger.Error("failed to stop container", zap.Error(err)) + // Continue to remove anyway + } + + // Remove container + if err := p.docker.RemoveContainer(ctx, p.containerID, true); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to remove PostgreSQL container") + } + + p.logger.Info("PostgreSQL service stopped successfully") + p.containerID = "" + return nil +} + +// GetConnectionInfo returns connection details +func (p *PostgreSQLPlugin) GetConnectionInfo() (*plugin.ConnectionInfo, error) { + if p.config == nil { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "PostgreSQL service not launched") + } + + return &plugin.ConnectionInfo{ + Host: "localhost", + Port: mustParsePort(p.config.Port), + Protocol: "postgresql", + Metadata: map[string]string{ + "user": p.config.User, + "password": p.config.Password, + "database": p.config.Database, + "sslmode": "disable", + }, + }, nil +} + +// GetLogs retrieves service logs +func (p *PostgreSQLPlugin) GetLogs(ctx context.Context, opts *plugin.LogOptions) ([]string, error) { + containerID := p.containerID + + // If no containerID, try to find container by labels + if containerID == "" { + // If no Docker client, cannot get logs + if p.docker == nil { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "no container ID and no Docker client") + } + + p.logger.Info("no container ID for logs, searching by labels") + + containers, err := p.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": "postgresql", + }) + + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to list PostgreSQL containers") + } + + if len(containers) == 0 { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, "PostgreSQL container not found") + } + + // Find first running container + var foundContainer *types.Container + for i := range containers { + if containers[i].State == "running" { + foundContainer = &containers[i] + break + } + } + + if foundContainer == nil { + // Fallback to first container if none are running + foundContainer = &containers[0] + } + + containerID = foundContainer.ID + p.logger.Info("found PostgreSQL container for logs", + zap.String("containerID", containerID), + zap.String("state", foundContainer.State), + zap.Strings("names", foundContainer.Names)) + } + + tail := 100 + if opts != nil && opts.Tail > 0 { + tail = opts.Tail + } + + logs, err := p.docker.GetContainerLogs(ctx, containerID, tail) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to get container logs") + } + + // Split logs into lines + lines := strings.Split(strings.TrimSpace(logs), "\n") + return lines, nil +} + +// parseConfig parses the configuration map into PostgreSQLConfig +func (p *PostgreSQLPlugin) parseConfig(config map[string]interface{}) (*PostgreSQLConfig, error) { + cfg := &PostgreSQLConfig{ + Image: defaultImage, + Port: defaultPort, + User: defaultUser, + Password: defaultPassword, + Database: defaultDatabase, + ContainerName: fmt.Sprintf("%s-%d", containerNamePrefix, time.Now().Unix()), + } + + // Override with provided values + if image, ok := config["image"].(string); ok && image != "" { + cfg.Image = image + } + if port, ok := config["port"].(string); ok && port != "" { + cfg.Port = port + } else if port, ok := config["port"].(float64); ok { + cfg.Port = fmt.Sprintf("%.0f", port) + } + if user, ok := config["user"].(string); ok && user != "" { + cfg.User = user + } + if password, ok := config["password"].(string); ok && password != "" { + cfg.Password = password + } + if database, ok := config["database"].(string); ok && database != "" { + cfg.Database = database + } + if scriptsPath, ok := config["scripts-path"].(string); ok && scriptsPath != "" { + cfg.ScriptsPath = scriptsPath + } + if containerName, ok := config["container-name"].(string); ok && containerName != "" { + cfg.ContainerName = containerName + } + + return cfg, nil +} + +// waitForReady waits for PostgreSQL to be ready +func (p *PostgreSQLPlugin) waitForReady(ctx context.Context) error { + maxRetries := 60 + interval := 2 * time.Second + + for i := 0; i < maxRetries; i++ { + ready, err := p.IsReady(ctx) + if err != nil { + p.logger.Debug("error checking readiness", + zap.Error(err), + zap.Int("attempt", i+1)) + } + + if ready { + p.logger.Info("PostgreSQL is ready", zap.Int("attempts", i+1)) + return nil + } + + select { + case <-ctx.Done(): + return gtErrors.New(gtErrors.ErrServiceFailed, "context cancelled while waiting for PostgreSQL") + case <-time.After(interval): + // Continue to next attempt + } + } + + return gtErrors.New(gtErrors.ErrServiceFailed, + fmt.Sprintf("PostgreSQL did not become ready after %d attempts", maxRetries)) +} + +// executeScripts executes SQL scripts from the specified directory +func (p *PostgreSQLPlugin) executeScripts(ctx context.Context, scriptsPath string) error { + // Check if scripts path exists + if _, err := os.Stat(scriptsPath); os.IsNotExist(err) { + return gtErrors.Wrap(err, gtErrors.ErrConfigInvalid, + fmt.Sprintf("scripts path does not exist: %s", scriptsPath)) + } + + // Get all .sql files + sqlFiles, err := filepath.Glob(filepath.Join(scriptsPath, "*.sql")) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrConfigInvalid, "failed to list SQL files") + } + + if len(sqlFiles) == 0 { + p.logger.Warn("no SQL files found in scripts path", zap.String("path", scriptsPath)) + return nil + } + + p.logger.Info("found SQL files to execute", + zap.Int("count", len(sqlFiles)), + zap.Strings("files", sqlFiles)) + + // Execute each SQL file + for _, sqlFile := range sqlFiles { + if err := p.executeScript(ctx, sqlFile); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, + fmt.Sprintf("failed to execute script: %s", sqlFile)) + } + } + + return nil +} + +// executeScript executes a single SQL script +func (p *PostgreSQLPlugin) executeScript(ctx context.Context, scriptPath string) error { + p.logger.Info("executing SQL script", zap.String("script", scriptPath)) + + // Read the script file + content, err := os.ReadFile(scriptPath) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrConfigInvalid, "failed to read SQL script") + } + + // Execute using psql + cmd := []string{ + "psql", + "-U", p.config.User, + "-d", p.config.Database, + "-c", string(content), + } + + output, err := p.docker.ExecInContainer(ctx, p.containerID, &docker.ExecConfig{ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + Env: []string{ + fmt.Sprintf("PGPASSWORD=%s", p.config.Password), + }, + }) + + if err != nil { + p.logger.Error("failed to execute SQL script", + zap.Error(err), + zap.String("script", scriptPath), + zap.String("output", output)) + return err + } + + p.logger.Info("SQL script executed successfully", + zap.String("script", scriptPath), + zap.String("output", strings.TrimSpace(output))) + + return nil +} + +// mustParsePort parses port string to int, panics on error +func mustParsePort(port string) int { + var p int + fmt.Sscanf(port, "%d", &p) + return p +} diff --git a/internal/plugin/services/postgresql/postgresql_test.go b/internal/plugin/services/postgresql/postgresql_test.go new file mode 100644 index 0000000..98e0d18 --- /dev/null +++ b/internal/plugin/services/postgresql/postgresql_test.go @@ -0,0 +1,254 @@ +package postgresql + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestNewPostgreSQLPlugin(t *testing.T) { + logger := zap.NewNop() + plugin := NewPostgreSQLPlugin(nil, logger) + + assert.NotNil(t, plugin) + assert.Equal(t, "postgresql", plugin.Name()) +} + +func TestName(t *testing.T) { + plugin := NewPostgreSQLPlugin(nil, nil) + assert.Equal(t, "postgresql", plugin.Name()) +} + +func TestParseConfig(t *testing.T) { + tests := []struct { + name string + input map[string]interface{} + want *PostgreSQLConfig + wantErr bool + errContains string + }{ + { + name: "default config", + input: map[string]interface{}{}, + want: &PostgreSQLConfig{ + Image: defaultImage, + Port: defaultPort, + User: defaultUser, + Password: defaultPassword, + Database: defaultDatabase, + }, + wantErr: false, + }, + { + name: "custom config with string port", + input: map[string]interface{}{ + "image": "postgres:15", + "port": "5433", + "user": "myuser", + "password": "mypass", + "database": "mydb", + }, + want: &PostgreSQLConfig{ + Image: "postgres:15", + Port: "5433", + User: "myuser", + Password: "mypass", + Database: "mydb", + }, + wantErr: false, + }, + { + name: "custom config with numeric port", + input: map[string]interface{}{ + "port": float64(5433), + }, + want: &PostgreSQLConfig{ + Image: defaultImage, + Port: "5433", + User: defaultUser, + Password: defaultPassword, + Database: defaultDatabase, + }, + wantErr: false, + }, + { + name: "with scripts path", + input: map[string]interface{}{ + "scripts-path": "/path/to/scripts", + }, + want: &PostgreSQLConfig{ + Image: defaultImage, + Port: defaultPort, + User: defaultUser, + Password: defaultPassword, + Database: defaultDatabase, + ScriptsPath: "/path/to/scripts", + }, + wantErr: false, + }, + { + name: "with container name", + input: map[string]interface{}{ + "container-name": "my-postgres", + }, + want: &PostgreSQLConfig{ + Image: defaultImage, + Port: defaultPort, + User: defaultUser, + Password: defaultPassword, + Database: defaultDatabase, + ContainerName: "my-postgres", + }, + wantErr: false, + }, + { + name: "partial config", + input: map[string]interface{}{ + "user": "customuser", + "database": "customdb", + }, + want: &PostgreSQLConfig{ + Image: defaultImage, + Port: defaultPort, + User: "customuser", + Password: defaultPassword, + Database: "customdb", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := NewPostgreSQLPlugin(nil, nil) + got, err := plugin.parseConfig(tt.input) + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + + require.NoError(t, err) + assert.Equal(t, tt.want.Image, got.Image) + assert.Equal(t, tt.want.Port, got.Port) + assert.Equal(t, tt.want.User, got.User) + assert.Equal(t, tt.want.Password, got.Password) + assert.Equal(t, tt.want.Database, got.Database) + assert.Equal(t, tt.want.ScriptsPath, got.ScriptsPath) + if tt.want.ContainerName != "" { + assert.Equal(t, tt.want.ContainerName, got.ContainerName) + } else { + // Container name should be auto-generated + assert.NotEmpty(t, got.ContainerName) + assert.Contains(t, got.ContainerName, containerNamePrefix) + } + }) + } +} + +func TestGetConnectionInfo(t *testing.T) { + tests := []struct { + name string + config *PostgreSQLConfig + wantErr bool + errContains string + }{ + { + name: "valid config", + config: &PostgreSQLConfig{ + Port: "5432", + User: "testuser", + Password: "testpass", + Database: "testdb", + }, + wantErr: false, + }, + { + name: "no config", + config: nil, + wantErr: true, + errContains: "not launched", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugin := NewPostgreSQLPlugin(nil, nil) + plugin.config = tt.config + + got, err := plugin.GetConnectionInfo() + + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + + require.NoError(t, err) + assert.NotNil(t, got) + assert.Equal(t, "localhost", got.Host) + assert.Equal(t, mustParsePort(tt.config.Port), got.Port) + assert.Equal(t, "postgresql", got.Protocol) + assert.Equal(t, tt.config.User, got.Metadata["user"]) + assert.Equal(t, tt.config.Password, got.Metadata["password"]) + assert.Equal(t, tt.config.Database, got.Metadata["database"]) + assert.Equal(t, "disable", got.Metadata["sslmode"]) + }) + } +} + +func TestMustParsePort(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + { + name: "standard port", + input: "5432", + want: 5432, + }, + { + name: "custom port", + input: "5433", + want: 5433, + }, + { + name: "zero returns zero", + input: "0", + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mustParsePort(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestIsReady_NotStarted(t *testing.T) { + plugin := NewPostgreSQLPlugin(nil, nil) + + // Should return error when container not started + ready, err := plugin.IsReady(nil) + assert.False(t, ready) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not started") +} + +func TestStop_NoContainer(t *testing.T) { + plugin := NewPostgreSQLPlugin(nil, zap.NewNop()) + + // Should not error when no container to stop + err := plugin.Stop(nil) + assert.NoError(t, err) +} From 14be33a8f2b84f46b62ea4e719e20013024c7661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Sat, 8 Nov 2025 18:42:38 +0100 Subject: [PATCH 14/19] feature: Add CI configuration for testing and linting with Go --- .github/workflows/ci.yml | 56 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..25f60af --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + push: + branches: [ '**' ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache: true + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: make test + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: coverage.out + retention-days: 7 + + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache: true + + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + args: --config=configs/golangci-lint.yml --timeout=5m From 68d2dd20b59876483d08c33fb3f13670fe4149c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Sat, 8 Nov 2025 19:36:48 +0100 Subject: [PATCH 15/19] feature: Add golangci-lint configuration for code quality checks --- configs/golangci-lint.yml | 81 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 configs/golangci-lint.yml diff --git a/configs/golangci-lint.yml b/configs/golangci-lint.yml new file mode 100644 index 0000000..8173107 --- /dev/null +++ b/configs/golangci-lint.yml @@ -0,0 +1,81 @@ +run: + timeout: 5m + tests: true + modules-download-mode: readonly + +linters: + enable: + - gosimple # Simplify code + - govet # Vet examines Go source code + - ineffassign # Detect ineffectual assignments + - staticcheck # Static analysis + - unused # Check for unused code + - gofmt # Check formatting + - goimports # Check imports + - misspell # Check for misspelled words + - gocyclo # Check cyclomatic complexity + + disable: + - errcheck # Too many legitimate ignores in codebase + - revive # Name stuttering not critical for now + - goconst # Repeated strings acceptable in this phase + - gosec # Security checks too strict for dev phase + +linters-settings: + errcheck: + check-blank: true + check-type-assertions: false + + govet: + enable-all: true + disable: + - shadow + - fieldalignment # Disabled: memory optimization not critical for now + + gocyclo: + min-complexity: 20 # Increased from 15 for parseConfig-like functions + + staticcheck: + checks: + - all + - -SA1019 # Ignore deprecated APIs (Docker SDK transition) + - -SA1012 # Ignore nil context in tests + + gosec: + excludes: + - G104 # Audit errors not checked + + revive: + rules: + - name: exported + disabled: false + - name: package-comments + disabled: true + +issues: + exclude-rules: + # Exclude some linters from running on tests files + - path: _test\.go + linters: + - gocyclo + - errcheck + - gosec + - goconst + + # Exclude some staticcheck messages + - linters: + - staticcheck + text: "SA9003:" + + max-issues-per-linter: 0 + max-same-issues: 0 + +output: + formats: + - format: colored-line-number + print-issued-lines: true + print-linter-name: true + +# Minimal settings for now, can be expanded later +severity: + default-severity: warning From ec23f5a69136dd0aa78846ff15ec7ac62b963bc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Sat, 8 Nov 2025 19:36:58 +0100 Subject: [PATCH 16/19] feature: Update go.mod to include go-connections dependency --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index ff22f9e..6e48414 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.9 require ( github.com/docker/docker v27.5.0+incompatible + github.com/docker/go-connections v0.6.0 github.com/spf13/cobra v1.10.1 github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.11.1 @@ -16,7 +17,6 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect From aae8780d4ee6953ad1c08fbc2c31ab6aa57c3f6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Sat, 8 Nov 2025 19:42:41 +0100 Subject: [PATCH 17/19] feature: Add application lifecycle management and configuration loader --- internal/core/app/launcher.go | 37 ++ internal/core/config/loader.go | 152 ++++++ internal/core/mock/manager.go | 467 ++++++++++++++++++ internal/core/orchestrator/orchestrator.go | 27 + internal/core/test/executor.go | 21 + internal/plugin/services/postgresql/README.md | 50 ++ 6 files changed, 754 insertions(+) create mode 100644 internal/core/app/launcher.go create mode 100644 internal/core/config/loader.go create mode 100644 internal/core/mock/manager.go create mode 100644 internal/core/orchestrator/orchestrator.go create mode 100644 internal/core/test/executor.go create mode 100644 internal/plugin/services/postgresql/README.md diff --git a/internal/core/app/launcher.go b/internal/core/app/launcher.go new file mode 100644 index 0000000..44d4764 --- /dev/null +++ b/internal/core/app/launcher.go @@ -0,0 +1,37 @@ +package app + +import ( + "context" + + "github.com/oswaldo-montano/gtool/internal/plugin" +) + +// Manager manages application lifecycle +type Manager struct { + launcher plugin.AppLauncher +} + +// NewManager creates a new app manager +func NewManager(launcher plugin.AppLauncher) *Manager { + return &Manager{ + launcher: launcher, + } +} + +// Start starts the application +func (m *Manager) Start(ctx context.Context, config *plugin.AppConfig) error { + // TODO: Implement in Phase 3 + return nil +} + +// Stop stops the application +func (m *Manager) Stop(ctx context.Context) error { + // TODO: Implement in Phase 3 + return nil +} + +// Restart restarts the application +func (m *Manager) Restart(ctx context.Context) error { + // TODO: Implement in Phase 3 + return nil +} diff --git a/internal/core/config/loader.go b/internal/core/config/loader.go new file mode 100644 index 0000000..216ffd4 --- /dev/null +++ b/internal/core/config/loader.go @@ -0,0 +1,152 @@ +package config + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/oswaldo-montano/gtool/pkg/config" + gtErrors "github.com/oswaldo-montano/gtool/pkg/errors" + "gopkg.in/yaml.v3" +) + +// Loader handles configuration loading from files +type Loader struct { + expandEnv bool +} + +// NewLoader creates a new config loader +func NewLoader() *Loader { + return &Loader{ + expandEnv: true, + } +} + +// Load loads configuration from a file +func (l *Loader) Load(path string) (*config.Config, error) { + // Check if file exists + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil, gtErrors.Wrap(err, gtErrors.ErrConfigNotFound, + fmt.Sprintf("configuration file not found: %s", path)) + } + + // Read file + data, err := os.ReadFile(path) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrConfigInvalid, + "failed to read configuration file") + } + + // Expand environment variables if enabled + if l.expandEnv { + data = []byte(l.expandEnvVars(string(data))) + } + + // Parse YAML + cfg := config.DefaultConfig() + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrConfigInvalid, + "failed to parse YAML configuration") + } + + // Apply environment variable overrides + l.applyEnvOverrides(cfg) + + return cfg, nil +} + +// LoadFromPath loads configuration from default paths +func (l *Loader) LoadFromPath() (*config.Config, error) { + // Try default paths + paths := []string{ + "./component-config.yml", + "./component-config.yaml", + "./gtool-config.yml", + "./gtool-config.yaml", + } + + var lastErr error + for _, path := range paths { + if _, err := os.Stat(path); err == nil { + cfg, err := l.Load(path) + if err != nil { + lastErr = err + continue + } + return cfg, nil + } + } + + if lastErr != nil { + return nil, lastErr + } + + return nil, gtErrors.New(gtErrors.ErrConfigNotFound, + "no configuration file found in current directory. Expected: component-config.yml or gtool-config.yml") +} + +// LoadFromPathOrDefault loads configuration or returns default +func (l *Loader) LoadFromPathOrDefault() *config.Config { + cfg, err := l.LoadFromPath() + if err != nil { + return config.DefaultConfig() + } + return cfg +} + +// expandEnvVars expands environment variables in the format ${VAR} or $VAR +func (l *Loader) expandEnvVars(content string) string { + // Pattern matches ${VAR} or $VAR + re := regexp.MustCompile(`\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)`) + + return re.ReplaceAllStringFunc(content, func(match string) string { + // Extract variable name + varName := strings.TrimPrefix(match, "$") + varName = strings.TrimPrefix(varName, "{") + varName = strings.TrimSuffix(varName, "}") + + // Get value from environment + if value := os.Getenv(varName); value != "" { + return value + } + + // Keep original if not found + return match + }) +} + +// applyEnvOverrides applies environment variable overrides to configuration +func (l *Loader) applyEnvOverrides(cfg *config.Config) { + // GTOOL_LOG_LEVEL overrides log level + if logLevel := os.Getenv("GTOOL_LOG_LEVEL"); logLevel != "" { + cfg.Observability.LogLevel = logLevel + } + + // GTOOL_PARALLEL_MOCKS overrides parallel mocks + if parallelMocks := os.Getenv("GTOOL_PARALLEL_MOCKS"); parallelMocks != "" { + cfg.Orchestration.ParallelMocks = parallelMocks == "true" + } + + // GTOOL_DOCKER_IMAGE overrides docker image + if dockerImage := os.Getenv("GTOOL_DOCKER_IMAGE"); dockerImage != "" { + cfg.AppConfig.DockerImage = dockerImage + } +} + +// SetExpandEnv enables or disables environment variable expansion +func (l *Loader) SetExpandEnv(expand bool) { + l.expandEnv = expand +} + +// LoadConfig is a convenience function to load configuration from a file +// If path is empty, it tries to load from default paths +func LoadConfig(path string) (*config.Config, error) { + loader := NewLoader() + + if path == "" { + return loader.LoadFromPath() + } + + return loader.Load(path) +} diff --git a/internal/core/mock/manager.go b/internal/core/mock/manager.go new file mode 100644 index 0000000..08c0a39 --- /dev/null +++ b/internal/core/mock/manager.go @@ -0,0 +1,467 @@ +package mock + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/docker/docker/api/types" + "github.com/oswaldo-montano/gtool/internal/plugin" + "github.com/oswaldo-montano/gtool/pkg/config" + gtErrors "github.com/oswaldo-montano/gtool/pkg/errors" + "go.uber.org/zap" +) + +// ServiceStatus represents the status of a service +type ServiceStatus struct { + Name string + Status string // "running", "stopped", "error", "starting" + Port int + Uptime time.Duration + Error string +} + +// Manager manages mock services lifecycle +type Manager struct { + registry *plugin.Registry + logger *zap.Logger + orchestration config.OrchestrationConfig + services map[string]*serviceState + docker DockerClient + mu sync.RWMutex +} + +// DockerClient interface for Docker operations +type DockerClient interface { + ListContainersByLabels(ctx context.Context, labels map[string]string) ([]types.Container, error) +} + +// serviceState tracks the state of a running service +type serviceState struct { + plugin plugin.ServicePlugin + startTime time.Time + stopped bool + error error +} + +// NewManager creates a new mock manager +func NewManager(registry *plugin.Registry, logger *zap.Logger, orchestration config.OrchestrationConfig, dockerClient DockerClient) *Manager { + if logger == nil { + logger = zap.NewNop() + } + + return &Manager{ + registry: registry, + logger: logger, + orchestration: orchestration, + services: make(map[string]*serviceState), + docker: dockerClient, + } +} + +// Start starts a specific service +func (m *Manager) Start(ctx context.Context, serviceName string, config map[string]interface{}) error { + m.mu.Lock() + defer m.mu.Unlock() + + // Check if already running + if state, exists := m.services[serviceName]; exists && !state.stopped { + m.logger.Warn("service already running", zap.String("service", serviceName)) + return nil + } + + // Get plugin from registry + servicePlugin, err := m.registry.GetService(serviceName) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, + fmt.Sprintf("service %s not found in registry", serviceName)) + } + + m.logger.Info("starting service", zap.String("service", serviceName)) + + // Launch the service + if err := servicePlugin.Launch(ctx, config); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, + fmt.Sprintf("failed to launch service %s", serviceName)) + } + + // Wait for service to be ready + m.logger.Info("waiting for service to be ready", zap.String("service", serviceName)) + + ready := false + for i := 0; i < m.orchestration.HealthCheckRetries; i++ { + isReady, err := servicePlugin.IsReady(ctx) + if err != nil { + m.logger.Debug("health check failed", + zap.String("service", serviceName), + zap.Error(err), + zap.Int("attempt", i+1)) + } + + if isReady { + ready = true + break + } + + select { + case <-ctx.Done(): + return gtErrors.New(gtErrors.ErrServiceFailed, "context cancelled while waiting for service") + case <-time.After(m.orchestration.HealthCheckInterval): + // Continue to next attempt + } + } + + if !ready { + // Cleanup on failure if configured + if m.orchestration.CleanupOnFailure { + _ = servicePlugin.Stop(ctx) + } + return gtErrors.New(gtErrors.ErrServiceNotReady, + fmt.Sprintf("service %s did not become ready within timeout", serviceName)) + } + + // Store service state + m.services[serviceName] = &serviceState{ + plugin: servicePlugin, + startTime: time.Now(), + stopped: false, + } + + m.logger.Info("service started successfully", zap.String("service", serviceName)) + return nil +} + +// Stop stops a specific service +func (m *Manager) Stop(ctx context.Context, serviceName string) error { + m.mu.Lock() + defer m.mu.Unlock() + + // First, check if service is in memory + state, exists := m.services[serviceName] + + // If not in memory, try to find it in Docker and get plugin + if !exists { + m.logger.Info("service not in memory, checking Docker", zap.String("service", serviceName)) + + // Try to find containers for this service + if m.docker != nil { + containers, err := m.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": serviceName, + }) + + if err != nil { + m.logger.Error("failed to list containers", zap.Error(err)) + } else if len(containers) == 0 { + return gtErrors.New(gtErrors.ErrServiceNotRunning, + fmt.Sprintf("service %s is not running", serviceName)) + } + } + + // Get plugin from registry to stop the service + servicePlugin, err := m.registry.GetService(serviceName) + if err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, + fmt.Sprintf("service %s not found in registry", serviceName)) + } + + m.logger.Info("stopping service via plugin", zap.String("service", serviceName)) + + if err := servicePlugin.Stop(ctx); err != nil { + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, + fmt.Sprintf("failed to stop service %s", serviceName)) + } + + m.logger.Info("service stopped", zap.String("service", serviceName)) + return nil + } + + // Service is in memory, stop normally + if state.stopped { + return nil + } + + m.logger.Info("stopping service", zap.String("service", serviceName)) + + if err := state.plugin.Stop(ctx); err != nil { + state.error = err + return gtErrors.Wrap(err, gtErrors.ErrServiceFailed, + fmt.Sprintf("failed to stop service %s", serviceName)) + } + + state.stopped = true + delete(m.services, serviceName) + + m.logger.Info("service stopped", zap.String("service", serviceName)) + return nil +} + +// StopAll stops all running services +func (m *Manager) StopAll(ctx context.Context) error { + m.mu.RLock() + serviceNames := make([]string, 0, len(m.services)) + for name := range m.services { + serviceNames = append(serviceNames, name) + } + m.mu.RUnlock() + + var errors []error + for _, name := range serviceNames { + if err := m.Stop(ctx, name); err != nil { + errors = append(errors, err) + } + } + + if len(errors) > 0 { + return fmt.Errorf("failed to stop some services: %v", errors) + } + + return nil +} + +// GetStatus returns the status of a specific service +func (m *Manager) GetStatus(ctx context.Context, serviceName string) (*ServiceStatus, error) { + m.mu.RLock() + state, exists := m.services[serviceName] + m.mu.RUnlock() + + // If not in memory, check Docker + if !exists { + if m.docker != nil { + containers, err := m.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": serviceName, + }) + + if err != nil { + m.logger.Debug("failed to list containers for status", zap.Error(err)) + } else if len(containers) > 0 { + // Found container(s) in Docker + container := containers[0] // Use first container + status := &ServiceStatus{ + Name: serviceName, + Status: container.State, + } + + // Try to get port from container + if len(container.Ports) > 0 { + status.Port = int(container.Ports[0].PublicPort) + } + + // Calculate uptime + if container.State == "running" { + status.Status = "running" + // Note: container.Created is a Unix timestamp + status.Uptime = time.Since(time.Unix(container.Created, 0)) + } + + return status, nil + } + } + + // Not in memory and not in Docker + return &ServiceStatus{ + Name: serviceName, + Status: "stopped", + }, nil + } + + // Service is in memory + status := &ServiceStatus{ + Name: serviceName, + Uptime: time.Since(state.startTime), + } + + if state.stopped { + status.Status = "stopped" + return status, nil + } + + if state.error != nil { + status.Status = "error" + status.Error = state.error.Error() + return status, nil + } + + // Check if service is still ready + ready, err := state.plugin.IsReady(ctx) + if err != nil || !ready { + status.Status = "error" + if err != nil { + status.Error = err.Error() + } + return status, nil + } + + status.Status = "running" + + // Get connection info for port + if connInfo, err := state.plugin.GetConnectionInfo(); err == nil { + status.Port = connInfo.Port + } + + return status, nil +} + +// GetAllStatuses returns the status of all services +func (m *Manager) GetAllStatuses(ctx context.Context) []*ServiceStatus { + // Get running services from both memory and Docker + serviceNames := m.ListRunning() + + statuses := make([]*ServiceStatus, 0, len(serviceNames)) + for _, name := range serviceNames { + status, err := m.GetStatus(ctx, name) + if err != nil { + statuses = append(statuses, &ServiceStatus{ + Name: name, + Status: "error", + Error: err.Error(), + }) + } else { + statuses = append(statuses, status) + } + } + + return statuses +} + +// GetLogs retrieves logs from a service +func (m *Manager) GetLogs(ctx context.Context, serviceName string, opts *plugin.LogOptions) ([]string, error) { + m.mu.RLock() + state, exists := m.services[serviceName] + m.mu.RUnlock() + + // If in memory, use the plugin + if exists { + return state.plugin.GetLogs(ctx, opts) + } + + // Not in memory, try to get plugin and let it find the container + m.logger.Info("service not in memory for logs, checking Docker", zap.String("service", serviceName)) + + // Verify container exists in Docker first + if m.docker != nil { + containers, err := m.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + "service": serviceName, + }) + + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrDockerFailed, "failed to check for containers") + } + + if len(containers) == 0 { + return nil, gtErrors.New(gtErrors.ErrServiceNotRunning, + fmt.Sprintf("service %s is not running", serviceName)) + } + } + + // Get plugin from registry + servicePlugin, err := m.registry.GetService(serviceName) + if err != nil { + return nil, gtErrors.Wrap(err, gtErrors.ErrServiceFailed, + fmt.Sprintf("service %s not found in registry", serviceName)) + } + + // Let the plugin get logs (it will find the container by labels) + return servicePlugin.GetLogs(ctx, opts) +} + +// ListRunning returns a list of currently running services +func (m *Manager) ListRunning() []string { + m.mu.RLock() + defer m.mu.RUnlock() + + // Start with services in memory + servicesMap := make(map[string]bool) + for name, state := range m.services { + if !state.stopped { + servicesMap[name] = true + } + } + + // Also check Docker for containers managed by gtool + if m.docker != nil { + ctx := context.Background() + containers, err := m.docker.ListContainersByLabels(ctx, map[string]string{ + "managed-by": "gtool", + }) + + if err != nil { + m.logger.Error("failed to list running containers", zap.Error(err)) + } else { + // Extract service names from labels + for _, container := range containers { + if container.State == "running" { + if serviceName, ok := container.Labels["service"]; ok { + servicesMap[serviceName] = true + } + } + } + } + } + + // Convert map to slice + services := make([]string, 0, len(servicesMap)) + for name := range servicesMap { + services = append(services, name) + } + + return services +} + +// StartAll starts all configured mock services +func (m *Manager) StartAll(ctx context.Context, serviceConfigs map[string]map[string]interface{}) error { + if m.orchestration.ParallelMocks { + return m.startParallel(ctx, serviceConfigs) + } + return m.startSequential(ctx, serviceConfigs) +} + +// startSequential starts services one by one +func (m *Manager) startSequential(ctx context.Context, serviceConfigs map[string]map[string]interface{}) error { + for serviceName, config := range serviceConfigs { + if err := m.Start(ctx, serviceName, config); err != nil { + if m.orchestration.CleanupOnFailure { + _ = m.StopAll(ctx) + } + return err + } + } + return nil +} + +// startParallel starts services in parallel +func (m *Manager) startParallel(ctx context.Context, serviceConfigs map[string]map[string]interface{}) error { + var wg sync.WaitGroup + errChan := make(chan error, len(serviceConfigs)) + + for serviceName, config := range serviceConfigs { + wg.Add(1) + go func(name string, cfg map[string]interface{}) { + defer wg.Done() + if err := m.Start(ctx, name, cfg); err != nil { + errChan <- fmt.Errorf("%s: %w", name, err) + } + }(serviceName, config) + } + + wg.Wait() + close(errChan) + + // Check for errors + var errors []error + for err := range errChan { + errors = append(errors, err) + } + + if len(errors) > 0 { + if m.orchestration.CleanupOnFailure { + _ = m.StopAll(ctx) + } + return fmt.Errorf("failed to start services: %v", errors) + } + + return nil +} diff --git a/internal/core/orchestrator/orchestrator.go b/internal/core/orchestrator/orchestrator.go new file mode 100644 index 0000000..d15cf96 --- /dev/null +++ b/internal/core/orchestrator/orchestrator.go @@ -0,0 +1,27 @@ +package orchestrator + +import ( + "context" + + "github.com/oswaldo-montano/gtool/pkg/config" +) + +type Orchestrator struct { + config *config.Config +} + +func NewOrchestrator(cfg *config.Config) *Orchestrator { + return &Orchestrator{ + config: cfg, + } +} + +func (o *Orchestrator) Run(ctx context.Context) error { + // TODO: Implement in Phase 5 + // 1. Initialize + // 2. Start mocks (parallel) + // 3. Start application + // 4. Execute tests + // 5. Cleanup + return nil +} diff --git a/internal/core/test/executor.go b/internal/core/test/executor.go new file mode 100644 index 0000000..58976db --- /dev/null +++ b/internal/core/test/executor.go @@ -0,0 +1,21 @@ +package test + +import ( + "context" + + "github.com/oswaldo-montano/gtool/internal/plugin" +) + +type Manager struct { + executor plugin.TestExecutor +} + +func NewManager(executor plugin.TestExecutor) *Manager { + return &Manager{ + executor: executor, + } +} + +func (m *Manager) Execute(ctx context.Context, config *plugin.TestConfig) (*plugin.TestResult, error) { + return nil, nil +} diff --git a/internal/plugin/services/postgresql/README.md b/internal/plugin/services/postgresql/README.md new file mode 100644 index 0000000..61b06b6 --- /dev/null +++ b/internal/plugin/services/postgresql/README.md @@ -0,0 +1,50 @@ +# PostgreSQL Service Plugin + +> **Documentation has moved!** +> +> For complete PostgreSQL plugin documentation, please visit: +> **[docs/services/postgresql/](../../../../docs/services/postgresql/)** + +## Quick Links + +- **[Quick Start Guide](../../../../docs/services/postgresql/quickstart.md)** - Get started in 5 minutes +- **[Plugin Documentation](../../../../docs/services/postgresql/README.md)** - Complete API reference +- **[Implementation Details](../../../../docs/services/postgresql/implementation.md)** - Technical architecture +- **[SQL Scripts Guide](../../../../docs/services/postgresql/sql-scripts.md)** - How to create SQL scripts + +## Quick Example + +```go +import ( + "context" + "github.com/oswaldo-montano/gtool/internal/infra/docker" + "github.com/oswaldo-montano/gtool/internal/plugin/services/postgresql" +) + +// Create plugin +dockerClient, _ := docker.NewClient(logger) +plugin := postgresql.NewPostgreSQLPlugin(dockerClient, logger) + +// Launch PostgreSQL +ctx := context.Background() +config := map[string]interface{}{ + "port": "5432", + "scripts-path": "./test/component/mocks-data/postgresql", +} +plugin.Launch(ctx, config) +defer plugin.Stop(ctx) +``` + +## Configuration + +```yaml +third-party: + mocks: + - postgresql + mock-config: + postgresql: + port: 5432 + scripts-path: ./test/component/mocks-data/postgresql +``` + +For more details, see the [full documentation](../../../../docs/services/postgresql/). From c1b420aa0cd3233b97de956b894efa45d48c4d6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Sat, 8 Nov 2025 19:47:59 +0100 Subject: [PATCH 18/19] feature: Update CI configuration to skip golangci-lint config check --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25f60af..ab8c640 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,4 +53,4 @@ jobs: uses: golangci/golangci-lint-action@v6 with: version: latest - args: --config=configs/golangci-lint.yml --timeout=5m + args: --config=configs/golangci-lint.yml --timeout=5m --skip-config-check From 00b85beea93619c259b5a2a05c6f2a250980e1d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oswaldo=20Monta=C3=B1o?= Date: Sat, 8 Nov 2025 20:17:58 +0100 Subject: [PATCH 19/19] refactor: Remove skip-config-check argument from golangci-lint action --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab8c640..25f60af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,4 +53,4 @@ jobs: uses: golangci/golangci-lint-action@v6 with: version: latest - args: --config=configs/golangci-lint.yml --timeout=5m --skip-config-check + args: --config=configs/golangci-lint.yml --timeout=5m