diff --git a/cmd/kportal/main.go b/cmd/kportal/main.go index 24ff828..4bd223a 100644 --- a/cmd/kportal/main.go +++ b/cmd/kportal/main.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "context" "flag" "fmt" @@ -49,6 +50,23 @@ var ( appVersion = "0.1.0" // Set via ldflags during build ) +// promptCreateConfig asks the user if they want to create a new config file. +// Returns true if the user answers yes, false otherwise. +func promptCreateConfig(path string) bool { + fmt.Printf("Configuration file not found: %s\n", path) + fmt.Print("Would you like to create an empty configuration? [Y/n] ") + + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil { + return false + } + + response = strings.TrimSpace(strings.ToLower(response)) + // Empty response (just Enter) defaults to yes + return response == "" || response == "y" || response == "yes" +} + func main() { flag.Parse() @@ -173,14 +191,38 @@ func main() { // Load configuration cfg, err := config.LoadConfig(*configFile) + configIsNew := false if err != nil { - fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) - os.Exit(1) + if err == config.ErrConfigNotFound { + // Config file doesn't exist - offer to create it + if !promptCreateConfig(*configFile) { + os.Exit(0) + } + // Create empty config file + if err := config.CreateEmptyConfigFile(*configFile); err != nil { + fmt.Fprintf(os.Stderr, "Error creating config file: %v\n", err) + os.Exit(1) + } + fmt.Printf("Created %s\n", *configFile) + fmt.Println("Use 'n' in the UI to add port forwards, or edit the file manually.") + fmt.Println() + + // Load the newly created config + cfg, err = config.LoadConfig(*configFile) + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) + os.Exit(1) + } + configIsNew = true + } else { + fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) + os.Exit(1) + } } - // Validate configuration + // Validate configuration (allow empty configs for newly created files) validator := config.NewValidator() - if errs := validator.ValidateConfig(cfg); len(errs) > 0 { + if errs := validator.ValidateConfigWithOptions(cfg, configIsNew || cfg.IsEmpty()); len(errs) > 0 { fmt.Fprint(os.Stderr, config.FormatValidationErrors(errs)) os.Exit(1) } diff --git a/internal/config/config.go b/internal/config/config.go index 42fdd18..75f5df1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "bytes" + "errors" "fmt" "os" "strings" @@ -10,6 +11,9 @@ import ( "gopkg.in/yaml.v3" ) +// ErrConfigNotFound is returned when the configuration file does not exist +var ErrConfigNotFound = fmt.Errorf("config file not found") + const ( // maxConfigSize is the maximum allowed configuration file size (10MB) maxConfigSize = 10 * 1024 * 1024 @@ -282,6 +286,9 @@ func LoadConfig(path string) (*Config, error) { // Validate file size before reading fileInfo, err := os.Stat(path) if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, ErrConfigNotFound + } return nil, fmt.Errorf("failed to stat config file: %w", err) } @@ -337,3 +344,58 @@ func (c *Config) GetAllForwards() []Forward { return forwards } + +// NewEmptyConfig returns a minimal empty configuration with no forwards. +// This is used when creating a new config file for the first time. +func NewEmptyConfig() *Config { + return &Config{ + Contexts: []Context{}, + } +} + +// IsEmpty returns true if the configuration has no forwards defined. +func (c *Config) IsEmpty() bool { + return len(c.Contexts) == 0 || len(c.GetAllForwards()) == 0 +} + +// CreateEmptyConfigFile creates a new empty configuration file at the given path. +// Returns an error if the file already exists or cannot be created. +func CreateEmptyConfigFile(path string) error { + // Check if file already exists + if _, err := os.Stat(path); err == nil { + return fmt.Errorf("config file already exists: %s", path) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to check config file: %w", err) + } + + cfg := NewEmptyConfig() + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("failed to marshal empty config: %w", err) + } + + // Add a helpful comment header + header := `# kportal configuration file +# Add port forwards using the 'n' key in the TUI, or manually add them below. +# +# Example forward: +# contexts: +# - name: my-cluster +# namespaces: +# - name: default +# forwards: +# - resource: service/my-service +# protocol: tcp +# port: 8080 +# localPort: 8080 +# +` + content := header + string(data) + + // Write with restrictive permissions (0600) + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9892c27..d4d0d36 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -97,7 +97,7 @@ func TestLoadConfig_FileNotFound(t *testing.T) { cfg, err := LoadConfig("/non/existent/path/.kportal.yaml") assert.Error(t, err, "LoadConfig should fail with non-existent file") assert.Nil(t, cfg, "config should be nil on error") - assert.Contains(t, err.Error(), "failed to stat config file", "error should mention stat failure") + assert.Equal(t, ErrConfigNotFound, err, "should return ErrConfigNotFound") } func TestForward_ID(t *testing.T) { @@ -397,3 +397,123 @@ func TestHTTPLogSpec_UnmarshalYAML(t *testing.T) { }) } } + +func TestNewEmptyConfig(t *testing.T) { + cfg := NewEmptyConfig() + assert.NotNil(t, cfg, "NewEmptyConfig should return non-nil config") + assert.Empty(t, cfg.Contexts, "NewEmptyConfig should have empty contexts") + assert.True(t, cfg.IsEmpty(), "NewEmptyConfig should be considered empty") +} + +func TestConfig_IsEmpty(t *testing.T) { + tests := []struct { + name string + config *Config + expected bool + }{ + { + name: "nil contexts", + config: &Config{}, + expected: true, + }, + { + name: "empty contexts slice", + config: &Config{Contexts: []Context{}}, + expected: true, + }, + { + name: "context with empty namespaces", + config: &Config{ + Contexts: []Context{ + {Name: "test", Namespaces: []Namespace{}}, + }, + }, + expected: true, + }, + { + name: "context with namespace but no forwards", + config: &Config{ + Contexts: []Context{ + { + Name: "test", + Namespaces: []Namespace{ + {Name: "default", Forwards: []Forward{}}, + }, + }, + }, + }, + expected: true, + }, + { + name: "config with forward", + config: &Config{ + Contexts: []Context{ + { + Name: "test", + Namespaces: []Namespace{ + { + Name: "default", + Forwards: []Forward{ + {Resource: "pod/app", Port: 8080, LocalPort: 8080}, + }, + }, + }, + }, + }, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.config.IsEmpty()) + }) + } +} + +func TestCreateEmptyConfigFile(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".kportal.yaml") + + // Create empty config file + err := CreateEmptyConfigFile(configPath) + assert.NoError(t, err, "CreateEmptyConfigFile should succeed") + + // Verify file exists + _, err = os.Stat(configPath) + assert.NoError(t, err, "config file should exist") + + // Verify file is readable and parseable + cfg, err := LoadConfig(configPath) + assert.NoError(t, err, "should be able to load created config") + assert.NotNil(t, cfg, "config should not be nil") + assert.True(t, cfg.IsEmpty(), "created config should be empty") + + // Verify file permissions (0600) + info, _ := os.Stat(configPath) + assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "file should have 0600 permissions") + + // Verify file contains helpful header + content, _ := os.ReadFile(configPath) + assert.Contains(t, string(content), "# kportal configuration file", "should contain header comment") + assert.Contains(t, string(content), "Example forward", "should contain example") +} + +func TestCreateEmptyConfigFile_AlreadyExists(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, ".kportal.yaml") + + // Create existing file + err := os.WriteFile(configPath, []byte("existing content"), 0644) + assert.NoError(t, err) + + // Try to create config file - should fail + err = CreateEmptyConfigFile(configPath) + assert.Error(t, err, "CreateEmptyConfigFile should fail when file exists") + assert.Contains(t, err.Error(), "already exists") + + // Verify original content is preserved + content, _ := os.ReadFile(configPath) + assert.Equal(t, "existing content", string(content)) +} diff --git a/internal/config/validator.go b/internal/config/validator.go index 22ef252..e2f2708 100644 --- a/internal/config/validator.go +++ b/internal/config/validator.go @@ -37,6 +37,13 @@ func NewValidator() *Validator { // ValidateConfig validates the entire configuration and returns all errors found. func (v *Validator) ValidateConfig(cfg *Config) []ValidationError { + return v.ValidateConfigWithOptions(cfg, false) +} + +// ValidateConfigWithOptions validates configuration with configurable strictness. +// When allowEmpty is true, empty configurations (no contexts/forwards) are allowed. +// This is useful for newly created config files where the user will add forwards via the TUI. +func (v *Validator) ValidateConfigWithOptions(cfg *Config, allowEmpty bool) []ValidationError { var errs []ValidationError if cfg == nil { @@ -46,6 +53,12 @@ func (v *Validator) ValidateConfig(cfg *Config) []ValidationError { }} } + // If empty configs are allowed and this config is empty, skip structure validation + if allowEmpty && cfg.IsEmpty() { + // Still validate health check and reliability if present (they don't require forwards) + return errs + } + // Validate structure errs = append(errs, v.validateStructure(cfg)...) diff --git a/internal/config/validator_test.go b/internal/config/validator_test.go index 5baadef..822c941 100644 --- a/internal/config/validator_test.go +++ b/internal/config/validator_test.go @@ -972,3 +972,141 @@ func TestIsAlphanumeric(t *testing.T) { }) } } + +func TestValidator_ValidateConfigWithOptions(t *testing.T) { + validator := NewValidator() + + tests := []struct { + name string + config *Config + allowEmpty bool + expectErrors bool + }{ + { + name: "empty config - strict mode", + config: &Config{Contexts: []Context{}}, + allowEmpty: false, + expectErrors: true, + }, + { + name: "empty config - allow empty", + config: &Config{Contexts: []Context{}}, + allowEmpty: true, + expectErrors: false, + }, + { + name: "nil contexts - allow empty", + config: &Config{}, + allowEmpty: true, + expectErrors: false, + }, + { + name: "context with no forwards - allow empty", + config: &Config{ + Contexts: []Context{ + { + Name: "dev", + Namespaces: []Namespace{ + {Name: "default", Forwards: []Forward{}}, + }, + }, + }, + }, + allowEmpty: true, + expectErrors: false, + }, + { + name: "valid config - strict mode", + config: &Config{ + Contexts: []Context{ + { + Name: "dev-cluster", + Namespaces: []Namespace{ + { + Name: "default", + Forwards: []Forward{ + { + Resource: "pod/my-app", + Protocol: "tcp", + Port: 8080, + LocalPort: 8080, + contextName: "dev-cluster", + namespaceName: "default", + }, + }, + }, + }, + }, + }, + }, + allowEmpty: false, + expectErrors: false, + }, + { + name: "valid config - allow empty (should still validate)", + config: &Config{ + Contexts: []Context{ + { + Name: "dev-cluster", + Namespaces: []Namespace{ + { + Name: "default", + Forwards: []Forward{ + { + Resource: "pod/my-app", + Protocol: "tcp", + Port: 8080, + LocalPort: 8080, + contextName: "dev-cluster", + namespaceName: "default", + }, + }, + }, + }, + }, + }, + }, + allowEmpty: true, + expectErrors: false, + }, + { + name: "invalid forward in non-empty config - allow empty still validates", + config: &Config{ + Contexts: []Context{ + { + Name: "dev-cluster", + Namespaces: []Namespace{ + { + Name: "default", + Forwards: []Forward{ + { + Resource: "pod/my-app", + Protocol: "tcp", + Port: 0, // Invalid port + LocalPort: 8080, + contextName: "dev-cluster", + namespaceName: "default", + }, + }, + }, + }, + }, + }, + }, + allowEmpty: true, + expectErrors: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := validator.ValidateConfigWithOptions(tt.config, tt.allowEmpty) + + if tt.expectErrors { + assert.NotEmpty(t, errs, "expected validation errors") + } else { + assert.Empty(t, errs, "expected no validation errors, got: %v", errs) + } + }) + } +} diff --git a/internal/forward/manager.go b/internal/forward/manager.go index 9bda964..9f168b5 100644 --- a/internal/forward/manager.go +++ b/internal/forward/manager.go @@ -169,8 +169,10 @@ func (m *Manager) Start(cfg *config.Config) error { // Get all forwards from config forwards := cfg.GetAllForwards() + // Empty config is valid - user can add forwards later via TUI if len(forwards) == 0 { - return fmt.Errorf("no forwards configured") + log.Printf("No forwards configured - use 'n' to add forwards") + return nil } // Check port availability before starting diff --git a/internal/forward/manager_test.go b/internal/forward/manager_test.go index 66e5414..a3cb636 100644 --- a/internal/forward/manager_test.go +++ b/internal/forward/manager_test.go @@ -124,8 +124,8 @@ func TestManager_Start_EmptyForwards(t *testing.T) { cfg := &config.Config{} err = manager.Start(cfg) - assert.Error(t, err) - assert.Contains(t, err.Error(), "no forwards configured") + // Empty config is now valid - allows users to add forwards via TUI + assert.NoError(t, err) } // TestManager_Reload_NilConfig tests reloading with nil config