Add configuration wizard. (#2)

* Add configuration wizard.
This commit is contained in:
2025-11-24 02:28:08 +00:00
committed by GitHub
parent 0f86f8e230
commit f41c316b2b
28 changed files with 5977 additions and 496 deletions
+14
View File
@@ -7,6 +7,10 @@ import (
"gopkg.in/yaml.v3"
)
const (
maxConfigSize = 10 * 1024 * 1024 // 10MB
)
// Config represents the root configuration structure from .kportal.yaml
type Config struct {
Contexts []Context `yaml:"contexts"`
@@ -80,6 +84,16 @@ func (f *Forward) GetNamespace() string {
// LoadConfig loads and parses the configuration file from the given path.
func LoadConfig(path string) (*Config, error) {
// Validate file size before reading
fileInfo, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("failed to stat config file: %w", err)
}
if fileInfo.Size() > maxConfigSize {
return nil, fmt.Errorf("config file too large: %d bytes (max %d)", fileInfo.Size(), maxConfigSize)
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
+1 -1
View File
@@ -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 read config file", "error should mention read failure")
assert.Contains(t, err.Error(), "failed to stat config file", "error should mention stat failure")
}
func TestForward_ID(t *testing.T) {
+273
View File
@@ -0,0 +1,273 @@
package config
import (
"fmt"
"os"
"path/filepath"
"sync"
"gopkg.in/yaml.v3"
)
// Mutator provides safe, atomic mutations to the kportal configuration file.
// All operations use atomic file writes (write to temp, then rename) to prevent
// corruption and ensure the file watcher picks up changes.
type Mutator struct {
configPath string
mu sync.Mutex // Ensure only one mutation at a time
}
// NewMutator creates a new configuration mutator for the given config file path.
func NewMutator(configPath string) *Mutator {
return &Mutator{
configPath: configPath,
}
}
// findOrCreateContext finds an existing context or creates a new one
func (m *Mutator) findOrCreateContext(cfg *Config, contextName string) *Context {
for i := range cfg.Contexts {
if cfg.Contexts[i].Name == contextName {
return &cfg.Contexts[i]
}
}
// Create new context
cfg.Contexts = append(cfg.Contexts, Context{
Name: contextName,
Namespaces: []Namespace{},
})
return &cfg.Contexts[len(cfg.Contexts)-1]
}
// findOrCreateNamespace finds an existing namespace or creates a new one
func (m *Mutator) findOrCreateNamespace(ctx *Context, namespaceName string) *Namespace {
for i := range ctx.Namespaces {
if ctx.Namespaces[i].Name == namespaceName {
return &ctx.Namespaces[i]
}
}
// Create new namespace
ctx.Namespaces = append(ctx.Namespaces, Namespace{
Name: namespaceName,
Forwards: []Forward{},
})
return &ctx.Namespaces[len(ctx.Namespaces)-1]
}
// AddForward adds a new port forward to the configuration.
// If the context or namespace doesn't exist, they will be created.
// The new configuration is validated before writing.
// Returns an error if the port is already in use or validation fails.
func (m *Mutator) AddForward(contextName, namespaceName string, fwd Forward) error {
m.mu.Lock()
defer m.mu.Unlock()
// Load current config
cfg, err := LoadConfig(m.configPath)
if err != nil {
// If file doesn't exist, create empty config
if os.IsNotExist(err) {
cfg = &Config{Contexts: []Context{}}
} else {
return fmt.Errorf("failed to load config: %w", err)
}
}
// Find or create context and namespace
targetContext := m.findOrCreateContext(cfg, contextName)
targetNamespace := m.findOrCreateNamespace(targetContext, namespaceName)
// Set context/namespace on the forward for validation
fwd.SetContext(contextName, namespaceName)
// Check for duplicate local port
allForwards := cfg.GetAllForwards()
for _, existing := range allForwards {
if existing.LocalPort == fwd.LocalPort {
return fmt.Errorf("port %d is already in use by %s", fwd.LocalPort, existing.String())
}
}
// Add the forward
targetNamespace.Forwards = append(targetNamespace.Forwards, fwd)
// Validate the new configuration
validator := NewValidator()
if errs := validator.ValidateConfig(cfg); len(errs) > 0 {
return fmt.Errorf("validation failed: %s", FormatValidationErrors(errs))
}
// Write atomically
return m.writeAtomic(cfg)
}
// RemoveForwards removes forwards matching the predicate function.
// The predicate receives the context, namespace, and forward, and should return true
// to remove that forward.
// Empty namespaces and contexts are preserved (not automatically removed).
func (m *Mutator) RemoveForwards(predicate func(ctx, ns string, fwd Forward) bool) error {
m.mu.Lock()
defer m.mu.Unlock()
// Load current config
cfg, err := LoadConfig(m.configPath)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
// Iterate and filter
for i := range cfg.Contexts {
ctx := &cfg.Contexts[i]
filteredNamespaces := []Namespace{}
for j := range ctx.Namespaces {
ns := &ctx.Namespaces[j]
// Filter forwards
filtered := []Forward{}
for _, fwd := range ns.Forwards {
// CRITICAL: Set context/namespace so fwd.ID() generates correct ID
fwd.SetContext(ctx.Name, ns.Name)
if !predicate(ctx.Name, ns.Name, fwd) {
// Keep this forward
filtered = append(filtered, fwd)
}
}
ns.Forwards = filtered
// Only keep namespaces that have at least one forward
if len(ns.Forwards) > 0 {
filteredNamespaces = append(filteredNamespaces, *ns)
}
}
ctx.Namespaces = filteredNamespaces
}
// Validate the new configuration
validator := NewValidator()
if errs := validator.ValidateConfig(cfg); len(errs) > 0 {
return fmt.Errorf("validation failed: %s", FormatValidationErrors(errs))
}
// Write atomically
return m.writeAtomic(cfg)
}
// RemoveForwardByID removes a specific forward by its ID.
func (m *Mutator) RemoveForwardByID(id string) error {
return m.RemoveForwards(func(ctx, ns string, fwd Forward) bool {
return fwd.ID() == id
})
}
// UpdateForward atomically replaces an existing forward with a new one.
// This is used for editing - it removes the old forward and adds the new one in a single transaction.
// If the old forward doesn't exist, returns an error.
// If the new forward validation fails, the operation is rolled back (old forward remains).
func (m *Mutator) UpdateForward(oldID, newContextName, newNamespaceName string, newFwd Forward) error {
m.mu.Lock()
defer m.mu.Unlock()
// Load current config
cfg, err := LoadConfig(m.configPath)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
// First, verify the old forward exists and remove it
oldForwardFound := false
for i := range cfg.Contexts {
ctx := &cfg.Contexts[i]
for j := range ctx.Namespaces {
ns := &ctx.Namespaces[j]
// Filter forwards, removing the old one
filtered := []Forward{}
for _, fwd := range ns.Forwards {
// CRITICAL: Set context/namespace so fwd.ID() generates correct ID
fwd.SetContext(ctx.Name, ns.Name)
if fwd.ID() == oldID {
oldForwardFound = true
// Skip this forward (remove it)
continue
}
// Keep this forward
filtered = append(filtered, fwd)
}
ns.Forwards = filtered
}
}
if !oldForwardFound {
return fmt.Errorf("forward with ID %s not found", oldID)
}
// Now add the new forward
// Find or create context and namespace
targetContext := m.findOrCreateContext(cfg, newContextName)
targetNamespace := m.findOrCreateNamespace(targetContext, newNamespaceName)
// Set context/namespace on the forward for validation
newFwd.SetContext(newContextName, newNamespaceName)
// Check for duplicate local port (excluding the one we just removed)
allForwards := cfg.GetAllForwards()
for _, existing := range allForwards {
if existing.LocalPort == newFwd.LocalPort && existing.ID() != oldID {
return fmt.Errorf("port %d is already in use by %s", newFwd.LocalPort, existing.String())
}
}
// Add the new forward
targetNamespace.Forwards = append(targetNamespace.Forwards, newFwd)
// Validate the new configuration
validator := NewValidator()
if errs := validator.ValidateConfig(cfg); len(errs) > 0 {
return fmt.Errorf("validation failed: %s", FormatValidationErrors(errs))
}
// Write atomically
return m.writeAtomic(cfg)
}
// writeAtomic writes the configuration atomically to prevent corruption.
// Steps:
// 1. Marshal config to YAML
// 2. Write to temporary file (.kportal.yaml.tmp)
// 3. Atomic rename to actual config file
//
// This ensures the file watcher picks up a complete, valid file.
func (m *Mutator) writeAtomic(cfg *Config) error {
// Marshal to YAML
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
// Create temporary file in same directory as config
dir := filepath.Dir(m.configPath)
tmpFile := filepath.Join(dir, ".kportal.yaml.tmp")
// Write to temp file
if err := os.WriteFile(tmpFile, data, 0600); err != nil {
return fmt.Errorf("failed to write temp file: %w", err)
}
// Atomic rename
if err := os.Rename(tmpFile, m.configPath); err != nil {
// Clean up temp file on failure
os.Remove(tmpFile)
return fmt.Errorf("failed to rename temp file: %w", err)
}
return nil
}
+20 -10
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"github.com/fsnotify/fsnotify"
"github.com/nvm/kportal/internal/logger"
)
// ReloadCallback is called when the configuration file changes.
@@ -113,28 +114,37 @@ func (w *Watcher) handleReload() {
// Load new configuration
newCfg, err := LoadConfig(w.configPath)
if err != nil {
log.Printf("Failed to load configuration: %v", err)
log.Printf("Keeping previous configuration active")
logger.Error("Failed to load configuration during hot-reload", map[string]interface{}{
"config_path": w.configPath,
"error": err.Error(),
})
logger.Info("Keeping previous configuration active", nil)
return
}
// Validate new configuration
validator := NewValidator()
if errs := validator.ValidateConfig(newCfg); len(errs) > 0 {
log.Printf("Configuration validation failed:")
log.Print(FormatValidationErrors(errs))
log.Printf("Keeping previous configuration active")
logger.Error("Configuration validation failed during hot-reload", map[string]interface{}{
"config_path": w.configPath,
"validation_errors": len(errs),
})
logger.Info("Keeping previous configuration active", nil)
return
}
// Call reload callback
if err := w.callback(newCfg); err != nil {
log.Printf("Failed to apply new configuration: %v", err)
log.Printf("Keeping previous configuration active")
logger.Error("Failed to apply new configuration", map[string]interface{}{
"config_path": w.configPath,
"error": err.Error(),
})
logger.Info("Keeping previous configuration active", nil)
return
}
if w.verbose {
log.Printf("Configuration reloaded successfully")
}
logger.Info("Configuration reloaded successfully", map[string]interface{}{
"config_path": w.configPath,
"forwards_count": len(newCfg.GetAllForwards()),
})
}