mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-07-16 08:04:43 +00:00
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user