WIP - before the testing.

This commit is contained in:
2025-11-23 15:24:51 +00:00
parent 3fee780dea
commit f50f0a9b49
21 changed files with 4582 additions and 13 deletions
+152
View File
@@ -0,0 +1,152 @@
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// Config represents the root configuration structure from .kportal.yaml
type Config struct {
Contexts []Context `yaml:"contexts"`
}
// Context represents a Kubernetes context with its namespaces
type Context struct {
Name string `yaml:"name"`
Namespaces []Namespace `yaml:"namespaces"`
}
// Namespace represents a Kubernetes namespace with its forwards
type Namespace struct {
Name string `yaml:"name"`
Forwards []Forward `yaml:"forwards"`
}
// Forward represents a single port-forward configuration
type Forward struct {
Resource string `yaml:"resource"` // e.g., "pod/my-app", "service/postgres", "pod"
Selector string `yaml:"selector"` // Label selector for pod resolution (e.g., "app=nginx,env=prod")
Protocol string `yaml:"protocol"` // tcp or udp
Port int `yaml:"port"` // Remote port
LocalPort int `yaml:"localPort"` // Local port
// Runtime fields (not in YAML)
contextName string
namespaceName string
}
// ID returns a unique identifier for this forward configuration.
// Format: context/namespace/resource:localPort
func (f *Forward) ID() string {
return fmt.Sprintf("%s/%s/%s:%d", f.contextName, f.namespaceName, f.Resource, f.LocalPort)
}
// String returns a human-readable representation of the forward.
// Format: context/namespace/resource:port→localPort
func (f *Forward) String() string {
if f.Selector != "" {
return fmt.Sprintf("%s/%s/%s[%s]:%d→%d",
f.contextName, f.namespaceName, f.Resource, f.Selector, f.Port, f.LocalPort)
}
return fmt.Sprintf("%s/%s/%s:%d→%d",
f.contextName, f.namespaceName, f.Resource, f.Port, f.LocalPort)
}
// SetContext sets the context and namespace names for this forward.
// This is used during config parsing to populate runtime fields.
func (f *Forward) SetContext(ctx, ns string) {
f.contextName = ctx
f.namespaceName = ns
}
// GetContext returns the context name for this forward.
func (f *Forward) GetContext() string {
return f.contextName
}
// GetNamespace returns the namespace name for this forward.
func (f *Forward) GetNamespace() string {
return f.namespaceName
}
// LoadConfig loads and parses the configuration file from the given path.
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
return ParseConfig(data)
}
// ParseConfig parses YAML configuration data into a Config struct.
func ParseConfig(data []byte) (*Config, error) {
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse YAML: %w", err)
}
// Populate runtime fields (context and namespace names)
for i := range cfg.Contexts {
ctx := &cfg.Contexts[i]
for j := range ctx.Namespaces {
ns := &ctx.Namespaces[j]
for k := range ns.Forwards {
fwd := &ns.Forwards[k]
fwd.SetContext(ctx.Name, ns.Name)
}
}
}
return &cfg, nil
}
// GetAllForwards returns a flat list of all forwards across all contexts and namespaces.
func (c *Config) GetAllForwards() []Forward {
var forwards []Forward
for _, ctx := range c.Contexts {
for _, ns := range ctx.Namespaces {
forwards = append(forwards, ns.Forwards...)
}
}
return forwards
}
// GetForwardsByContext returns all forwards for a specific context.
func (c *Config) GetForwardsByContext(contextName string) []Forward {
var forwards []Forward
for _, ctx := range c.Contexts {
if ctx.Name == contextName {
for _, ns := range ctx.Namespaces {
forwards = append(forwards, ns.Forwards...)
}
break
}
}
return forwards
}
// GetForwardsByNamespace returns all forwards for a specific context and namespace.
func (c *Config) GetForwardsByNamespace(contextName, namespaceName string) []Forward {
var forwards []Forward
for _, ctx := range c.Contexts {
if ctx.Name == contextName {
for _, ns := range ctx.Namespaces {
if ns.Name == namespaceName {
forwards = append(forwards, ns.Forwards...)
break
}
}
break
}
}
return forwards
}
+357
View File
@@ -0,0 +1,357 @@
package config
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestLoadConfig_ValidYAML(t *testing.T) {
// Create a temporary config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
validYAML := `contexts:
- name: dev-cluster
namespaces:
- name: default
forwards:
- resource: pod/my-app
protocol: tcp
port: 8080
localPort: 8080
- name: staging
forwards:
- resource: service/postgres
protocol: tcp
port: 5432
localPort: 5433
- name: prod-cluster
namespaces:
- name: production
forwards:
- resource: pod
selector: app=nginx,env=prod
protocol: tcp
port: 80
localPort: 8081
`
err := os.WriteFile(configPath, []byte(validYAML), 0644)
assert.NoError(t, err, "should write temp config file")
// Load the config
cfg, err := LoadConfig(configPath)
assert.NoError(t, err, "LoadConfig should succeed")
assert.NotNil(t, cfg, "config should not be nil")
// Verify structure
assert.Len(t, cfg.Contexts, 2, "should have 2 contexts")
// Verify first context
assert.Equal(t, "dev-cluster", cfg.Contexts[0].Name)
assert.Len(t, cfg.Contexts[0].Namespaces, 2, "dev-cluster should have 2 namespaces")
// Verify first namespace in first context
assert.Equal(t, "default", cfg.Contexts[0].Namespaces[0].Name)
assert.Len(t, cfg.Contexts[0].Namespaces[0].Forwards, 1)
// Verify forward details
fwd := cfg.Contexts[0].Namespaces[0].Forwards[0]
assert.Equal(t, "pod/my-app", fwd.Resource)
assert.Equal(t, "tcp", fwd.Protocol)
assert.Equal(t, 8080, fwd.Port)
assert.Equal(t, 8080, fwd.LocalPort)
assert.Equal(t, "", fwd.Selector)
// Verify runtime fields are populated
assert.Equal(t, "dev-cluster", fwd.GetContext())
assert.Equal(t, "default", fwd.GetNamespace())
}
func TestLoadConfig_InvalidYAML(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
invalidYAML := `contexts:
- name: dev-cluster
namespaces:
- name: default
forwards: [this is invalid yaml syntax
`
err := os.WriteFile(configPath, []byte(invalidYAML), 0644)
assert.NoError(t, err, "should write temp config file")
// Load the config
cfg, err := LoadConfig(configPath)
assert.Error(t, err, "LoadConfig should fail with invalid YAML")
assert.Nil(t, cfg, "config should be nil on error")
assert.Contains(t, err.Error(), "failed to parse YAML", "error should mention YAML parsing")
}
func TestLoadConfig_FileNotFound(t *testing.T) {
// Try to load a non-existent file
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")
}
func TestForward_ID(t *testing.T) {
tests := []struct {
name string
forward Forward
expectedID string
}{
{
name: "pod with explicit name",
forward: Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
expectedID: "dev-cluster/default/pod/my-app:8080",
},
{
name: "service resource",
forward: Forward{
Resource: "service/postgres",
Port: 5432,
LocalPort: 5433,
contextName: "prod-cluster",
namespaceName: "database",
},
expectedID: "prod-cluster/database/service/postgres:5433",
},
{
name: "pod with selector",
forward: Forward{
Resource: "pod",
Selector: "app=nginx",
Port: 80,
LocalPort: 8081,
contextName: "staging",
namespaceName: "web",
},
expectedID: "staging/web/pod:8081",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
id := tt.forward.ID()
assert.Equal(t, tt.expectedID, id, "ID() should return correct format")
})
}
}
func TestForward_String(t *testing.T) {
tests := []struct {
name string
forward Forward
expectedString string
}{
{
name: "pod without selector",
forward: Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
expectedString: "dev-cluster/default/pod/my-app:8080→8080",
},
{
name: "service resource",
forward: Forward{
Resource: "service/postgres",
Port: 5432,
LocalPort: 5433,
contextName: "prod-cluster",
namespaceName: "database",
},
expectedString: "prod-cluster/database/service/postgres:5432→5433",
},
{
name: "pod with selector",
forward: Forward{
Resource: "pod",
Selector: "app=nginx,env=prod",
Port: 80,
LocalPort: 8081,
contextName: "staging",
namespaceName: "web",
},
expectedString: "staging/web/pod[app=nginx,env=prod]:80→8081",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
str := tt.forward.String()
assert.Equal(t, tt.expectedString, str, "String() should return correct format")
})
}
}
func TestParseConfig_ValidYAML(t *testing.T) {
yamlData := []byte(`contexts:
- name: test-cluster
namespaces:
- name: default
forwards:
- resource: pod/app
protocol: tcp
port: 8080
localPort: 8080
`)
cfg, err := ParseConfig(yamlData)
assert.NoError(t, err, "ParseConfig should succeed")
assert.NotNil(t, cfg, "config should not be nil")
assert.Len(t, cfg.Contexts, 1)
assert.Equal(t, "test-cluster", cfg.Contexts[0].Name)
}
func TestParseConfig_PopulatesRuntimeFields(t *testing.T) {
yamlData := []byte(`contexts:
- name: my-cluster
namespaces:
- name: my-namespace
forwards:
- resource: pod/my-pod
port: 8080
localPort: 8080
`)
cfg, err := ParseConfig(yamlData)
assert.NoError(t, err)
assert.NotNil(t, cfg)
// Check that runtime fields are populated
fwd := cfg.Contexts[0].Namespaces[0].Forwards[0]
assert.Equal(t, "my-cluster", fwd.GetContext())
assert.Equal(t, "my-namespace", fwd.GetNamespace())
assert.Equal(t, "my-cluster/my-namespace/pod/my-pod:8080", fwd.ID())
}
func TestConfig_GetAllForwards(t *testing.T) {
yamlData := []byte(`contexts:
- name: cluster1
namespaces:
- name: ns1
forwards:
- resource: pod/app1
port: 8080
localPort: 8080
- resource: pod/app2
port: 8081
localPort: 8081
- name: ns2
forwards:
- resource: service/db
port: 5432
localPort: 5432
- name: cluster2
namespaces:
- name: ns3
forwards:
- resource: pod/app3
port: 9090
localPort: 9090
`)
cfg, err := ParseConfig(yamlData)
assert.NoError(t, err)
forwards := cfg.GetAllForwards()
assert.Len(t, forwards, 4, "should return all forwards from all contexts and namespaces")
}
func TestConfig_GetForwardsByContext(t *testing.T) {
yamlData := []byte(`contexts:
- name: cluster1
namespaces:
- name: ns1
forwards:
- resource: pod/app1
port: 8080
localPort: 8080
- resource: pod/app2
port: 8081
localPort: 8081
- name: cluster2
namespaces:
- name: ns2
forwards:
- resource: pod/app3
port: 9090
localPort: 9090
`)
cfg, err := ParseConfig(yamlData)
assert.NoError(t, err)
forwards := cfg.GetForwardsByContext("cluster1")
assert.Len(t, forwards, 2, "should return forwards only from cluster1")
forwards2 := cfg.GetForwardsByContext("cluster2")
assert.Len(t, forwards2, 1, "should return forwards only from cluster2")
forwards3 := cfg.GetForwardsByContext("non-existent")
assert.Len(t, forwards3, 0, "should return empty slice for non-existent context")
}
func TestConfig_GetForwardsByNamespace(t *testing.T) {
yamlData := []byte(`contexts:
- name: cluster1
namespaces:
- name: ns1
forwards:
- resource: pod/app1
port: 8080
localPort: 8080
- resource: pod/app2
port: 8081
localPort: 8081
- name: ns2
forwards:
- resource: pod/app3
port: 9090
localPort: 9090
`)
cfg, err := ParseConfig(yamlData)
assert.NoError(t, err)
forwards := cfg.GetForwardsByNamespace("cluster1", "ns1")
assert.Len(t, forwards, 2, "should return forwards only from cluster1/ns1")
forwards2 := cfg.GetForwardsByNamespace("cluster1", "ns2")
assert.Len(t, forwards2, 1, "should return forwards only from cluster1/ns2")
forwards3 := cfg.GetForwardsByNamespace("cluster1", "non-existent")
assert.Len(t, forwards3, 0, "should return empty slice for non-existent namespace")
}
func TestForward_SetContext(t *testing.T) {
fwd := Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
}
assert.Equal(t, "", fwd.GetContext(), "initial context should be empty")
assert.Equal(t, "", fwd.GetNamespace(), "initial namespace should be empty")
fwd.SetContext("my-cluster", "my-namespace")
assert.Equal(t, "my-cluster", fwd.GetContext())
assert.Equal(t, "my-namespace", fwd.GetNamespace())
}
+267
View File
@@ -0,0 +1,267 @@
package config
import (
"fmt"
"strings"
)
const (
minPort = 1
maxPort = 65535
)
// ValidationError represents a configuration validation error with context.
type ValidationError struct {
Field string // The field that failed validation
Message string // Error message
Context map[string]string // Additional context information
}
// Error implements the error interface.
func (e ValidationError) Error() string {
return e.Message
}
// Validator validates configuration files.
type Validator struct{}
// NewValidator creates a new Validator instance.
func NewValidator() *Validator {
return &Validator{}
}
// ValidateConfig validates the entire configuration and returns all errors found.
func (v *Validator) ValidateConfig(cfg *Config) []ValidationError {
var errs []ValidationError
if cfg == nil {
return []ValidationError{{
Field: "config",
Message: "Configuration is nil",
}}
}
// Validate structure
errs = append(errs, v.validateStructure(cfg)...)
// Validate each forward
for _, ctx := range cfg.Contexts {
for _, ns := range ctx.Namespaces {
for _, fwd := range ns.Forwards {
errs = append(errs, v.validateForward(&fwd)...)
}
}
}
// Check for duplicate local ports
errs = append(errs, v.validateDuplicatePorts(cfg)...)
return errs
}
// validateStructure validates the basic structure of the configuration.
func (v *Validator) validateStructure(cfg *Config) []ValidationError {
var errs []ValidationError
if len(cfg.Contexts) == 0 {
errs = append(errs, ValidationError{
Field: "contexts",
Message: "Configuration must have at least one context",
})
return errs
}
for i, ctx := range cfg.Contexts {
if ctx.Name == "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("contexts[%d].name", i),
Message: "Context name cannot be empty",
})
}
if len(ctx.Namespaces) == 0 {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("contexts[%d].namespaces", i),
Message: fmt.Sprintf("Context '%s' must have at least one namespace", ctx.Name),
})
continue
}
for j, ns := range ctx.Namespaces {
if ns.Name == "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("contexts[%d].namespaces[%d].name", i, j),
Message: fmt.Sprintf("Namespace name cannot be empty in context '%s'", ctx.Name),
})
}
if len(ns.Forwards) == 0 {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("contexts[%d].namespaces[%d].forwards", i, j),
Message: fmt.Sprintf("Namespace '%s/%s' must have at least one forward", ctx.Name, ns.Name),
})
}
}
}
return errs
}
// validateForward validates a single forward configuration.
func (v *Validator) validateForward(fwd *Forward) []ValidationError {
var errs []ValidationError
// Validate resource
if fwd.Resource == "" {
errs = append(errs, ValidationError{
Field: "resource",
Message: fmt.Sprintf("Resource cannot be empty for forward %s", fwd.ID()),
})
} else {
errs = append(errs, v.validateResource(fwd)...)
}
// Validate protocol
if fwd.Protocol != "" && fwd.Protocol != "tcp" && fwd.Protocol != "udp" {
errs = append(errs, ValidationError{
Field: "protocol",
Message: fmt.Sprintf("Invalid protocol '%s' for forward %s (must be 'tcp' or 'udp')", fwd.Protocol, fwd.ID()),
})
}
// Validate ports
if fwd.Port < minPort || fwd.Port > maxPort {
errs = append(errs, ValidationError{
Field: "port",
Message: fmt.Sprintf("Invalid port %d for forward %s (must be between %d and %d)", fwd.Port, fwd.ID(), minPort, maxPort),
})
}
if fwd.LocalPort < minPort || fwd.LocalPort > maxPort {
errs = append(errs, ValidationError{
Field: "localPort",
Message: fmt.Sprintf("Invalid localPort %d for forward %s (must be between %d and %d)", fwd.LocalPort, fwd.ID(), minPort, maxPort),
})
}
return errs
}
// validateResource validates the resource field format and selector usage.
func (v *Validator) validateResource(fwd *Forward) []ValidationError {
var errs []ValidationError
parts := strings.SplitN(fwd.Resource, "/", 2)
resourceType := parts[0]
// Valid resource types: pod, service
if resourceType != "pod" && resourceType != "service" {
errs = append(errs, ValidationError{
Field: "resource",
Message: fmt.Sprintf("Invalid resource type '%s' for forward %s (must be 'pod' or 'service')", resourceType, fwd.ID()),
})
return errs
}
// For pod resources
if resourceType == "pod" {
if len(parts) == 2 {
// pod/name format - should not have selector
if fwd.Selector != "" {
errs = append(errs, ValidationError{
Field: "selector",
Message: fmt.Sprintf("Forward %s uses explicit pod name (%s) and should not have a selector", fwd.ID(), fwd.Resource),
})
}
// Validate pod name is not empty
if parts[1] == "" {
errs = append(errs, ValidationError{
Field: "resource",
Message: fmt.Sprintf("Pod name cannot be empty for forward %s", fwd.ID()),
})
}
} else {
// pod (no name) - must have selector
if fwd.Selector == "" {
errs = append(errs, ValidationError{
Field: "selector",
Message: fmt.Sprintf("Forward %s uses generic 'pod' resource and must have a selector", fwd.ID()),
})
}
}
}
// For service resources
if resourceType == "service" {
if len(parts) < 2 || parts[1] == "" {
errs = append(errs, ValidationError{
Field: "resource",
Message: fmt.Sprintf("Service name cannot be empty for forward %s", fwd.ID()),
})
}
if fwd.Selector != "" {
errs = append(errs, ValidationError{
Field: "selector",
Message: fmt.Sprintf("Forward %s uses service resource and should not have a selector", fwd.ID()),
})
}
}
return errs
}
// validateDuplicatePorts checks for duplicate local ports across all forwards.
func (v *Validator) validateDuplicatePorts(cfg *Config) []ValidationError {
var errs []ValidationError
portMap := make(map[int][]string) // port -> list of forward IDs
for _, ctx := range cfg.Contexts {
for _, ns := range ctx.Namespaces {
for _, fwd := range ns.Forwards {
portMap[fwd.LocalPort] = append(portMap[fwd.LocalPort], fwd.ID())
}
}
}
// Find duplicates
for port, forwards := range portMap {
if len(forwards) > 1 {
errs = append(errs, ValidationError{
Field: "localPort",
Message: fmt.Sprintf("Duplicate local port %d used by multiple forwards", port),
Context: map[string]string{
"port": fmt.Sprintf("%d", port),
"forwards": strings.Join(forwards, ", "),
},
})
}
}
return errs
}
// FormatValidationErrors formats validation errors into a human-readable string.
func FormatValidationErrors(errs []ValidationError) string {
if len(errs) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("\nConfiguration Validation Errors:\n")
sb.WriteString(strings.Repeat("=", 50) + "\n\n")
for i, err := range errs {
sb.WriteString(fmt.Sprintf("%d. %s\n", i+1, err.Message))
if len(err.Context) > 0 {
for k, v := range err.Context {
sb.WriteString(fmt.Sprintf(" %s: %s\n", k, v))
}
}
sb.WriteString("\n")
}
return sb.String()
}
+703
View File
@@ -0,0 +1,703 @@
package config
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestValidator_ValidateConfig(t *testing.T) {
validator := NewValidator()
tests := []struct {
name string
config *Config
expectErrors bool
errorContains []string
}{
{
name: "valid config",
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",
},
},
},
},
},
},
},
expectErrors: false,
},
{
name: "nil config",
config: nil,
expectErrors: true,
errorContains: []string{"Configuration is nil"},
},
{
name: "empty contexts",
config: &Config{
Contexts: []Context{},
},
expectErrors: true,
errorContains: []string{"must have at least one context"},
},
{
name: "empty namespaces",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{},
},
},
},
expectErrors: true,
errorContains: []string{"must have at least one namespace"},
},
{
name: "empty forwards",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"must have at least one forward"},
},
{
name: "invalid port - zero",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/my-app",
Protocol: "tcp",
Port: 0,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Invalid port 0"},
},
{
name: "invalid port - above max",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/my-app",
Protocol: "tcp",
Port: 8080,
LocalPort: 65536,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Invalid localPort 65536"},
},
{
name: "invalid protocol",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/my-app",
Protocol: "http",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Invalid protocol 'http'", "must be 'tcp' or 'udp'"},
},
{
name: "empty resource",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "",
Protocol: "tcp",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Resource cannot be empty"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.ValidateConfig(tt.config)
if tt.expectErrors {
assert.NotEmpty(t, errs, "expected validation errors")
// Check that expected error messages are present
for _, expectedMsg := range tt.errorContains {
found := false
for _, err := range errs {
if strings.Contains(err.Message, expectedMsg) {
found = true
break
}
}
assert.True(t, found, "expected error message '%s' not found", expectedMsg)
}
} else {
assert.Empty(t, errs, "expected no validation errors")
}
})
}
}
func TestValidator_ValidateResourceFormat(t *testing.T) {
validator := NewValidator()
tests := []struct {
name string
forward Forward
expectErrors bool
errorContains []string
}{
{
name: "valid pod with name",
forward: Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: false,
},
{
name: "valid service with name",
forward: Forward{
Resource: "service/postgres",
Port: 5432,
LocalPort: 5432,
contextName: "dev",
namespaceName: "default",
},
expectErrors: false,
},
{
name: "valid pod with selector (no name)",
forward: Forward{
Resource: "pod",
Selector: "app=nginx",
Port: 80,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: false,
},
{
name: "invalid resource type",
forward: Forward{
Resource: "deployment/my-app",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: true,
errorContains: []string{"Invalid resource type 'deployment'"},
},
{
name: "pod with name and selector (invalid)",
forward: Forward{
Resource: "pod/my-app",
Selector: "app=nginx",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: true,
errorContains: []string{"should not have a selector"},
},
{
name: "pod without name and without selector (invalid)",
forward: Forward{
Resource: "pod",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: true,
errorContains: []string{"must have a selector"},
},
{
name: "service without name (invalid)",
forward: Forward{
Resource: "service",
Port: 5432,
LocalPort: 5432,
contextName: "dev",
namespaceName: "default",
},
expectErrors: true,
errorContains: []string{"Service name cannot be empty"},
},
{
name: "service with selector (invalid)",
forward: Forward{
Resource: "service/postgres",
Selector: "app=db",
Port: 5432,
LocalPort: 5432,
contextName: "dev",
namespaceName: "default",
},
expectErrors: true,
errorContains: []string{"should not have a selector"},
},
{
name: "pod with empty name after slash",
forward: Forward{
Resource: "pod/",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: true,
errorContains: []string{"Pod name cannot be empty"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.validateForward(&tt.forward)
if tt.expectErrors {
assert.NotEmpty(t, errs, "expected validation errors")
// Check that expected error messages are present
for _, expectedMsg := range tt.errorContains {
found := false
for _, err := range errs {
if strings.Contains(err.Message, expectedMsg) {
found = true
break
}
}
assert.True(t, found, "expected error message '%s' not found", expectedMsg)
}
} else {
assert.Empty(t, errs, "expected no validation errors")
}
})
}
}
func TestValidator_CheckDuplicatePorts(t *testing.T) {
validator := NewValidator()
tests := []struct {
name string
config *Config
expectErrors bool
errorContains []string
}{
{
name: "no duplicate ports",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/app1",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
{
Resource: "pod/app2",
Port: 8081,
LocalPort: 8081,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: false,
},
{
name: "duplicate ports in same namespace",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/app1",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
{
Resource: "pod/app2",
Port: 8081,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Duplicate local port 8080"},
},
{
name: "duplicate ports across namespaces",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "ns1",
Forwards: []Forward{
{
Resource: "pod/app1",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "ns1",
},
},
},
{
Name: "ns2",
Forwards: []Forward{
{
Resource: "pod/app2",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "ns2",
},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Duplicate local port 8080"},
},
{
name: "duplicate ports across contexts",
config: &Config{
Contexts: []Context{
{
Name: "cluster1",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/app1",
Port: 8080,
LocalPort: 8080,
contextName: "cluster1",
namespaceName: "default",
},
},
},
},
},
{
Name: "cluster2",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/app2",
Port: 8080,
LocalPort: 8080,
contextName: "cluster2",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Duplicate local port 8080"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.validateDuplicatePorts(tt.config)
if tt.expectErrors {
assert.NotEmpty(t, errs, "expected validation errors")
// Check that expected error messages are present
for _, expectedMsg := range tt.errorContains {
found := false
for _, err := range errs {
if strings.Contains(err.Message, expectedMsg) {
found = true
break
}
}
assert.True(t, found, "expected error message '%s' not found", expectedMsg)
}
} else {
assert.Empty(t, errs, "expected no validation errors")
}
})
}
}
func TestFormatValidationErrors(t *testing.T) {
tests := []struct {
name string
errors []ValidationError
expectEmpty bool
expectContains []string
}{
{
name: "no errors",
errors: []ValidationError{},
expectEmpty: true,
},
{
name: "single error",
errors: []ValidationError{
{
Field: "port",
Message: "Invalid port 0",
},
},
expectEmpty: false,
expectContains: []string{"Configuration Validation Errors", "1. Invalid port 0"},
},
{
name: "multiple errors",
errors: []ValidationError{
{
Field: "port",
Message: "Invalid port 0",
},
{
Field: "resource",
Message: "Resource cannot be empty",
},
},
expectEmpty: false,
expectContains: []string{"Configuration Validation Errors", "1. Invalid port 0", "2. Resource cannot be empty"},
},
{
name: "error with context",
errors: []ValidationError{
{
Field: "localPort",
Message: "Duplicate local port 8080",
Context: map[string]string{
"port": "8080",
"forwards": "dev/default/pod/app1:8080, dev/default/pod/app2:8080",
},
},
},
expectEmpty: false,
expectContains: []string{"Configuration Validation Errors", "Duplicate local port 8080", "port:", "8080", "forwards:"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output := FormatValidationErrors(tt.errors)
if tt.expectEmpty {
assert.Empty(t, output, "expected empty output")
} else {
assert.NotEmpty(t, output, "expected non-empty output")
// Check that expected strings are present
for _, expected := range tt.expectContains {
assert.Contains(t, output, expected, "output should contain '%s'", expected)
}
}
})
}
}
func TestValidationError_Error(t *testing.T) {
err := ValidationError{
Field: "port",
Message: "Invalid port 0",
}
assert.Equal(t, "Invalid port 0", err.Error(), "Error() should return the message")
}
func TestValidator_ValidateStructure(t *testing.T) {
validator := NewValidator()
tests := []struct {
name string
config *Config
expectErrors bool
errorContains []string
}{
{
name: "empty context name",
config: &Config{
Contexts: []Context{
{
Name: "",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{{Resource: "pod/app", Port: 8080, LocalPort: 8080}},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Context name cannot be empty"},
},
{
name: "empty namespace name",
config: &Config{
Contexts: []Context{
{
Name: "dev",
Namespaces: []Namespace{
{
Name: "",
Forwards: []Forward{{Resource: "pod/app", Port: 8080, LocalPort: 8080}},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Namespace name cannot be empty"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.validateStructure(tt.config)
if tt.expectErrors {
assert.NotEmpty(t, errs, "expected validation errors")
// Check that expected error messages are present
for _, expectedMsg := range tt.errorContains {
found := false
for _, err := range errs {
if strings.Contains(err.Message, expectedMsg) {
found = true
break
}
}
assert.True(t, found, "expected error message '%s' not found", expectedMsg)
}
} else {
assert.Empty(t, errs, "expected no validation errors")
}
})
}
}
+140
View File
@@ -0,0 +1,140 @@
package config
import (
"fmt"
"log"
"path/filepath"
"github.com/fsnotify/fsnotify"
)
// ReloadCallback is called when the configuration file changes.
// It receives the new configuration and should return an error if the reload fails.
type ReloadCallback func(*Config) error
// Watcher watches a configuration file for changes and triggers hot-reload.
type Watcher struct {
configPath string
callback ReloadCallback
watcher *fsnotify.Watcher
done chan struct{}
verbose bool
}
// NewWatcher creates a new file watcher for the given config file.
func NewWatcher(configPath string, callback ReloadCallback, verbose bool) (*Watcher, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("failed to create file watcher: %w", err)
}
absPath, err := filepath.Abs(configPath)
if err != nil {
watcher.Close()
return nil, fmt.Errorf("failed to resolve absolute path: %w", err)
}
// Watch the directory instead of the file to handle atomic writes
// (many editors delete and recreate files on save)
dir := filepath.Dir(absPath)
if err := watcher.Add(dir); err != nil {
watcher.Close()
return nil, fmt.Errorf("failed to watch directory %s: %w", dir, err)
}
return &Watcher{
configPath: absPath,
callback: callback,
watcher: watcher,
done: make(chan struct{}),
verbose: verbose,
}, nil
}
// Start begins watching the configuration file for changes.
func (w *Watcher) Start() {
go w.watch()
}
// Stop stops watching the configuration file.
func (w *Watcher) Stop() {
close(w.done)
w.watcher.Close()
}
// watch runs the file watching loop.
func (w *Watcher) watch() {
if w.verbose {
log.Printf("Watching configuration file: %s", w.configPath)
}
for {
select {
case event, ok := <-w.watcher.Events:
if !ok {
return
}
// Only process events for our config file
eventPath, err := filepath.Abs(event.Name)
if err != nil {
if w.verbose {
log.Printf("Failed to resolve event path: %v", err)
}
continue
}
if eventPath != w.configPath {
continue
}
// Handle write and create events (create happens on atomic writes)
if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create {
if w.verbose {
log.Printf("Configuration file changed, reloading...")
}
w.handleReload()
}
case err, ok := <-w.watcher.Errors:
if !ok {
return
}
log.Printf("File watcher error: %v", err)
case <-w.done:
return
}
}
}
// handleReload loads and validates the new configuration, then calls the callback.
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")
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")
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")
return
}
if w.verbose {
log.Printf("Configuration reloaded successfully")
}
}