mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-07-05 06:05:39 +00:00
WIP - before the testing.
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
)
|
||||
|
||||
// Manager orchestrates all port-forward workers.
|
||||
// It handles starting, stopping, and hot-reloading forwards.
|
||||
type Manager struct {
|
||||
workers map[string]*ForwardWorker // key: forward.ID()
|
||||
workersMu sync.RWMutex
|
||||
clientPool *k8s.ClientPool
|
||||
resolver *k8s.ResourceResolver
|
||||
portForwarder *k8s.PortForwarder
|
||||
portChecker *PortChecker
|
||||
verbose bool
|
||||
currentConfig *config.Config
|
||||
}
|
||||
|
||||
// NewManager creates a new forward Manager.
|
||||
func NewManager(verbose bool) *Manager {
|
||||
clientPool, err := k8s.NewClientPool()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create client pool: %v", err)
|
||||
}
|
||||
|
||||
resolver := k8s.NewResourceResolver(clientPool)
|
||||
portForwarder := k8s.NewPortForwarder(clientPool, resolver)
|
||||
|
||||
return &Manager{
|
||||
workers: make(map[string]*ForwardWorker),
|
||||
clientPool: clientPool,
|
||||
resolver: resolver,
|
||||
portForwarder: portForwarder,
|
||||
portChecker: NewPortChecker(),
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// Start initializes and starts all port-forwards from the configuration.
|
||||
func (m *Manager) Start(cfg *config.Config) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("configuration is nil")
|
||||
}
|
||||
|
||||
m.currentConfig = cfg
|
||||
|
||||
// Get all forwards from config
|
||||
forwards := cfg.GetAllForwards()
|
||||
|
||||
if len(forwards) == 0 {
|
||||
return fmt.Errorf("no forwards configured")
|
||||
}
|
||||
|
||||
// Check port availability before starting
|
||||
ports := m.extractPorts(forwards)
|
||||
conflicts := m.portChecker.CheckAvailability(ports, nil)
|
||||
if len(conflicts) > 0 {
|
||||
// Add resource information to conflicts
|
||||
for i := range conflicts {
|
||||
conflicts[i].Resource = m.getResourceForPort(forwards, conflicts[i].Port)
|
||||
}
|
||||
return fmt.Errorf("port conflicts detected:\n%s", FormatConflicts(conflicts))
|
||||
}
|
||||
|
||||
// Start all workers
|
||||
log.Printf("Starting %d port-forward(s)...", len(forwards))
|
||||
|
||||
for _, fwd := range forwards {
|
||||
if err := m.startWorker(fwd); err != nil {
|
||||
log.Printf("Failed to start worker for %s: %v", fwd.ID(), err)
|
||||
// Continue with other workers
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("All port-forwards started")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully stops all port-forward workers.
|
||||
func (m *Manager) Stop() {
|
||||
log.Printf("Stopping all port-forwards...")
|
||||
|
||||
m.workersMu.Lock()
|
||||
workers := make([]*ForwardWorker, 0, len(m.workers))
|
||||
for _, worker := range m.workers {
|
||||
workers = append(workers, worker)
|
||||
}
|
||||
m.workersMu.Unlock()
|
||||
|
||||
// Stop all workers
|
||||
var wg sync.WaitGroup
|
||||
for _, worker := range workers {
|
||||
wg.Add(1)
|
||||
go func(w *ForwardWorker) {
|
||||
defer wg.Done()
|
||||
w.Stop()
|
||||
}(worker)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Clear workers map
|
||||
m.workersMu.Lock()
|
||||
m.workers = make(map[string]*ForwardWorker)
|
||||
m.workersMu.Unlock()
|
||||
|
||||
log.Printf("All port-forwards stopped")
|
||||
}
|
||||
|
||||
// Reload applies a new configuration with hot-reload logic.
|
||||
// It diffs the new config against the current one and:
|
||||
// - Stops removed forwards
|
||||
// - Keeps unchanged forwards running
|
||||
// - Starts new forwards
|
||||
func (m *Manager) Reload(newCfg *config.Config) error {
|
||||
if newCfg == nil {
|
||||
return fmt.Errorf("new configuration is nil")
|
||||
}
|
||||
|
||||
log.Printf("Reloading configuration...")
|
||||
|
||||
// Get all forwards from new config
|
||||
newForwards := newCfg.GetAllForwards()
|
||||
|
||||
if len(newForwards) == 0 {
|
||||
log.Printf("New configuration has no forwards, stopping all")
|
||||
m.Stop()
|
||||
m.currentConfig = newCfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create maps for easier comparison
|
||||
newForwardsMap := make(map[string]config.Forward)
|
||||
for _, fwd := range newForwards {
|
||||
newForwardsMap[fwd.ID()] = fwd
|
||||
}
|
||||
|
||||
m.workersMu.RLock()
|
||||
currentForwardsMap := make(map[string]config.Forward)
|
||||
for id, worker := range m.workers {
|
||||
currentForwardsMap[id] = worker.GetForward()
|
||||
}
|
||||
m.workersMu.RUnlock()
|
||||
|
||||
// Determine changes
|
||||
var toAdd []config.Forward
|
||||
var toRemove []string
|
||||
var toKeep []string
|
||||
|
||||
// Find forwards to add and keep
|
||||
for id, fwd := range newForwardsMap {
|
||||
if _, exists := currentForwardsMap[id]; exists {
|
||||
toKeep = append(toKeep, id)
|
||||
} else {
|
||||
toAdd = append(toAdd, fwd)
|
||||
}
|
||||
}
|
||||
|
||||
// Find forwards to remove
|
||||
for id := range currentForwardsMap {
|
||||
if _, exists := newForwardsMap[id]; !exists {
|
||||
toRemove = append(toRemove, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Check port availability for new forwards
|
||||
if len(toAdd) > 0 {
|
||||
// Get currently managed ports to skip in availability check
|
||||
managedPorts := make(map[int]bool)
|
||||
for _, id := range toKeep {
|
||||
managedPorts[currentForwardsMap[id].LocalPort] = true
|
||||
}
|
||||
|
||||
// Check new ports
|
||||
newPorts := m.extractPorts(toAdd)
|
||||
conflicts := m.portChecker.CheckAvailability(newPorts, managedPorts)
|
||||
if len(conflicts) > 0 {
|
||||
// Add resource information to conflicts
|
||||
for i := range conflicts {
|
||||
conflicts[i].Resource = m.getResourceForPort(toAdd, conflicts[i].Port)
|
||||
}
|
||||
log.Printf("Config change rejected due to port conflicts:\n%s", FormatConflicts(conflicts))
|
||||
log.Printf("Keeping previous configuration active")
|
||||
return fmt.Errorf("port conflicts detected")
|
||||
}
|
||||
}
|
||||
|
||||
// Apply changes
|
||||
log.Printf("Configuration diff: %d to add, %d to remove, %d to keep",
|
||||
len(toAdd), len(toRemove), len(toKeep))
|
||||
|
||||
// Stop removed forwards
|
||||
for _, id := range toRemove {
|
||||
if err := m.stopWorker(id); err != nil {
|
||||
log.Printf("Failed to stop worker %s: %v", id, err)
|
||||
} else {
|
||||
log.Printf("Stopped: %s", id)
|
||||
}
|
||||
}
|
||||
|
||||
// Start new forwards
|
||||
for _, fwd := range toAdd {
|
||||
if err := m.startWorker(fwd); err != nil {
|
||||
log.Printf("Failed to start worker for %s: %v", fwd.ID(), err)
|
||||
} else {
|
||||
log.Printf("Started: %s", fwd.ID())
|
||||
}
|
||||
}
|
||||
|
||||
// Update current config
|
||||
m.currentConfig = newCfg
|
||||
|
||||
log.Printf("Configuration reloaded successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// startWorker creates and starts a new forward worker.
|
||||
func (m *Manager) startWorker(fwd config.Forward) error {
|
||||
m.workersMu.Lock()
|
||||
defer m.workersMu.Unlock()
|
||||
|
||||
// Check if worker already exists
|
||||
if _, exists := m.workers[fwd.ID()]; exists {
|
||||
return fmt.Errorf("worker already exists for %s", fwd.ID())
|
||||
}
|
||||
|
||||
// Create and start worker
|
||||
worker := NewForwardWorker(fwd, m.portForwarder, m.verbose)
|
||||
worker.Start()
|
||||
|
||||
// Store worker
|
||||
m.workers[fwd.ID()] = worker
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopWorker stops and removes a forward worker.
|
||||
func (m *Manager) stopWorker(id string) error {
|
||||
m.workersMu.Lock()
|
||||
worker, exists := m.workers[id]
|
||||
if !exists {
|
||||
m.workersMu.Unlock()
|
||||
return fmt.Errorf("worker not found: %s", id)
|
||||
}
|
||||
delete(m.workers, id)
|
||||
m.workersMu.Unlock()
|
||||
|
||||
// Stop the worker
|
||||
worker.Stop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetActiveForwards returns a list of all active forward IDs.
|
||||
func (m *Manager) GetActiveForwards() []string {
|
||||
m.workersMu.RLock()
|
||||
defer m.workersMu.RUnlock()
|
||||
|
||||
ids := make([]string, 0, len(m.workers))
|
||||
for id := range m.workers {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
// GetWorkerCount returns the number of active workers.
|
||||
func (m *Manager) GetWorkerCount() int {
|
||||
m.workersMu.RLock()
|
||||
defer m.workersMu.RUnlock()
|
||||
|
||||
return len(m.workers)
|
||||
}
|
||||
|
||||
// extractPorts extracts all local ports from a list of forwards.
|
||||
func (m *Manager) extractPorts(forwards []config.Forward) []int {
|
||||
ports := make([]int, len(forwards))
|
||||
for i, fwd := range forwards {
|
||||
ports[i] = fwd.LocalPort
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
// getResourceForPort finds the resource (forward ID) that uses a given port.
|
||||
func (m *Manager) getResourceForPort(forwards []config.Forward, port int) string {
|
||||
for _, fwd := range forwards {
|
||||
if fwd.LocalPort == port {
|
||||
return fwd.ID()
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PortConflict represents a local port that is already in use.
|
||||
type PortConflict struct {
|
||||
Port int // The conflicting port number
|
||||
Resource string // The forward resource that needs this port
|
||||
UsedBy string // Process information (PID, command) using the port
|
||||
}
|
||||
|
||||
// PortChecker checks port availability on the local system.
|
||||
type PortChecker struct{}
|
||||
|
||||
// NewPortChecker creates a new PortChecker instance.
|
||||
func NewPortChecker() *PortChecker {
|
||||
return &PortChecker{}
|
||||
}
|
||||
|
||||
// CheckAvailability checks if the given ports are available for binding.
|
||||
// It returns a list of conflicts for ports that are already in use.
|
||||
// The skipPorts map contains ports currently managed by kportal that should be excluded from the check.
|
||||
func (pc *PortChecker) CheckAvailability(ports []int, skipPorts map[int]bool) []PortConflict {
|
||||
var conflicts []PortConflict
|
||||
|
||||
for _, port := range ports {
|
||||
// Skip ports that are already managed by kportal
|
||||
if skipPorts[port] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to bind to the port
|
||||
if !pc.isPortAvailable(port) {
|
||||
// Port is in use, get process info
|
||||
usedBy := pc.getProcessUsingPort(port)
|
||||
conflicts = append(conflicts, PortConflict{
|
||||
Port: port,
|
||||
UsedBy: usedBy,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts
|
||||
}
|
||||
|
||||
// isPortAvailable checks if a port is available by attempting to bind to it.
|
||||
func (pc *PortChecker) isPortAvailable(port int) bool {
|
||||
// Try to listen on the port
|
||||
addr := fmt.Sprintf(":%d", port)
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
listener.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// getProcessUsingPort returns information about the process using the given port.
|
||||
// Returns a string like "nginx (PID 1234)" or "unknown" if the process cannot be determined.
|
||||
func (pc *PortChecker) getProcessUsingPort(port int) string {
|
||||
switch runtime.GOOS {
|
||||
case "darwin", "linux":
|
||||
return pc.getProcessUsingPortUnix(port)
|
||||
case "windows":
|
||||
return pc.getProcessUsingPortWindows(port)
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// getProcessUsingPortUnix uses lsof to find the process using a port on Unix systems.
|
||||
func (pc *PortChecker) getProcessUsingPortUnix(port int) string {
|
||||
// Use lsof to find the process
|
||||
// lsof -i :PORT -sTCP:LISTEN -t returns PIDs
|
||||
cmd := exec.Command("lsof", "-i", fmt.Sprintf(":%d", port), "-sTCP:LISTEN", "-t")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
pidStr := strings.TrimSpace(string(output))
|
||||
if pidStr == "" {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// Get the first PID if multiple are returned
|
||||
pids := strings.Split(pidStr, "\n")
|
||||
pid := pids[0]
|
||||
|
||||
// Get process name using ps
|
||||
cmd = exec.Command("ps", "-p", pid, "-o", "comm=")
|
||||
output, err = cmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Sprintf("PID %s", pid)
|
||||
}
|
||||
|
||||
procName := strings.TrimSpace(string(output))
|
||||
if procName == "" {
|
||||
return fmt.Sprintf("PID %s", pid)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s (PID %s)", procName, pid)
|
||||
}
|
||||
|
||||
// getProcessUsingPortWindows uses netstat to find the process using a port on Windows.
|
||||
func (pc *PortChecker) getProcessUsingPortWindows(port int) string {
|
||||
// Use netstat to find the process
|
||||
// netstat -ano | findstr :PORT
|
||||
cmd := exec.Command("netstat", "-ano")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
lines := strings.Split(string(output), "\n")
|
||||
portStr := fmt.Sprintf(":%d", port)
|
||||
|
||||
for _, line := range lines {
|
||||
if !strings.Contains(line, portStr) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse the line to extract PID
|
||||
// Format: TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 1234
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 5 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is a LISTENING state
|
||||
if !strings.Contains(strings.ToUpper(line), "LISTENING") {
|
||||
continue
|
||||
}
|
||||
|
||||
pid := fields[len(fields)-1]
|
||||
|
||||
// Get process name using tasklist
|
||||
cmd = exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %s", pid), "/FO", "CSV", "/NH")
|
||||
output, err = cmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Sprintf("PID %s", pid)
|
||||
}
|
||||
|
||||
// Parse CSV output: "process.exe","1234","Console","1","12,345 K"
|
||||
csvLine := strings.TrimSpace(string(output))
|
||||
if csvLine == "" {
|
||||
return fmt.Sprintf("PID %s", pid)
|
||||
}
|
||||
|
||||
parts := strings.Split(csvLine, ",")
|
||||
if len(parts) > 0 {
|
||||
procName := strings.Trim(parts[0], "\"")
|
||||
return fmt.Sprintf("%s (PID %s)", procName, pid)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("PID %s", pid)
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// FormatConflicts formats port conflicts into a human-readable error message.
|
||||
func FormatConflicts(conflicts []PortConflict) string {
|
||||
if len(conflicts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("\nPort Conflicts Detected:\n")
|
||||
sb.WriteString(strings.Repeat("=", 50) + "\n\n")
|
||||
|
||||
for _, conflict := range conflicts {
|
||||
sb.WriteString(fmt.Sprintf("Port %d\n", conflict.Port))
|
||||
if conflict.Resource != "" {
|
||||
sb.WriteString(fmt.Sprintf(" Needed for: %s\n", conflict.Resource))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" Currently used by: %s\n", conflict.UsedBy))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("Action: Stop conflicting processes or change localPort in config.\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// GetPortsFromForwards extracts all local ports from a list of forward configurations.
|
||||
func GetPortsFromForwards(forwards []interface{}) []int {
|
||||
ports := make([]int, 0, len(forwards))
|
||||
for _, fwd := range forwards {
|
||||
// This function expects a generic interface to work with different forward types
|
||||
// The actual implementation should use the Forward struct from config package
|
||||
if f, ok := fwd.(interface{ GetLocalPort() int }); ok {
|
||||
ports = append(ports, f.GetLocalPort())
|
||||
}
|
||||
}
|
||||
return ports
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPortChecker_IsAvailable(t *testing.T) {
|
||||
pc := NewPortChecker()
|
||||
|
||||
// Test that isPortAvailable returns a bool
|
||||
// We use a high port that's likely to be available
|
||||
result := pc.isPortAvailable(54321)
|
||||
assert.IsType(t, false, result, "isPortAvailable should return bool")
|
||||
}
|
||||
|
||||
func TestPortChecker_CheckAvailability_EmptyPorts(t *testing.T) {
|
||||
pc := NewPortChecker()
|
||||
|
||||
// Test with empty ports slice
|
||||
conflicts := pc.CheckAvailability([]int{}, nil)
|
||||
assert.Empty(t, conflicts, "should return empty conflicts for empty ports")
|
||||
|
||||
// Test with nil exclude map
|
||||
conflicts = pc.CheckAvailability([]int{}, nil)
|
||||
assert.Empty(t, conflicts, "should return empty conflicts for nil exclude map")
|
||||
}
|
||||
|
||||
func TestPortChecker_CheckAvailability_ExcludeMap(t *testing.T) {
|
||||
pc := NewPortChecker()
|
||||
|
||||
// Create a listener to occupy a port
|
||||
listener, err := net.Listen("tcp", ":0")
|
||||
assert.NoError(t, err, "should create listener")
|
||||
defer listener.Close()
|
||||
|
||||
// Get the port that's now occupied
|
||||
addr := listener.Addr().(*net.TCPAddr)
|
||||
occupiedPort := addr.Port
|
||||
|
||||
// Test that the occupied port shows as conflicted
|
||||
conflicts := pc.CheckAvailability([]int{occupiedPort}, nil)
|
||||
assert.Len(t, conflicts, 1, "should detect conflict for occupied port")
|
||||
assert.Equal(t, occupiedPort, conflicts[0].Port)
|
||||
|
||||
// Test that skipPorts map excludes the port from conflict detection
|
||||
skipPorts := map[int]bool{
|
||||
occupiedPort: true,
|
||||
}
|
||||
conflicts = pc.CheckAvailability([]int{occupiedPort}, skipPorts)
|
||||
assert.Empty(t, conflicts, "should skip ports in exclude map")
|
||||
}
|
||||
|
||||
func TestPortChecker_CheckAvailability_MultipleSkipPorts(t *testing.T) {
|
||||
pc := NewPortChecker()
|
||||
|
||||
// Create multiple listeners
|
||||
listener1, err := net.Listen("tcp", ":0")
|
||||
assert.NoError(t, err)
|
||||
defer listener1.Close()
|
||||
|
||||
listener2, err := net.Listen("tcp", ":0")
|
||||
assert.NoError(t, err)
|
||||
defer listener2.Close()
|
||||
|
||||
port1 := listener1.Addr().(*net.TCPAddr).Port
|
||||
port2 := listener2.Addr().(*net.TCPAddr).Port
|
||||
|
||||
// Test with both ports occupied
|
||||
conflicts := pc.CheckAvailability([]int{port1, port2}, nil)
|
||||
assert.Len(t, conflicts, 2, "should detect both conflicts")
|
||||
|
||||
// Test excluding one port
|
||||
skipPorts := map[int]bool{port1: true}
|
||||
conflicts = pc.CheckAvailability([]int{port1, port2}, skipPorts)
|
||||
assert.Len(t, conflicts, 1, "should detect only non-excluded port")
|
||||
assert.Equal(t, port2, conflicts[0].Port)
|
||||
|
||||
// Test excluding both ports
|
||||
skipPorts = map[int]bool{port1: true, port2: true}
|
||||
conflicts = pc.CheckAvailability([]int{port1, port2}, skipPorts)
|
||||
assert.Empty(t, conflicts, "should skip all excluded ports")
|
||||
}
|
||||
|
||||
func TestPortChecker_GetProcessInfo(t *testing.T) {
|
||||
pc := NewPortChecker()
|
||||
|
||||
// Test that getProcessUsingPort returns a string
|
||||
// We don't test actual process detection to avoid flakiness
|
||||
result := pc.getProcessUsingPort(12345)
|
||||
assert.IsType(t, "", result, "getProcessUsingPort should return string")
|
||||
assert.NotEmpty(t, result, "should return some string (even if 'unknown')")
|
||||
}
|
||||
|
||||
func TestFormatConflicts_Empty(t *testing.T) {
|
||||
// Test with empty conflicts
|
||||
output := FormatConflicts([]PortConflict{})
|
||||
assert.Empty(t, output, "should return empty string for no conflicts")
|
||||
}
|
||||
|
||||
func TestFormatConflicts_SingleConflict(t *testing.T) {
|
||||
conflicts := []PortConflict{
|
||||
{
|
||||
Port: 8080,
|
||||
Resource: "dev/default/pod/my-app:8080",
|
||||
UsedBy: "nginx (PID 1234)",
|
||||
},
|
||||
}
|
||||
|
||||
output := FormatConflicts(conflicts)
|
||||
assert.NotEmpty(t, output, "should return non-empty output")
|
||||
assert.Contains(t, output, "Port Conflicts Detected", "should contain header")
|
||||
assert.Contains(t, output, "Port 8080", "should contain port number")
|
||||
assert.Contains(t, output, "dev/default/pod/my-app:8080", "should contain resource")
|
||||
assert.Contains(t, output, "nginx (PID 1234)", "should contain process info")
|
||||
}
|
||||
|
||||
func TestFormatConflicts_MultipleConflicts(t *testing.T) {
|
||||
conflicts := []PortConflict{
|
||||
{
|
||||
Port: 8080,
|
||||
Resource: "dev/default/pod/app1:8080",
|
||||
UsedBy: "nginx (PID 1234)",
|
||||
},
|
||||
{
|
||||
Port: 5432,
|
||||
Resource: "prod/database/service/postgres:5432",
|
||||
UsedBy: "postgres (PID 5678)",
|
||||
},
|
||||
}
|
||||
|
||||
output := FormatConflicts(conflicts)
|
||||
assert.NotEmpty(t, output, "should return non-empty output")
|
||||
assert.Contains(t, output, "Port Conflicts Detected", "should contain header")
|
||||
assert.Contains(t, output, "Port 8080", "should contain first port")
|
||||
assert.Contains(t, output, "Port 5432", "should contain second port")
|
||||
assert.Contains(t, output, "nginx (PID 1234)", "should contain first process")
|
||||
assert.Contains(t, output, "postgres (PID 5678)", "should contain second process")
|
||||
assert.Contains(t, output, "Action:", "should contain action message")
|
||||
}
|
||||
|
||||
func TestFormatConflicts_WithoutResource(t *testing.T) {
|
||||
conflicts := []PortConflict{
|
||||
{
|
||||
Port: 8080,
|
||||
UsedBy: "nginx (PID 1234)",
|
||||
},
|
||||
}
|
||||
|
||||
output := FormatConflicts(conflicts)
|
||||
assert.NotEmpty(t, output, "should return non-empty output")
|
||||
assert.Contains(t, output, "Port 8080", "should contain port")
|
||||
assert.Contains(t, output, "nginx (PID 1234)", "should contain process info")
|
||||
// Should not crash or include empty "Needed for:" line
|
||||
assert.NotContains(t, output, "Needed for: \n", "should not have empty resource line")
|
||||
}
|
||||
|
||||
func TestPortConflict_Structure(t *testing.T) {
|
||||
// Test that PortConflict structure works correctly
|
||||
conflict := PortConflict{
|
||||
Port: 8080,
|
||||
Resource: "dev/default/pod/app:8080",
|
||||
UsedBy: "nginx (PID 1234)",
|
||||
}
|
||||
|
||||
assert.Equal(t, 8080, conflict.Port)
|
||||
assert.Equal(t, "dev/default/pod/app:8080", conflict.Resource)
|
||||
assert.Equal(t, "nginx (PID 1234)", conflict.UsedBy)
|
||||
}
|
||||
|
||||
func TestNewPortChecker(t *testing.T) {
|
||||
pc := NewPortChecker()
|
||||
assert.NotNil(t, pc, "NewPortChecker should return non-nil instance")
|
||||
}
|
||||
|
||||
func TestPortChecker_PortAvailability_Integration(t *testing.T) {
|
||||
pc := NewPortChecker()
|
||||
|
||||
// Create a listener to occupy a port
|
||||
listener, err := net.Listen("tcp", ":0")
|
||||
assert.NoError(t, err, "should create listener")
|
||||
defer listener.Close()
|
||||
|
||||
// Get the occupied port
|
||||
occupiedPort := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
// Test that the port is correctly detected as unavailable
|
||||
available := pc.isPortAvailable(occupiedPort)
|
||||
assert.False(t, available, "occupied port should not be available")
|
||||
|
||||
// Close the listener
|
||||
listener.Close()
|
||||
|
||||
// The port should now be available (though there might be a brief delay)
|
||||
// We don't assert this to avoid flakiness in CI environments
|
||||
}
|
||||
|
||||
func TestPortChecker_CheckAvailability_AvailablePorts(t *testing.T) {
|
||||
pc := NewPortChecker()
|
||||
|
||||
// Use high port numbers that are very unlikely to be in use
|
||||
// This test might be slightly flaky in unusual environments, but should be stable
|
||||
unlikelyPorts := []int{54321, 54322, 54323}
|
||||
|
||||
conflicts := pc.CheckAvailability(unlikelyPorts, nil)
|
||||
|
||||
// Most likely all ports will be available
|
||||
// The function returns nil or empty slice when there are no conflicts
|
||||
// We just verify the function executes without panicking
|
||||
_ = conflicts
|
||||
}
|
||||
|
||||
func TestFormatConflicts_Formatting(t *testing.T) {
|
||||
conflicts := []PortConflict{
|
||||
{
|
||||
Port: 8080,
|
||||
Resource: "dev/default/pod/my-app:8080",
|
||||
UsedBy: "nginx (PID 1234)",
|
||||
},
|
||||
}
|
||||
|
||||
output := FormatConflicts(conflicts)
|
||||
|
||||
// Check formatting details
|
||||
assert.Contains(t, output, "==================================================", "should contain separator line")
|
||||
assert.Contains(t, output, "\n", "should contain newlines")
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
"github.com/nvm/kportal/internal/retry"
|
||||
)
|
||||
|
||||
// ForwardWorker manages a single port-forward connection with automatic retry.
|
||||
type ForwardWorker struct {
|
||||
forward config.Forward
|
||||
portForwarder *k8s.PortForwarder
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
stopChan chan struct{}
|
||||
doneChan chan struct{}
|
||||
verbose bool
|
||||
lastPod string // Track the last pod we connected to
|
||||
}
|
||||
|
||||
// NewForwardWorker creates a new ForwardWorker for a single forward configuration.
|
||||
func NewForwardWorker(fwd config.Forward, portForwarder *k8s.PortForwarder, verbose bool) *ForwardWorker {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &ForwardWorker{
|
||||
forward: fwd,
|
||||
portForwarder: portForwarder,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
stopChan: make(chan struct{}),
|
||||
doneChan: make(chan struct{}),
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the port-forward worker in a goroutine.
|
||||
// The worker will continuously retry on failures with exponential backoff.
|
||||
func (w *ForwardWorker) Start() {
|
||||
go w.run()
|
||||
}
|
||||
|
||||
// Stop gracefully stops the port-forward worker.
|
||||
func (w *ForwardWorker) Stop() {
|
||||
w.cancel()
|
||||
close(w.stopChan)
|
||||
<-w.doneChan // Wait for worker to finish
|
||||
}
|
||||
|
||||
// run is the main worker loop that handles retries.
|
||||
func (w *ForwardWorker) run() {
|
||||
defer close(w.doneChan)
|
||||
|
||||
backoff := retry.NewBackoff()
|
||||
|
||||
for {
|
||||
// Check if we should stop
|
||||
select {
|
||||
case <-w.ctx.Done():
|
||||
if w.verbose {
|
||||
log.Printf("[%s] Worker stopped", w.forward.ID())
|
||||
}
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Resolve the resource to get current pod name
|
||||
podName, err := w.portForwarder.GetPodForResource(
|
||||
w.ctx,
|
||||
w.forward.GetContext(),
|
||||
w.forward.GetNamespace(),
|
||||
w.forward.Resource,
|
||||
w.forward.Selector,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[%s] Failed to resolve resource: %v", w.forward.ID(), err)
|
||||
w.sleepWithBackoff(backoff)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if pod changed (restart detected)
|
||||
if w.lastPod != "" && w.lastPod != podName {
|
||||
log.Printf("[%s] Switched to new pod: %s → %s", w.forward.ID(), w.lastPod, podName)
|
||||
} else if w.lastPod == "" {
|
||||
log.Printf("[%s] Forwarding %s → localhost:%d",
|
||||
w.forward.ID(), w.forward.String(), w.forward.LocalPort)
|
||||
}
|
||||
|
||||
w.lastPod = podName
|
||||
|
||||
// Establish port-forward connection
|
||||
err = w.establishForward(podName)
|
||||
|
||||
if err != nil {
|
||||
// Connection failed or was interrupted
|
||||
if w.ctx.Err() != nil {
|
||||
// Context was cancelled, exit gracefully
|
||||
return
|
||||
}
|
||||
|
||||
// Log the error
|
||||
log.Printf("[%s] Port-forward connection failed: %v", w.forward.ID(), err)
|
||||
|
||||
// Clear last pod so we re-resolve on next attempt
|
||||
w.lastPod = ""
|
||||
|
||||
// Wait with backoff before retrying
|
||||
w.sleepWithBackoff(backoff)
|
||||
continue
|
||||
}
|
||||
|
||||
// Connection closed normally (shouldn't happen unless stopped)
|
||||
if w.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Connection closed unexpectedly, retry
|
||||
log.Printf("[%s] Connection closed unexpectedly, retrying...", w.forward.ID())
|
||||
w.lastPod = ""
|
||||
w.sleepWithBackoff(backoff)
|
||||
}
|
||||
}
|
||||
|
||||
// establishForward establishes a port-forward connection.
|
||||
// This blocks until the connection is closed or an error occurs.
|
||||
func (w *ForwardWorker) establishForward(podName string) error {
|
||||
// Create channels for this forward
|
||||
stopChan := make(chan struct{}, 1)
|
||||
readyChan := make(chan struct{}, 1)
|
||||
|
||||
// Create a context for this forward attempt
|
||||
forwardCtx, forwardCancel := context.WithCancel(w.ctx)
|
||||
defer forwardCancel()
|
||||
|
||||
// Start a goroutine to monitor for stop signal
|
||||
go func() {
|
||||
select {
|
||||
case <-w.stopChan:
|
||||
close(stopChan)
|
||||
case <-forwardCtx.Done():
|
||||
close(stopChan)
|
||||
}
|
||||
}()
|
||||
|
||||
// Set up output writers
|
||||
var out, errOut io.Writer
|
||||
if w.verbose {
|
||||
out = &logWriter{prefix: fmt.Sprintf("[%s] ", w.forward.ID())}
|
||||
errOut = &logWriter{prefix: fmt.Sprintf("[%s] ERROR: ", w.forward.ID())}
|
||||
} else {
|
||||
out = io.Discard
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
// Create forward request
|
||||
req := &k8s.ForwardRequest{
|
||||
ContextName: w.forward.GetContext(),
|
||||
Namespace: w.forward.GetNamespace(),
|
||||
Resource: w.forward.Resource,
|
||||
Selector: w.forward.Selector,
|
||||
LocalPort: w.forward.LocalPort,
|
||||
RemotePort: w.forward.Port,
|
||||
StopChan: stopChan,
|
||||
ReadyChan: readyChan,
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
}
|
||||
|
||||
// Start port forwarding in a goroutine
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
errChan <- w.portForwarder.Forward(forwardCtx, req)
|
||||
}()
|
||||
|
||||
// Wait for ready or error
|
||||
select {
|
||||
case <-readyChan:
|
||||
if w.verbose {
|
||||
log.Printf("[%s] Port-forward connection established", w.forward.ID())
|
||||
}
|
||||
case err := <-errChan:
|
||||
return fmt.Errorf("failed to establish forward: %w", err)
|
||||
case <-w.ctx.Done():
|
||||
return nil
|
||||
case <-time.After(30 * time.Second):
|
||||
return fmt.Errorf("timeout waiting for port-forward to become ready")
|
||||
}
|
||||
|
||||
// Wait for connection to close or error
|
||||
select {
|
||||
case err := <-errChan:
|
||||
return err
|
||||
case <-w.ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// sleepWithBackoff waits for the next backoff duration.
|
||||
// Returns early if the worker is stopped.
|
||||
func (w *ForwardWorker) sleepWithBackoff(backoff *retry.Backoff) {
|
||||
delay := backoff.Next()
|
||||
|
||||
if w.verbose {
|
||||
log.Printf("[%s] Retrying in %v (attempt %d)", w.forward.ID(), delay, backoff.Attempt())
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
// Continue with retry
|
||||
case <-w.ctx.Done():
|
||||
// Worker stopped
|
||||
}
|
||||
}
|
||||
|
||||
// GetForward returns the forward configuration for this worker.
|
||||
func (w *ForwardWorker) GetForward() config.Forward {
|
||||
return w.forward
|
||||
}
|
||||
|
||||
// IsRunning returns true if the worker is running.
|
||||
func (w *ForwardWorker) IsRunning() bool {
|
||||
select {
|
||||
case <-w.doneChan:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// logWriter implements io.Writer to write log messages with a prefix.
|
||||
type logWriter struct {
|
||||
prefix string
|
||||
}
|
||||
|
||||
func (lw *logWriter) Write(p []byte) (n int, err error) {
|
||||
log.Printf("%s%s", lw.prefix, string(p))
|
||||
return len(p), nil
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
// ClientPool manages Kubernetes clients per context with thread-safe access.
|
||||
type ClientPool struct {
|
||||
mu sync.RWMutex
|
||||
clients map[string]*kubernetes.Clientset
|
||||
configs map[string]*rest.Config
|
||||
loader clientcmd.ClientConfig
|
||||
}
|
||||
|
||||
// NewClientPool creates a new ClientPool instance.
|
||||
func NewClientPool() (*ClientPool, error) {
|
||||
// Load kubeconfig using default loading rules
|
||||
loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
|
||||
configOverrides := &clientcmd.ConfigOverrides{}
|
||||
|
||||
loader := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides)
|
||||
|
||||
return &ClientPool{
|
||||
clients: make(map[string]*kubernetes.Clientset),
|
||||
configs: make(map[string]*rest.Config),
|
||||
loader: loader,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetClient returns a Kubernetes client for the given context.
|
||||
// Clients are cached and reused across multiple calls.
|
||||
// This method is thread-safe.
|
||||
func (p *ClientPool) GetClient(contextName string) (*kubernetes.Clientset, error) {
|
||||
// Try to get cached client (read lock)
|
||||
p.mu.RLock()
|
||||
client, exists := p.clients[contextName]
|
||||
p.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// Client doesn't exist, create it (write lock)
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Double-check in case another goroutine created it while we waited
|
||||
if client, exists := p.clients[contextName]; exists {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// Create new client
|
||||
config, err := p.getRestConfig(contextName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get rest config for context %s: %w", contextName, err)
|
||||
}
|
||||
|
||||
client, err = kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create client for context %s: %w", contextName, err)
|
||||
}
|
||||
|
||||
// Cache the client and config
|
||||
p.clients[contextName] = client
|
||||
p.configs[contextName] = config
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GetRestConfig returns the REST config for the given context.
|
||||
// Configs are cached and reused.
|
||||
// This method is thread-safe.
|
||||
func (p *ClientPool) GetRestConfig(contextName string) (*rest.Config, error) {
|
||||
// Try to get cached config (read lock)
|
||||
p.mu.RLock()
|
||||
config, exists := p.configs[contextName]
|
||||
p.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Config doesn't exist, create it (write lock)
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Double-check in case another goroutine created it while we waited
|
||||
if config, exists := p.configs[contextName]; exists {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Create new config
|
||||
config, err := p.getRestConfig(contextName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the config
|
||||
p.configs[contextName] = config
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// getRestConfig creates a REST config for the given context.
|
||||
// This is an internal method that should only be called with a lock held.
|
||||
func (p *ClientPool) getRestConfig(contextName string) (*rest.Config, error) {
|
||||
// Load the raw kubeconfig
|
||||
rawConfig, err := p.loader.RawConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load kubeconfig: %w", err)
|
||||
}
|
||||
|
||||
// Check if the context exists
|
||||
if _, exists := rawConfig.Contexts[contextName]; !exists {
|
||||
return nil, fmt.Errorf("context %s not found in kubeconfig", contextName)
|
||||
}
|
||||
|
||||
// Create config overrides for the specific context
|
||||
overrides := &clientcmd.ConfigOverrides{
|
||||
CurrentContext: contextName,
|
||||
}
|
||||
|
||||
// Build the config
|
||||
config, err := clientcmd.NewNonInteractiveClientConfig(
|
||||
rawConfig,
|
||||
contextName,
|
||||
overrides,
|
||||
clientcmd.NewDefaultClientConfigLoadingRules(),
|
||||
).ClientConfig()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build client config for context %s: %w", contextName, err)
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// GetCurrentContext returns the name of the current context from kubeconfig.
|
||||
func (p *ClientPool) GetCurrentContext() (string, error) {
|
||||
rawConfig, err := p.loader.RawConfig()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load kubeconfig: %w", err)
|
||||
}
|
||||
|
||||
return rawConfig.CurrentContext, nil
|
||||
}
|
||||
|
||||
// ListContexts returns a list of all available contexts from kubeconfig.
|
||||
func (p *ClientPool) ListContexts() ([]string, error) {
|
||||
rawConfig, err := p.loader.RawConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load kubeconfig: %w", err)
|
||||
}
|
||||
|
||||
contexts := make([]string, 0, len(rawConfig.Contexts))
|
||||
for name := range rawConfig.Contexts {
|
||||
contexts = append(contexts, name)
|
||||
}
|
||||
|
||||
return contexts, nil
|
||||
}
|
||||
|
||||
// ClearCache removes all cached clients and configs.
|
||||
// This is useful for testing or when kubeconfig has been updated.
|
||||
func (p *ClientPool) ClearCache() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.clients = make(map[string]*kubernetes.Clientset)
|
||||
p.configs = make(map[string]*rest.Config)
|
||||
}
|
||||
|
||||
// RemoveContext removes a specific context from the cache.
|
||||
// This is useful when a context is removed or updated.
|
||||
func (p *ClientPool) RemoveContext(contextName string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
delete(p.clients, contextName)
|
||||
delete(p.configs, contextName)
|
||||
}
|
||||
|
||||
// GetNamespace returns the default namespace for the given context.
|
||||
func (p *ClientPool) GetNamespace(contextName string) (string, error) {
|
||||
rawConfig, err := p.loader.RawConfig()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load kubeconfig: %w", err)
|
||||
}
|
||||
|
||||
context, exists := rawConfig.Contexts[contextName]
|
||||
if !exists {
|
||||
return "", fmt.Errorf("context %s not found", contextName)
|
||||
}
|
||||
|
||||
// Return the namespace from the context, or "default" if not specified
|
||||
if context.Namespace == "" {
|
||||
return corev1.NamespaceDefault, nil
|
||||
}
|
||||
|
||||
return context.Namespace, nil
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewClientPool(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
|
||||
assert.NoError(t, err, "NewClientPool should not return error")
|
||||
assert.NotNil(t, pool, "pool should not be nil")
|
||||
assert.NotNil(t, pool.clients, "clients map should be initialized")
|
||||
assert.NotNil(t, pool.configs, "configs map should be initialized")
|
||||
assert.Empty(t, pool.clients, "clients map should be empty initially")
|
||||
assert.Empty(t, pool.configs, "configs map should be empty initially")
|
||||
}
|
||||
|
||||
func TestClientPool_ClearCache(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Initially empty
|
||||
assert.Empty(t, pool.clients)
|
||||
assert.Empty(t, pool.configs)
|
||||
|
||||
// Call ClearCache on empty pool (should not panic)
|
||||
pool.ClearCache()
|
||||
|
||||
// Should still be empty
|
||||
assert.Empty(t, pool.clients)
|
||||
assert.Empty(t, pool.configs)
|
||||
}
|
||||
|
||||
func TestClientPool_RemoveContext(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Remove from empty pool (should not panic)
|
||||
pool.RemoveContext("non-existent-context")
|
||||
|
||||
// Should still be empty
|
||||
assert.Empty(t, pool.clients)
|
||||
assert.Empty(t, pool.configs)
|
||||
}
|
||||
|
||||
func TestClientPool_Structure(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify structure - just check maps are initialized
|
||||
assert.NotNil(t, pool.clients, "clients map should exist")
|
||||
assert.NotNil(t, pool.configs, "configs map should exist")
|
||||
assert.NotNil(t, pool.loader, "loader should exist")
|
||||
}
|
||||
|
||||
func TestClientPool_GetCurrentContext(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Try to get current context
|
||||
// This may fail if kubeconfig is not available, which is fine for unit tests
|
||||
context, err := pool.GetCurrentContext()
|
||||
|
||||
if err == nil {
|
||||
// If successful, context should be a string
|
||||
assert.IsType(t, "", context)
|
||||
} else {
|
||||
// If failed, error should mention kubeconfig
|
||||
assert.Contains(t, err.Error(), "kubeconfig")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPool_ListContexts(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Try to list contexts
|
||||
// This may fail if kubeconfig is not available, which is fine for unit tests
|
||||
contexts, err := pool.ListContexts()
|
||||
|
||||
if err == nil {
|
||||
// If successful, contexts should be a slice
|
||||
assert.NotNil(t, contexts)
|
||||
assert.IsType(t, []string{}, contexts)
|
||||
} else {
|
||||
// If failed, error should mention kubeconfig
|
||||
assert.Contains(t, err.Error(), "kubeconfig")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPool_GetNamespace(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Try to get namespace for a non-existent context
|
||||
namespace, err := pool.GetNamespace("non-existent-context")
|
||||
|
||||
// This should fail with context not found error
|
||||
if err != nil {
|
||||
// Error is expected, check it mentions the context or kubeconfig
|
||||
errMsg := err.Error()
|
||||
containsContext := assert.Contains(t, errMsg, "context", "error should mention context") ||
|
||||
assert.Contains(t, errMsg, "kubeconfig", "error should mention kubeconfig")
|
||||
assert.True(t, containsContext)
|
||||
} else {
|
||||
// If no error (unlikely), namespace should be a string
|
||||
assert.IsType(t, "", namespace)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPool_GetClient_NonExistentContext(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Try to get a client for a non-existent context
|
||||
client, err := pool.GetClient("non-existent-context")
|
||||
|
||||
// This should fail
|
||||
assert.Error(t, err, "should return error for non-existent context")
|
||||
assert.Nil(t, client, "client should be nil on error")
|
||||
}
|
||||
|
||||
func TestClientPool_GetRestConfig_NonExistentContext(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Try to get a rest config for a non-existent context
|
||||
config, err := pool.GetRestConfig("non-existent-context")
|
||||
|
||||
// This should fail
|
||||
assert.Error(t, err, "should return error for non-existent context")
|
||||
assert.Nil(t, config, "config should be nil on error")
|
||||
}
|
||||
|
||||
func TestClientPool_ThreadSafety(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test that concurrent operations don't panic
|
||||
// We don't check for errors as the context may not exist
|
||||
done := make(chan bool)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
go func() {
|
||||
pool.ClearCache()
|
||||
pool.RemoveContext("test-context")
|
||||
pool.GetCurrentContext()
|
||||
pool.ListContexts()
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// If we get here without panic, thread safety is working
|
||||
assert.True(t, true, "concurrent operations should not panic")
|
||||
}
|
||||
|
||||
func TestClientPool_CacheBehavior(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Initially empty
|
||||
assert.Empty(t, pool.clients)
|
||||
assert.Empty(t, pool.configs)
|
||||
|
||||
// Add a context to the internal cache manually for testing
|
||||
// Note: This is a simplified test that doesn't require actual kubeconfig
|
||||
pool.mu.Lock()
|
||||
testContext := "test-context"
|
||||
pool.clients[testContext] = nil // Just mark as cached
|
||||
pool.configs[testContext] = nil // Just mark as cached
|
||||
pool.mu.Unlock()
|
||||
|
||||
// Verify it's cached
|
||||
pool.mu.RLock()
|
||||
_, clientExists := pool.clients[testContext]
|
||||
_, configExists := pool.configs[testContext]
|
||||
pool.mu.RUnlock()
|
||||
|
||||
assert.True(t, clientExists, "client should be in cache")
|
||||
assert.True(t, configExists, "config should be in cache")
|
||||
|
||||
// Remove context
|
||||
pool.RemoveContext(testContext)
|
||||
|
||||
// Verify it's removed
|
||||
pool.mu.RLock()
|
||||
_, clientExists = pool.clients[testContext]
|
||||
_, configExists = pool.configs[testContext]
|
||||
pool.mu.RUnlock()
|
||||
|
||||
assert.False(t, clientExists, "client should be removed from cache")
|
||||
assert.False(t, configExists, "config should be removed from cache")
|
||||
|
||||
// Add back and clear cache
|
||||
pool.mu.Lock()
|
||||
pool.clients[testContext] = nil
|
||||
pool.configs[testContext] = nil
|
||||
pool.mu.Unlock()
|
||||
|
||||
pool.ClearCache()
|
||||
|
||||
// Verify cache is cleared
|
||||
assert.Empty(t, pool.clients, "clients should be cleared")
|
||||
assert.Empty(t, pool.configs, "configs should be cleared")
|
||||
}
|
||||
|
||||
func TestClientPool_EmptyPoolOperations(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test various operations on empty pool (should not panic)
|
||||
pool.ClearCache()
|
||||
pool.RemoveContext("any-context")
|
||||
|
||||
// All these operations should complete without panic
|
||||
assert.NotNil(t, pool, "pool should still be valid")
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/portforward"
|
||||
"k8s.io/client-go/transport/spdy"
|
||||
)
|
||||
|
||||
// PortForwarder handles Kubernetes port-forwarding operations.
|
||||
type PortForwarder struct {
|
||||
clientPool *ClientPool
|
||||
resolver *ResourceResolver
|
||||
}
|
||||
|
||||
// NewPortForwarder creates a new PortForwarder instance.
|
||||
func NewPortForwarder(clientPool *ClientPool, resolver *ResourceResolver) *PortForwarder {
|
||||
return &PortForwarder{
|
||||
clientPool: clientPool,
|
||||
resolver: resolver,
|
||||
}
|
||||
}
|
||||
|
||||
// ForwardRequest contains the parameters for a port-forward request.
|
||||
type ForwardRequest struct {
|
||||
ContextName string // Kubernetes context name
|
||||
Namespace string // Namespace
|
||||
Resource string // Resource (pod/name or service/name)
|
||||
Selector string // Label selector (for pod resolution)
|
||||
LocalPort int // Local port
|
||||
RemotePort int // Remote port
|
||||
StopChan chan struct{}
|
||||
ReadyChan chan struct{}
|
||||
Out io.Writer // Output writer for logs
|
||||
ErrOut io.Writer // Error output writer
|
||||
}
|
||||
|
||||
// Forward establishes a port-forward connection to a Kubernetes resource.
|
||||
// It supports both pod and service forwarding.
|
||||
// The connection runs until StopChan is closed or an error occurs.
|
||||
func (pf *PortForwarder) Forward(ctx context.Context, req *ForwardRequest) error {
|
||||
// Resolve the resource to an actual pod name
|
||||
resolvedResource, err := pf.resolver.Resolve(ctx, req.ContextName, req.Namespace, req.Resource, req.Selector)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve resource: %w", err)
|
||||
}
|
||||
|
||||
// Parse the resolved resource
|
||||
parts := strings.SplitN(resolvedResource, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("invalid resolved resource format: %s", resolvedResource)
|
||||
}
|
||||
|
||||
resourceType := parts[0]
|
||||
resourceName := parts[1]
|
||||
|
||||
// Handle different resource types
|
||||
switch resourceType {
|
||||
case "pod":
|
||||
return pf.forwardToPod(ctx, req, resourceName)
|
||||
case "service":
|
||||
return pf.forwardToService(ctx, req, resourceName)
|
||||
default:
|
||||
return fmt.Errorf("unsupported resource type: %s", resourceType)
|
||||
}
|
||||
}
|
||||
|
||||
// forwardToPod establishes a port-forward to a specific pod.
|
||||
func (pf *PortForwarder) forwardToPod(ctx context.Context, req *ForwardRequest, podName string) error {
|
||||
// Get Kubernetes client and config
|
||||
client, err := pf.clientPool.GetClient(req.ContextName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
config, err := pf.clientPool.GetRestConfig(req.ContextName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get rest config: %w", err)
|
||||
}
|
||||
|
||||
// Verify pod exists and is running
|
||||
pod, err := client.CoreV1().Pods(req.Namespace).Get(ctx, podName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get pod: %w", err)
|
||||
}
|
||||
|
||||
if pod.Status.Phase != corev1.PodRunning {
|
||||
return fmt.Errorf("pod is not running (current phase: %s)", pod.Status.Phase)
|
||||
}
|
||||
|
||||
// Build the port-forward URL
|
||||
reqURL := client.CoreV1().RESTClient().Post().
|
||||
Resource("pods").
|
||||
Namespace(req.Namespace).
|
||||
Name(podName).
|
||||
SubResource("portforward").
|
||||
URL()
|
||||
|
||||
// Create the port-forward
|
||||
return pf.executePortForward(config, reqURL, req)
|
||||
}
|
||||
|
||||
// forwardToService establishes a port-forward to a service.
|
||||
// This resolves the service to its backing pods and forwards to one of them.
|
||||
func (pf *PortForwarder) forwardToService(ctx context.Context, req *ForwardRequest, serviceName string) error {
|
||||
// Get Kubernetes client
|
||||
client, err := pf.clientPool.GetClient(req.ContextName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
// Get the service
|
||||
service, err := client.CoreV1().Services(req.Namespace).Get(ctx, serviceName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get service: %w", err)
|
||||
}
|
||||
|
||||
// Get pods backing the service using label selector
|
||||
selector := metav1.FormatLabelSelector(&metav1.LabelSelector{MatchLabels: service.Spec.Selector})
|
||||
pods, err := client.CoreV1().Pods(req.Namespace).List(ctx, metav1.ListOptions{
|
||||
LabelSelector: selector,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list pods for service: %w", err)
|
||||
}
|
||||
|
||||
// Find first running pod
|
||||
var targetPod *corev1.Pod
|
||||
for i := range pods.Items {
|
||||
pod := &pods.Items[i]
|
||||
if pod.Status.Phase == corev1.PodRunning {
|
||||
targetPod = pod
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if targetPod == nil {
|
||||
return fmt.Errorf("no running pods found for service %s", serviceName)
|
||||
}
|
||||
|
||||
// Forward to the pod
|
||||
config, err := pf.clientPool.GetRestConfig(req.ContextName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get rest config: %w", err)
|
||||
}
|
||||
|
||||
reqURL := client.CoreV1().RESTClient().Post().
|
||||
Resource("pods").
|
||||
Namespace(req.Namespace).
|
||||
Name(targetPod.Name).
|
||||
SubResource("portforward").
|
||||
URL()
|
||||
|
||||
return pf.executePortForward(config, reqURL, req)
|
||||
}
|
||||
|
||||
// executePortForward performs the actual port-forward operation.
|
||||
func (pf *PortForwarder) executePortForward(config *rest.Config, url *url.URL, req *ForwardRequest) error {
|
||||
// Create SPDY roundtripper
|
||||
transport, upgrader, err := spdy.RoundTripperFor(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create round tripper: %w", err)
|
||||
}
|
||||
|
||||
// Create dialer
|
||||
dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, http.MethodPost, url)
|
||||
|
||||
// Set up port forwarding
|
||||
ports := []string{fmt.Sprintf("%d:%d", req.LocalPort, req.RemotePort)}
|
||||
|
||||
// Create output writers
|
||||
out := req.Out
|
||||
errOut := req.ErrOut
|
||||
if out == nil {
|
||||
out = io.Discard
|
||||
}
|
||||
if errOut == nil {
|
||||
errOut = io.Discard
|
||||
}
|
||||
|
||||
// Create port forwarder
|
||||
fw, err := portforward.New(dialer, ports, req.StopChan, req.ReadyChan, out, errOut)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create port forwarder: %w", err)
|
||||
}
|
||||
|
||||
// Start forwarding (blocks until stopped or error)
|
||||
if err := fw.ForwardPorts(); err != nil {
|
||||
return fmt.Errorf("port forward failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPodForResource returns the pod name that would be used for forwarding.
|
||||
// This is useful for logging and debugging.
|
||||
func (pf *PortForwarder) GetPodForResource(ctx context.Context, contextName, namespace, resource, selector string) (string, error) {
|
||||
resolvedResource, err := pf.resolver.Resolve(ctx, contextName, namespace, resource, selector)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
parts := strings.SplitN(resolvedResource, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid resolved resource format: %s", resolvedResource)
|
||||
}
|
||||
|
||||
resourceType := parts[0]
|
||||
resourceName := parts[1]
|
||||
|
||||
if resourceType == "service" {
|
||||
// For services, need to resolve to backing pod
|
||||
client, err := pf.clientPool.GetClient(contextName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
service, err := client.CoreV1().Services(namespace).Get(ctx, resourceName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get service: %w", err)
|
||||
}
|
||||
|
||||
selector := metav1.FormatLabelSelector(&metav1.LabelSelector{MatchLabels: service.Spec.Selector})
|
||||
pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
|
||||
LabelSelector: selector,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list pods: %w", err)
|
||||
}
|
||||
|
||||
for i := range pods.Items {
|
||||
if pods.Items[i].Status.Phase == corev1.PodRunning {
|
||||
return pods.Items[i].Name, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no running pods found for service")
|
||||
}
|
||||
|
||||
return resourceName, nil
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
// Default cache TTL for resolved resources
|
||||
defaultCacheTTL = 30 * time.Second
|
||||
)
|
||||
|
||||
// ResolvedResource represents a resolved Kubernetes resource.
|
||||
type ResolvedResource struct {
|
||||
Name string // The resolved pod or service name
|
||||
Namespace string // The namespace
|
||||
Timestamp time.Time // When this was resolved
|
||||
}
|
||||
|
||||
// cacheEntry stores a cached resolution result with expiry.
|
||||
type cacheEntry struct {
|
||||
resource ResolvedResource
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// ResourceResolver resolves Kubernetes resources with caching.
|
||||
// It handles prefix matching for pods and label selector resolution.
|
||||
type ResourceResolver struct {
|
||||
clientPool *ClientPool
|
||||
cache map[string]cacheEntry // key: contextName/namespace/resource -> resolved name
|
||||
cacheMu sync.RWMutex
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
// NewResourceResolver creates a new ResourceResolver instance.
|
||||
func NewResourceResolver(clientPool *ClientPool) *ResourceResolver {
|
||||
return &ResourceResolver{
|
||||
clientPool: clientPool,
|
||||
cache: make(map[string]cacheEntry),
|
||||
cacheTTL: defaultCacheTTL,
|
||||
}
|
||||
}
|
||||
|
||||
// SetCacheTTL sets the cache TTL for resolved resources.
|
||||
func (r *ResourceResolver) SetCacheTTL(ttl time.Duration) {
|
||||
r.cacheTTL = ttl
|
||||
}
|
||||
|
||||
// Resolve resolves a resource name to an actual pod or service name.
|
||||
// It supports:
|
||||
// - pod/prefix: Prefix matching (e.g., "pod/my-app" matches "my-app-xyz789")
|
||||
// - pod + selector: Label selector matching (e.g., "pod" with selector "app=nginx")
|
||||
// - service/name: Direct service name (no resolution needed)
|
||||
func (r *ResourceResolver) Resolve(ctx context.Context, contextName, namespace, resource, selector string) (string, error) {
|
||||
// Parse resource type and name
|
||||
parts := strings.SplitN(resource, "/", 2)
|
||||
resourceType := parts[0]
|
||||
|
||||
// Services don't need resolution
|
||||
if resourceType == "service" {
|
||||
if len(parts) < 2 {
|
||||
return "", fmt.Errorf("invalid service resource format: %s", resource)
|
||||
}
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
// Handle pod resolution
|
||||
if resourceType == "pod" {
|
||||
if len(parts) == 2 {
|
||||
// pod/prefix format - prefix matching
|
||||
prefix := parts[1]
|
||||
return r.resolvePodPrefix(ctx, contextName, namespace, prefix)
|
||||
}
|
||||
|
||||
// pod with selector - label selector matching
|
||||
if selector != "" {
|
||||
return r.resolvePodSelector(ctx, contextName, namespace, selector)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("pod resource requires either a name prefix (pod/name) or a selector")
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("unsupported resource type: %s", resourceType)
|
||||
}
|
||||
|
||||
// resolvePodPrefix resolves a pod name using prefix matching.
|
||||
// It returns the newest running pod that matches the prefix.
|
||||
func (r *ResourceResolver) resolvePodPrefix(ctx context.Context, contextName, namespace, prefix string) (string, error) {
|
||||
// Check cache first
|
||||
cacheKey := fmt.Sprintf("%s/%s/pod/%s", contextName, namespace, prefix)
|
||||
if cached := r.getFromCache(cacheKey); cached != "" {
|
||||
return fmt.Sprintf("pod/%s", cached), nil
|
||||
}
|
||||
|
||||
// Get Kubernetes client
|
||||
client, err := r.clientPool.GetClient(contextName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
// List all pods in the namespace
|
||||
pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list pods: %w", err)
|
||||
}
|
||||
|
||||
// Find pods matching the prefix
|
||||
var matchingPods []*corev1.Pod
|
||||
for i := range pods.Items {
|
||||
pod := &pods.Items[i]
|
||||
if strings.HasPrefix(pod.Name, prefix) && pod.Status.Phase == corev1.PodRunning {
|
||||
matchingPods = append(matchingPods, pod)
|
||||
}
|
||||
}
|
||||
|
||||
if len(matchingPods) == 0 {
|
||||
return "", fmt.Errorf("no running pods found matching prefix '%s' in namespace %s", prefix, namespace)
|
||||
}
|
||||
|
||||
// Sort by creation timestamp (newest first)
|
||||
sort.Slice(matchingPods, func(i, j int) bool {
|
||||
return matchingPods[i].CreationTimestamp.After(matchingPods[j].CreationTimestamp.Time)
|
||||
})
|
||||
|
||||
// Return the newest pod
|
||||
resolvedName := matchingPods[0].Name
|
||||
r.putInCache(cacheKey, resolvedName)
|
||||
|
||||
return fmt.Sprintf("pod/%s", resolvedName), nil
|
||||
}
|
||||
|
||||
// resolvePodSelector resolves a pod name using label selectors.
|
||||
// It returns the first running pod matching the selector.
|
||||
func (r *ResourceResolver) resolvePodSelector(ctx context.Context, contextName, namespace, selector string) (string, error) {
|
||||
// Check cache first
|
||||
cacheKey := fmt.Sprintf("%s/%s/pod?selector=%s", contextName, namespace, selector)
|
||||
if cached := r.getFromCache(cacheKey); cached != "" {
|
||||
return fmt.Sprintf("pod/%s", cached), nil
|
||||
}
|
||||
|
||||
// Get Kubernetes client
|
||||
client, err := r.clientPool.GetClient(contextName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
// List pods matching the selector
|
||||
pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
|
||||
LabelSelector: selector,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list pods with selector '%s': %w", selector, err)
|
||||
}
|
||||
|
||||
// Find first running pod
|
||||
for i := range pods.Items {
|
||||
pod := &pods.Items[i]
|
||||
if pod.Status.Phase == corev1.PodRunning {
|
||||
resolvedName := pod.Name
|
||||
r.putInCache(cacheKey, resolvedName)
|
||||
return fmt.Sprintf("pod/%s", resolvedName), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no running pods found matching selector '%s' in namespace %s", selector, namespace)
|
||||
}
|
||||
|
||||
// getFromCache retrieves a cached resolution result if it exists and hasn't expired.
|
||||
func (r *ResourceResolver) getFromCache(key string) string {
|
||||
r.cacheMu.RLock()
|
||||
defer r.cacheMu.RUnlock()
|
||||
|
||||
entry, exists := r.cache[key]
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return entry.resource.Name
|
||||
}
|
||||
|
||||
// putInCache stores a resolution result in the cache with TTL.
|
||||
func (r *ResourceResolver) putInCache(key, value string) {
|
||||
r.cacheMu.Lock()
|
||||
defer r.cacheMu.Unlock()
|
||||
|
||||
r.cache[key] = cacheEntry{
|
||||
resource: ResolvedResource{
|
||||
Name: value,
|
||||
Timestamp: time.Now(),
|
||||
},
|
||||
expiresAt: time.Now().Add(r.cacheTTL),
|
||||
}
|
||||
}
|
||||
|
||||
// ClearCache clears all cached resolution results.
|
||||
func (r *ResourceResolver) ClearCache() {
|
||||
r.cacheMu.Lock()
|
||||
defer r.cacheMu.Unlock()
|
||||
|
||||
r.cache = make(map[string]cacheEntry)
|
||||
}
|
||||
|
||||
// InvalidateCache invalidates cache entries for a specific resource.
|
||||
func (r *ResourceResolver) InvalidateCache(contextName, namespace, resource string) {
|
||||
r.cacheMu.Lock()
|
||||
defer r.cacheMu.Unlock()
|
||||
|
||||
// Remove exact match
|
||||
delete(r.cache, fmt.Sprintf("%s/%s/%s", contextName, namespace, resource))
|
||||
|
||||
// Remove prefix matches (for selector-based resources)
|
||||
prefix := fmt.Sprintf("%s/%s/", contextName, namespace)
|
||||
for key := range r.cache {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
delete(r.cache, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetPodList returns a list of pods matching the given criteria.
|
||||
// This is useful for debugging and testing.
|
||||
func (r *ResourceResolver) GetPodList(ctx context.Context, contextName, namespace, selector string) ([]*corev1.Pod, error) {
|
||||
client, err := r.clientPool.GetClient(contextName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get client: %w", err)
|
||||
}
|
||||
|
||||
listOptions := metav1.ListOptions{}
|
||||
if selector != "" {
|
||||
listOptions.LabelSelector = selector
|
||||
}
|
||||
|
||||
pods, err := client.CoreV1().Pods(namespace).List(ctx, listOptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list pods: %w", err)
|
||||
}
|
||||
|
||||
result := make([]*corev1.Pod, len(pods.Items))
|
||||
for i := range pods.Items {
|
||||
result[i] = &pods.Items[i]
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package retry
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// Backoff intervals: 1s → 2s → 4s → 8s → 10s (max)
|
||||
initialDelay = 1 * time.Second
|
||||
maxDelay = 10 * time.Second
|
||||
jitterPct = 0.1 // 10% jitter
|
||||
)
|
||||
|
||||
// Backoff implements exponential backoff with jitter for retry logic.
|
||||
// The backoff sequence is: 1s → 2s → 4s → 8s → 10s (max, then stays at 10s).
|
||||
type Backoff struct {
|
||||
attempt int
|
||||
rng *rand.Rand
|
||||
}
|
||||
|
||||
// NewBackoff creates a new Backoff instance with a seeded random number generator.
|
||||
func NewBackoff() *Backoff {
|
||||
return &Backoff{
|
||||
attempt: 0,
|
||||
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
}
|
||||
}
|
||||
|
||||
// Next returns the next backoff duration and increments the attempt counter.
|
||||
// The duration follows exponential backoff: 1s → 2s → 4s → 8s → 10s (max).
|
||||
// A 10% jitter is added to prevent thundering herd effects.
|
||||
func (b *Backoff) Next() time.Duration {
|
||||
// Calculate base delay: 2^attempt seconds
|
||||
exp := math.Pow(2, float64(b.attempt))
|
||||
delay := time.Duration(exp) * time.Second
|
||||
|
||||
// Cap at max delay
|
||||
if delay > maxDelay {
|
||||
delay = maxDelay
|
||||
}
|
||||
|
||||
// Add jitter (±10%)
|
||||
jitter := b.calculateJitter(delay)
|
||||
delay = delay + jitter
|
||||
|
||||
b.attempt++
|
||||
return delay
|
||||
}
|
||||
|
||||
// Reset resets the backoff to the initial state.
|
||||
func (b *Backoff) Reset() {
|
||||
b.attempt = 0
|
||||
}
|
||||
|
||||
// Attempt returns the current attempt number.
|
||||
func (b *Backoff) Attempt() int {
|
||||
return b.attempt
|
||||
}
|
||||
|
||||
// calculateJitter adds random jitter to prevent synchronized retries.
|
||||
// Returns a value between -jitterPct*delay and +jitterPct*delay.
|
||||
func (b *Backoff) calculateJitter(delay time.Duration) time.Duration {
|
||||
maxJitter := float64(delay) * jitterPct
|
||||
// Generate random value in range [-maxJitter, +maxJitter]
|
||||
jitter := (b.rng.Float64()*2 - 1) * maxJitter
|
||||
return time.Duration(jitter)
|
||||
}
|
||||
|
||||
// Sleep waits for the next backoff duration.
|
||||
func (b *Backoff) Sleep() {
|
||||
time.Sleep(b.Next())
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package retry
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBackoff_Next(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
attempt int
|
||||
minDelay time.Duration
|
||||
maxDelay time.Duration
|
||||
}{
|
||||
{
|
||||
name: "first attempt returns ~1s",
|
||||
attempt: 0,
|
||||
minDelay: 900 * time.Millisecond, // 1s - 10% jitter
|
||||
maxDelay: 1100 * time.Millisecond, // 1s + 10% jitter
|
||||
},
|
||||
{
|
||||
name: "second attempt returns ~2s",
|
||||
attempt: 1,
|
||||
minDelay: 1800 * time.Millisecond, // 2s - 10% jitter
|
||||
maxDelay: 2200 * time.Millisecond, // 2s + 10% jitter
|
||||
},
|
||||
{
|
||||
name: "third attempt returns ~4s",
|
||||
attempt: 2,
|
||||
minDelay: 3600 * time.Millisecond, // 4s - 10% jitter
|
||||
maxDelay: 4400 * time.Millisecond, // 4s + 10% jitter
|
||||
},
|
||||
{
|
||||
name: "fourth attempt returns ~8s",
|
||||
attempt: 3,
|
||||
minDelay: 7200 * time.Millisecond, // 8s - 10% jitter
|
||||
maxDelay: 8800 * time.Millisecond, // 8s + 10% jitter
|
||||
},
|
||||
{
|
||||
name: "fifth attempt returns ~10s (max)",
|
||||
attempt: 4,
|
||||
minDelay: 9000 * time.Millisecond, // 10s - 10% jitter
|
||||
maxDelay: 11000 * time.Millisecond, // 10s + 10% jitter
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
b := NewBackoff()
|
||||
|
||||
// Advance to the desired attempt
|
||||
for i := 0; i < tt.attempt; i++ {
|
||||
b.Next()
|
||||
}
|
||||
|
||||
delay := b.Next()
|
||||
|
||||
assert.GreaterOrEqual(t, delay, tt.minDelay, "delay should be >= min with jitter")
|
||||
assert.LessOrEqual(t, delay, tt.maxDelay, "delay should be <= max with jitter")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackoff_Next_StaysAtMax(t *testing.T) {
|
||||
b := NewBackoff()
|
||||
|
||||
// Go through several attempts past the max
|
||||
for i := 0; i < 10; i++ {
|
||||
delay := b.Next()
|
||||
|
||||
// After the 5th attempt (index 4), should always be at max (10s ± jitter)
|
||||
if i >= 4 {
|
||||
assert.GreaterOrEqual(t, delay, 9*time.Second, "should stay at max delay")
|
||||
assert.LessOrEqual(t, delay, 11*time.Second, "should stay at max delay with jitter")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackoff_Reset(t *testing.T) {
|
||||
b := NewBackoff()
|
||||
|
||||
// Advance through several attempts
|
||||
for i := 0; i < 5; i++ {
|
||||
b.Next()
|
||||
}
|
||||
|
||||
// Should be at attempt 5
|
||||
assert.Equal(t, 5, b.Attempt(), "should be at attempt 5")
|
||||
|
||||
// Reset
|
||||
b.Reset()
|
||||
|
||||
// Should be back to attempt 0
|
||||
assert.Equal(t, 0, b.Attempt(), "should be reset to attempt 0")
|
||||
|
||||
// Next call should return ~1s (first attempt)
|
||||
delay := b.Next()
|
||||
assert.GreaterOrEqual(t, delay, 900*time.Millisecond, "after reset should return ~1s")
|
||||
assert.LessOrEqual(t, delay, 1100*time.Millisecond, "after reset should return ~1s with jitter")
|
||||
}
|
||||
|
||||
func TestBackoff_Jitter_IsWithinExpectedRange(t *testing.T) {
|
||||
b := NewBackoff()
|
||||
|
||||
// Test multiple times to ensure jitter varies
|
||||
delays := make([]time.Duration, 20)
|
||||
for i := 0; i < 20; i++ {
|
||||
b.Reset()
|
||||
delays[i] = b.Next()
|
||||
}
|
||||
|
||||
// All delays should be within the jitter range for 1s
|
||||
for _, delay := range delays {
|
||||
assert.GreaterOrEqual(t, delay, 900*time.Millisecond, "jitter should not go below 10%")
|
||||
assert.LessOrEqual(t, delay, 1100*time.Millisecond, "jitter should not go above 10%")
|
||||
}
|
||||
|
||||
// Check that not all delays are identical (jitter is working)
|
||||
allSame := true
|
||||
first := delays[0]
|
||||
for _, d := range delays[1:] {
|
||||
if d != first {
|
||||
allSame = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
assert.False(t, allSame, "jitter should produce varying delays")
|
||||
}
|
||||
|
||||
func TestBackoff_Attempt(t *testing.T) {
|
||||
b := NewBackoff()
|
||||
|
||||
assert.Equal(t, 0, b.Attempt(), "initial attempt should be 0")
|
||||
|
||||
b.Next()
|
||||
assert.Equal(t, 1, b.Attempt(), "attempt should increment after Next()")
|
||||
|
||||
b.Next()
|
||||
assert.Equal(t, 2, b.Attempt(), "attempt should increment after Next()")
|
||||
|
||||
b.Reset()
|
||||
assert.Equal(t, 0, b.Attempt(), "attempt should reset to 0")
|
||||
}
|
||||
|
||||
func TestBackoff_ExponentialProgression(t *testing.T) {
|
||||
b := NewBackoff()
|
||||
|
||||
// Track the progression
|
||||
var delays []time.Duration
|
||||
for i := 0; i < 5; i++ {
|
||||
delays = append(delays, b.Next())
|
||||
}
|
||||
|
||||
// Verify exponential growth (each should be roughly 2x the previous)
|
||||
// We allow for jitter by checking a range
|
||||
for i := 1; i < len(delays)-1; i++ {
|
||||
// Each delay should be roughly double the previous (accounting for jitter)
|
||||
// With 10% jitter on each value, worst case: (2.0 * 1.1) / 0.9 = 2.44
|
||||
// We use 1.7x to 2.5x as a reasonable range with 10% jitter on each
|
||||
ratio := float64(delays[i]) / float64(delays[i-1])
|
||||
assert.GreaterOrEqual(t, ratio, 1.7, "exponential growth should be ~2x")
|
||||
assert.LessOrEqual(t, ratio, 2.5, "exponential growth should be ~2x")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user