mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-07-08 06:35:10 +00:00
healtcheck improvements (#4)
* Advanced healtchecks. * Add watchdog for stale connections handling.
This commit is contained in:
@@ -1,6 +1,27 @@
|
|||||||
# Example kportal configuration
|
# Example kportal configuration
|
||||||
# Copy this file to your project and customize as needed
|
# Copy this file to your project and customize as needed
|
||||||
|
|
||||||
|
# Optional: Health check configuration
|
||||||
|
# These settings control how kportal monitors connection health and detects stale connections
|
||||||
|
healthCheck:
|
||||||
|
interval: "3s" # How often to check connection health (default: 3s)
|
||||||
|
timeout: "2s" # Timeout for health check operations (default: 2s)
|
||||||
|
method: "data-transfer" # Health check method: "tcp-dial" or "data-transfer" (default: data-transfer)
|
||||||
|
# - tcp-dial: Simple TCP connection test (fast, less reliable)
|
||||||
|
# - data-transfer: Attempts to read data (slower, more reliable)
|
||||||
|
maxConnectionAge: "25m" # Maximum connection age before proactive reconnect (default: 25m)
|
||||||
|
# Helps avoid Kubernetes API server timeouts (typically 30m)
|
||||||
|
maxIdleTime: "10m" # Maximum idle time before marking as stale (default: 10m)
|
||||||
|
# Connections with no data transfer are marked stale
|
||||||
|
|
||||||
|
# Optional: Reliability configuration
|
||||||
|
# These settings improve connection stability for long-running transfers
|
||||||
|
reliability:
|
||||||
|
tcpKeepalive: "30s" # TCP keepalive interval for OS-level connection monitoring (default: 30s)
|
||||||
|
dialTimeout: "30s" # Connection dial timeout (default: 30s)
|
||||||
|
retryOnStale: true # Automatically reconnect when stale connections detected (default: true)
|
||||||
|
watchdogPeriod: "30s" # Goroutine watchdog check interval to detect hung workers (default: 30s)
|
||||||
|
|
||||||
contexts:
|
contexts:
|
||||||
# Production context
|
# Production context
|
||||||
- name: production
|
- name: production
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ kportal simplifies managing multiple Kubernetes port-forwards with an elegant, i
|
|||||||
- 🗑️ **Live Delete** - Remove port-forwards instantly from the running session
|
- 🗑️ **Live Delete** - Remove port-forwards instantly from the running session
|
||||||
- 🔄 **Auto-Reconnect** - Automatic retry with exponential backoff on connection failures (max 10s)
|
- 🔄 **Auto-Reconnect** - Automatic retry with exponential backoff on connection failures (max 10s)
|
||||||
- ⚡ **Hot-Reload** - Update configuration without restarting - changes applied automatically
|
- ⚡ **Hot-Reload** - Update configuration without restarting - changes applied automatically
|
||||||
- 🏥 **Health Checks** - Real-time port forward status monitoring with 5-second intervals
|
- 🏥 **Advanced Health Checks** - Multiple check methods (tcp-dial, data-transfer) with stale connection detection
|
||||||
|
- 🛡️ **Goroutine Watchdog** - Detects and recovers from completely hung workers
|
||||||
- 🎨 **Multi-Context** - Support for multiple Kubernetes contexts and namespaces
|
- 🎨 **Multi-Context** - Support for multiple Kubernetes contexts and namespaces
|
||||||
- 📦 **Batch Management** - Manage all port-forwards from a single configuration file
|
- 📦 **Batch Management** - Manage all port-forwards from a single configuration file
|
||||||
- 🔌 **Toggle Forwards** - Enable/disable individual port-forwards on the fly with Space key
|
- 🔌 **Toggle Forwards** - Enable/disable individual port-forwards on the fly with Space key
|
||||||
@@ -194,6 +195,47 @@ contexts:
|
|||||||
- **Service**: `service/service-name` or `svc/service-name`
|
- **Service**: `service/service-name` or `svc/service-name`
|
||||||
- **Deployment**: `deployment/deployment-name` or `deploy/deployment-name`
|
- **Deployment**: `deployment/deployment-name` or `deploy/deployment-name`
|
||||||
|
|
||||||
|
### Health Check & Reliability (Advanced)
|
||||||
|
|
||||||
|
kportal includes advanced health checking to prevent stale connections during long-running operations like database dumps:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
healthCheck:
|
||||||
|
interval: "3s" # Health check frequency (default: 3s)
|
||||||
|
timeout: "2s" # Health check timeout (default: 2s)
|
||||||
|
method: "data-transfer" # Check method: "tcp-dial" or "data-transfer" (default: data-transfer)
|
||||||
|
maxConnectionAge: "25m" # Proactive reconnect before k8s timeout (default: 25m)
|
||||||
|
maxIdleTime: "10m" # Detect hung connections (default: 10m)
|
||||||
|
|
||||||
|
reliability:
|
||||||
|
tcpKeepalive: "30s" # TCP keepalive interval (default: 30s)
|
||||||
|
dialTimeout: "30s" # Connection dial timeout (default: 30s)
|
||||||
|
retryOnStale: true # Auto-reconnect stale connections (default: true)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Health Check Methods:**
|
||||||
|
- **`tcp-dial`**: Fast TCP connection test - verifies local port is listening
|
||||||
|
- **`data-transfer`**: More reliable - attempts to read data to verify tunnel is functional
|
||||||
|
|
||||||
|
**Stale Detection:**
|
||||||
|
- **Max Connection Age**: Kubernetes API typically has 30-minute timeout. kportal reconnects at 25 minutes by default to avoid hitting this limit. **Important**: Age-based reconnection only occurs when the connection is ALSO idle - active transfers (like database dumps) are never interrupted.
|
||||||
|
- **Max Idle Time**: Detects connections with no data transfer, common when intermediate firewalls drop idle TCP connections
|
||||||
|
|
||||||
|
**Use Case Example - Database Dumps:**
|
||||||
|
```yaml
|
||||||
|
# Optimized for long-running pg_dump
|
||||||
|
healthCheck:
|
||||||
|
method: "data-transfer"
|
||||||
|
maxConnectionAge: "20m" # Only applies when idle - won't interrupt active dumps
|
||||||
|
maxIdleTime: "5m" # Detects truly stale connections
|
||||||
|
|
||||||
|
reliability:
|
||||||
|
tcpKeepalive: "30s"
|
||||||
|
retryOnStale: true
|
||||||
|
```
|
||||||
|
|
||||||
|
This configuration ensures multi-hour database dumps complete without interruption. The `maxConnectionAge` will only trigger reconnection if the connection has been idle for more than `maxIdleTime`, preventing interruption of active data transfers.
|
||||||
|
|
||||||
## 🎮 Usage
|
## 🎮 Usage
|
||||||
|
|
||||||
### Interactive Mode (Default)
|
### Interactive Mode (Default)
|
||||||
|
|||||||
+107
-1
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
@@ -13,7 +14,112 @@ const (
|
|||||||
|
|
||||||
// Config represents the root configuration structure from .kportal.yaml
|
// Config represents the root configuration structure from .kportal.yaml
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Contexts []Context `yaml:"contexts"`
|
Contexts []Context `yaml:"contexts"`
|
||||||
|
HealthCheck *HealthCheckSpec `yaml:"healthCheck,omitempty"`
|
||||||
|
Reliability *ReliabilitySpec `yaml:"reliability,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheckSpec configures health check behavior
|
||||||
|
type HealthCheckSpec struct {
|
||||||
|
Interval string `yaml:"interval,omitempty"` // e.g., "3s", "5s"
|
||||||
|
Timeout string `yaml:"timeout,omitempty"` // e.g., "2s"
|
||||||
|
Method string `yaml:"method,omitempty"` // "tcp-dial" | "data-transfer"
|
||||||
|
MaxConnectionAge string `yaml:"maxConnectionAge,omitempty"` // e.g., "25m" - reconnect before k8s timeout
|
||||||
|
MaxIdleTime string `yaml:"maxIdleTime,omitempty"` // e.g., "10m" - reconnect if no activity
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReliabilitySpec configures connection reliability features
|
||||||
|
type ReliabilitySpec struct {
|
||||||
|
TCPKeepalive string `yaml:"tcpKeepalive,omitempty"` // e.g., "30s" - OS-level keepalive
|
||||||
|
DialTimeout string `yaml:"dialTimeout,omitempty"` // e.g., "30s" - connection dial timeout
|
||||||
|
RetryOnStale bool `yaml:"retryOnStale,omitempty"` // Auto-reconnect on stale detection
|
||||||
|
WatchdogPeriod string `yaml:"watchdogPeriod,omitempty"` // e.g., "30s" - goroutine watchdog interval
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHealthCheckIntervalOrDefault returns the health check interval or default value
|
||||||
|
func (c *Config) GetHealthCheckIntervalOrDefault() time.Duration {
|
||||||
|
if c.HealthCheck != nil && c.HealthCheck.Interval != "" {
|
||||||
|
if d, err := time.ParseDuration(c.HealthCheck.Interval); err == nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 3 * time.Second // Default: check every 3 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHealthCheckTimeoutOrDefault returns the health check timeout or default value
|
||||||
|
func (c *Config) GetHealthCheckTimeoutOrDefault() time.Duration {
|
||||||
|
if c.HealthCheck != nil && c.HealthCheck.Timeout != "" {
|
||||||
|
if d, err := time.ParseDuration(c.HealthCheck.Timeout); err == nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 2 * time.Second // Default: 2 second timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHealthCheckMethod returns the health check method or default
|
||||||
|
func (c *Config) GetHealthCheckMethod() string {
|
||||||
|
if c.HealthCheck != nil && c.HealthCheck.Method != "" {
|
||||||
|
return c.HealthCheck.Method
|
||||||
|
}
|
||||||
|
return "data-transfer" // Default: more reliable data transfer test
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMaxConnectionAge returns the max connection age or default
|
||||||
|
func (c *Config) GetMaxConnectionAge() time.Duration {
|
||||||
|
if c.HealthCheck != nil && c.HealthCheck.MaxConnectionAge != "" {
|
||||||
|
if d, err := time.ParseDuration(c.HealthCheck.MaxConnectionAge); err == nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 25 * time.Minute // Default: 25 minutes (before typical 30min k8s timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMaxIdleTime returns the max idle time or default
|
||||||
|
func (c *Config) GetMaxIdleTime() time.Duration {
|
||||||
|
if c.HealthCheck != nil && c.HealthCheck.MaxIdleTime != "" {
|
||||||
|
if d, err := time.ParseDuration(c.HealthCheck.MaxIdleTime); err == nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 10 * time.Minute // Default: 10 minutes idle before reconnect
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTCPKeepalive returns the TCP keepalive duration or default
|
||||||
|
func (c *Config) GetTCPKeepalive() time.Duration {
|
||||||
|
if c.Reliability != nil && c.Reliability.TCPKeepalive != "" {
|
||||||
|
if d, err := time.ParseDuration(c.Reliability.TCPKeepalive); err == nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 30 * time.Second // Default: 30 second keepalive
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRetryOnStale returns whether to retry on stale connections
|
||||||
|
func (c *Config) GetRetryOnStale() bool {
|
||||||
|
if c.Reliability != nil {
|
||||||
|
return c.Reliability.RetryOnStale
|
||||||
|
}
|
||||||
|
return true // Default: enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWatchdogPeriod returns the goroutine watchdog check period or default
|
||||||
|
func (c *Config) GetWatchdogPeriod() time.Duration {
|
||||||
|
if c.Reliability != nil && c.Reliability.WatchdogPeriod != "" {
|
||||||
|
if d, err := time.ParseDuration(c.Reliability.WatchdogPeriod); err == nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 30 * time.Second // Default: check every 30 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDialTimeout returns the connection dial timeout or default
|
||||||
|
func (c *Config) GetDialTimeout() time.Duration {
|
||||||
|
if c.Reliability != nil && c.Reliability.DialTimeout != "" {
|
||||||
|
if d, err := time.ParseDuration(c.Reliability.DialTimeout); err == nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 30 * time.Second // Default: 30 second dial timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
// Context represents a Kubernetes context with its namespaces
|
// Context represents a Kubernetes context with its namespaces
|
||||||
|
|||||||
+109
-11
@@ -12,11 +12,6 @@ import (
|
|||||||
"github.com/nvm/kportal/internal/logger"
|
"github.com/nvm/kportal/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
healthCheckInterval = 5 * time.Second
|
|
||||||
healthCheckTimeout = 2 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
// StatusUpdater is an interface for updating forward status
|
// StatusUpdater is an interface for updating forward status
|
||||||
type StatusUpdater interface {
|
type StatusUpdater interface {
|
||||||
UpdateStatus(id string, status string)
|
UpdateStatus(id string, status string)
|
||||||
@@ -34,12 +29,15 @@ type Manager struct {
|
|||||||
portForwarder *k8s.PortForwarder
|
portForwarder *k8s.PortForwarder
|
||||||
portChecker *PortChecker
|
portChecker *PortChecker
|
||||||
healthChecker *healthcheck.Checker
|
healthChecker *healthcheck.Checker
|
||||||
|
watchdog *Watchdog
|
||||||
verbose bool
|
verbose bool
|
||||||
currentConfig *config.Config
|
currentConfig *config.Config
|
||||||
statusUI StatusUpdater
|
statusUI StatusUpdater
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a new forward Manager.
|
// NewManager creates a new forward Manager.
|
||||||
|
// The health checker will be created with default settings and can be
|
||||||
|
// reconfigured via SetConfig().
|
||||||
func NewManager(verbose bool) (*Manager, error) {
|
func NewManager(verbose bool) (*Manager, error) {
|
||||||
clientPool, err := k8s.NewClientPool()
|
clientPool, err := k8s.NewClientPool()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -49,8 +47,13 @@ func NewManager(verbose bool) (*Manager, error) {
|
|||||||
resolver := k8s.NewResourceResolver(clientPool)
|
resolver := k8s.NewResourceResolver(clientPool)
|
||||||
portForwarder := k8s.NewPortForwarder(clientPool, resolver)
|
portForwarder := k8s.NewPortForwarder(clientPool, resolver)
|
||||||
|
|
||||||
// Create health checker: check every 5 seconds with 2 second timeout
|
// Create health checker with defaults: check every 3 seconds with 2 second timeout
|
||||||
healthChecker := healthcheck.NewChecker(healthCheckInterval, healthCheckTimeout)
|
// Will be reconfigured when config is loaded
|
||||||
|
healthChecker := healthcheck.NewChecker(3*time.Second, 2*time.Second)
|
||||||
|
|
||||||
|
// Create watchdog with default settings: check every 30 seconds, 60 second hang threshold
|
||||||
|
// Will be reconfigured when config is loaded
|
||||||
|
watchdog := NewWatchdog(30*time.Second, 60*time.Second)
|
||||||
|
|
||||||
return &Manager{
|
return &Manager{
|
||||||
workers: make(map[string]*ForwardWorker),
|
workers: make(map[string]*ForwardWorker),
|
||||||
@@ -59,10 +62,56 @@ func NewManager(verbose bool) (*Manager, error) {
|
|||||||
portForwarder: portForwarder,
|
portForwarder: portForwarder,
|
||||||
portChecker: NewPortChecker(),
|
portChecker: NewPortChecker(),
|
||||||
healthChecker: healthChecker,
|
healthChecker: healthChecker,
|
||||||
|
watchdog: watchdog,
|
||||||
verbose: verbose,
|
verbose: verbose,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// configureHealthChecker creates a new health checker with settings from config
|
||||||
|
func (m *Manager) configureHealthChecker(cfg *config.Config) {
|
||||||
|
// Stop existing health checker
|
||||||
|
if m.healthChecker != nil {
|
||||||
|
m.healthChecker.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse check method
|
||||||
|
methodStr := cfg.GetHealthCheckMethod()
|
||||||
|
var method healthcheck.CheckMethod
|
||||||
|
switch methodStr {
|
||||||
|
case "tcp-dial":
|
||||||
|
method = healthcheck.CheckMethodTCPDial
|
||||||
|
case "data-transfer":
|
||||||
|
method = healthcheck.CheckMethodDataTransfer
|
||||||
|
default:
|
||||||
|
method = healthcheck.CheckMethodDataTransfer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new health checker with config settings
|
||||||
|
m.healthChecker = healthcheck.NewCheckerWithOptions(healthcheck.CheckerOptions{
|
||||||
|
Interval: cfg.GetHealthCheckIntervalOrDefault(),
|
||||||
|
Timeout: cfg.GetHealthCheckTimeoutOrDefault(),
|
||||||
|
Method: method,
|
||||||
|
MaxConnectionAge: cfg.GetMaxConnectionAge(),
|
||||||
|
MaxIdleTime: cfg.GetMaxIdleTime(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Configure TCP settings on port forwarder
|
||||||
|
tcpKeepalive := cfg.GetTCPKeepalive()
|
||||||
|
dialTimeout := cfg.GetDialTimeout()
|
||||||
|
m.portForwarder.SetTCPKeepalive(tcpKeepalive)
|
||||||
|
m.portForwarder.SetDialTimeout(dialTimeout)
|
||||||
|
|
||||||
|
logger.Info("Health checker and reliability configured", map[string]interface{}{
|
||||||
|
"interval": cfg.GetHealthCheckIntervalOrDefault().String(),
|
||||||
|
"timeout": cfg.GetHealthCheckTimeoutOrDefault().String(),
|
||||||
|
"method": methodStr,
|
||||||
|
"max_connection_age": cfg.GetMaxConnectionAge().String(),
|
||||||
|
"max_idle_time": cfg.GetMaxIdleTime().String(),
|
||||||
|
"tcp_keepalive": tcpKeepalive.String(),
|
||||||
|
"dial_timeout": dialTimeout.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// SetStatusUI sets the status updater for the manager
|
// SetStatusUI sets the status updater for the manager
|
||||||
func (m *Manager) SetStatusUI(ui StatusUpdater) {
|
func (m *Manager) SetStatusUI(ui StatusUpdater) {
|
||||||
m.statusUI = ui
|
m.statusUI = ui
|
||||||
@@ -76,6 +125,20 @@ func (m *Manager) Start(cfg *config.Config) error {
|
|||||||
|
|
||||||
m.currentConfig = cfg
|
m.currentConfig = cfg
|
||||||
|
|
||||||
|
// Configure health checker with settings from config
|
||||||
|
m.configureHealthChecker(cfg)
|
||||||
|
|
||||||
|
// Start watchdog
|
||||||
|
watchdogPeriod := cfg.GetWatchdogPeriod()
|
||||||
|
m.watchdog.checkInterval = watchdogPeriod
|
||||||
|
m.watchdog.hangThreshold = watchdogPeriod * 2 // Hang threshold is 2x check interval
|
||||||
|
m.watchdog.Start()
|
||||||
|
|
||||||
|
logger.Info("Watchdog started", map[string]interface{}{
|
||||||
|
"check_interval": watchdogPeriod.String(),
|
||||||
|
"hang_threshold": (watchdogPeriod * 2).String(),
|
||||||
|
})
|
||||||
|
|
||||||
// Get all forwards from config
|
// Get all forwards from config
|
||||||
forwards := cfg.GetAllForwards()
|
forwards := cfg.GetAllForwards()
|
||||||
|
|
||||||
@@ -119,8 +182,9 @@ func (m *Manager) Start(cfg *config.Config) error {
|
|||||||
func (m *Manager) Stop() {
|
func (m *Manager) Stop() {
|
||||||
log.Printf("Stopping all port-forwards...")
|
log.Printf("Stopping all port-forwards...")
|
||||||
|
|
||||||
// Stop health checker first
|
// Stop health checker and watchdog first
|
||||||
m.healthChecker.Stop()
|
m.healthChecker.Stop()
|
||||||
|
m.watchdog.Stop()
|
||||||
|
|
||||||
m.workersMu.Lock()
|
m.workersMu.Lock()
|
||||||
workers := make([]*ForwardWorker, 0, len(m.workers))
|
workers := make([]*ForwardWorker, 0, len(m.workers))
|
||||||
@@ -273,21 +337,54 @@ func (m *Manager) startWorker(fwd config.Forward) error {
|
|||||||
m.statusUI.AddForward(fwd.ID(), &fwd)
|
m.statusUI.AddForward(fwd.ID(), &fwd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register with watchdog
|
||||||
|
m.watchdog.RegisterWorker(fwd.ID(), func(forwardID string) {
|
||||||
|
logger.Warn("Watchdog triggered reconnection for hung worker", map[string]interface{}{
|
||||||
|
"forward_id": forwardID,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Find and trigger reconnect on hung worker
|
||||||
|
m.workersMu.RLock()
|
||||||
|
worker, exists := m.workers[forwardID]
|
||||||
|
m.workersMu.RUnlock()
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
worker.TriggerReconnect("watchdog detected hung worker")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Register with health checker
|
// Register with health checker
|
||||||
m.healthChecker.Register(fwd.ID(), fwd.LocalPort, func(forwardID string, status healthcheck.Status, errorMsg string) {
|
m.healthChecker.Register(fwd.ID(), fwd.LocalPort, func(forwardID string, status healthcheck.Status, errorMsg string) {
|
||||||
if m.statusUI != nil {
|
if m.statusUI != nil {
|
||||||
m.statusUI.UpdateStatus(forwardID, string(status))
|
m.statusUI.UpdateStatus(forwardID, string(status))
|
||||||
// Send error separately if there is one
|
// Send error separately if there is one
|
||||||
if status == healthcheck.StatusUnhealthy && errorMsg != "" {
|
if (status == healthcheck.StatusUnhealthy || status == healthcheck.StatusStale) && errorMsg != "" {
|
||||||
if ui, ok := m.statusUI.(interface{ SetError(id, msg string) }); ok {
|
if ui, ok := m.statusUI.(interface{ SetError(id, msg string) }); ok {
|
||||||
ui.SetError(forwardID, errorMsg)
|
ui.SetError(forwardID, errorMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle stale connections: trigger reconnection if retryOnStale is enabled
|
||||||
|
if status == healthcheck.StatusStale && m.currentConfig.GetRetryOnStale() {
|
||||||
|
logger.Info("Stale connection detected, triggering reconnection", map[string]interface{}{
|
||||||
|
"forward_id": forwardID,
|
||||||
|
"reason": errorMsg,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Find and notify the worker to reconnect
|
||||||
|
m.workersMu.RLock()
|
||||||
|
worker, exists := m.workers[forwardID]
|
||||||
|
m.workersMu.RUnlock()
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
worker.TriggerReconnect("stale connection")
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create and start worker
|
// Create and start worker
|
||||||
worker := NewForwardWorker(fwd, m.portForwarder, m.verbose, m.statusUI, m.healthChecker)
|
worker := NewForwardWorker(fwd, m.portForwarder, m.verbose, m.statusUI, m.healthChecker, m.watchdog)
|
||||||
worker.Start()
|
worker.Start()
|
||||||
|
|
||||||
// Store worker
|
// Store worker
|
||||||
@@ -312,8 +409,9 @@ func (m *Manager) stopWorkerInternal(id string, removeFromUI bool) error {
|
|||||||
delete(m.workers, id)
|
delete(m.workers, id)
|
||||||
m.workersMu.Unlock()
|
m.workersMu.Unlock()
|
||||||
|
|
||||||
// Unregister from health checker
|
// Unregister from health checker and watchdog
|
||||||
m.healthChecker.Unregister(id)
|
m.healthChecker.Unregister(id)
|
||||||
|
m.watchdog.UnregisterWorker(id)
|
||||||
|
|
||||||
// Notify UI - either remove or update to disabled status
|
// Notify UI - either remove or update to disabled status
|
||||||
if m.statusUI != nil {
|
if m.statusUI != nil {
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package forward
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nvm/kportal/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Watchdog monitors worker goroutines to detect hung workers
|
||||||
|
type Watchdog struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
workers map[string]*workerState // key: forward ID
|
||||||
|
checkInterval time.Duration
|
||||||
|
hangThreshold time.Duration // How long without heartbeat before considered hung
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// workerState tracks the health of a single worker
|
||||||
|
type workerState struct {
|
||||||
|
forwardID string
|
||||||
|
lastHeartbeat time.Time
|
||||||
|
heartbeatCount uint64
|
||||||
|
isHung bool
|
||||||
|
onHungCallback func(forwardID string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWatchdog creates a new goroutine watchdog
|
||||||
|
func NewWatchdog(checkInterval, hangThreshold time.Duration) *Watchdog {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
return &Watchdog{
|
||||||
|
workers: make(map[string]*workerState),
|
||||||
|
checkInterval: checkInterval,
|
||||||
|
hangThreshold: hangThreshold,
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins the watchdog monitoring loop
|
||||||
|
func (w *Watchdog) Start() {
|
||||||
|
w.wg.Add(1)
|
||||||
|
go w.monitorLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the watchdog
|
||||||
|
func (w *Watchdog) Stop() {
|
||||||
|
w.cancel()
|
||||||
|
w.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterWorker adds a worker to monitor
|
||||||
|
func (w *Watchdog) RegisterWorker(forwardID string, onHungCallback func(string)) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
w.workers[forwardID] = &workerState{
|
||||||
|
forwardID: forwardID,
|
||||||
|
lastHeartbeat: time.Now(),
|
||||||
|
heartbeatCount: 0,
|
||||||
|
isHung: false,
|
||||||
|
onHungCallback: onHungCallback,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug("Watchdog registered worker", map[string]interface{}{
|
||||||
|
"forward_id": forwardID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnregisterWorker removes a worker from monitoring
|
||||||
|
func (w *Watchdog) UnregisterWorker(forwardID string) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
delete(w.workers, forwardID)
|
||||||
|
|
||||||
|
logger.Debug("Watchdog unregistered worker", map[string]interface{}{
|
||||||
|
"forward_id": forwardID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat records that a worker is alive and processing
|
||||||
|
// Workers should call this periodically (e.g., in their main loop)
|
||||||
|
func (w *Watchdog) Heartbeat(forwardID string) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
if state, exists := w.workers[forwardID]; exists {
|
||||||
|
state.lastHeartbeat = time.Now()
|
||||||
|
state.heartbeatCount++
|
||||||
|
state.isHung = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWorkerState returns the current state of a worker (for testing)
|
||||||
|
func (w *Watchdog) GetWorkerState(forwardID string) (lastHeartbeat time.Time, count uint64, exists bool) {
|
||||||
|
w.mu.RLock()
|
||||||
|
defer w.mu.RUnlock()
|
||||||
|
|
||||||
|
if state, ok := w.workers[forwardID]; ok {
|
||||||
|
return state.lastHeartbeat, state.heartbeatCount, true
|
||||||
|
}
|
||||||
|
return time.Time{}, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// monitorLoop periodically checks all workers
|
||||||
|
func (w *Watchdog) monitorLoop() {
|
||||||
|
defer w.wg.Done()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(w.checkInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-w.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
w.checkWorkers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkWorkers checks all registered workers for hung state
|
||||||
|
func (w *Watchdog) checkWorkers() {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for forwardID, state := range w.workers {
|
||||||
|
timeSinceHeartbeat := now.Sub(state.lastHeartbeat)
|
||||||
|
|
||||||
|
// Check if worker is hung
|
||||||
|
if timeSinceHeartbeat > w.hangThreshold {
|
||||||
|
if !state.isHung {
|
||||||
|
// First time detecting hung state
|
||||||
|
state.isHung = true
|
||||||
|
|
||||||
|
logger.Warn("Watchdog detected hung worker", map[string]interface{}{
|
||||||
|
"forward_id": forwardID,
|
||||||
|
"time_since_heartbeat": timeSinceHeartbeat.String(),
|
||||||
|
"hang_threshold": w.hangThreshold.String(),
|
||||||
|
"heartbeat_count": state.heartbeatCount,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Trigger callback to handle hung worker (without holding lock)
|
||||||
|
if state.onHungCallback != nil {
|
||||||
|
callback := state.onHungCallback
|
||||||
|
w.mu.Unlock()
|
||||||
|
callback(forwardID)
|
||||||
|
w.mu.Lock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
package forward
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WatchdogTestSuite contains tests for the watchdog
|
||||||
|
type WatchdogTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
watchdog *Watchdog
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWatchdogSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(WatchdogTestSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WatchdogTestSuite) SetupTest() {
|
||||||
|
// Create watchdog with fast intervals for testing
|
||||||
|
s.watchdog = NewWatchdog(100*time.Millisecond, 300*time.Millisecond)
|
||||||
|
s.watchdog.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WatchdogTestSuite) TearDownTest() {
|
||||||
|
if s.watchdog != nil {
|
||||||
|
s.watchdog.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegisterUnregister tests basic registration and unregistration
|
||||||
|
func (s *WatchdogTestSuite) TestRegisterUnregister() {
|
||||||
|
callbackCalled := false
|
||||||
|
callback := func(forwardID string) {
|
||||||
|
callbackCalled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register worker
|
||||||
|
s.watchdog.RegisterWorker("test-forward", callback)
|
||||||
|
|
||||||
|
// Verify worker is registered
|
||||||
|
_, _, exists := s.watchdog.GetWorkerState("test-forward")
|
||||||
|
assert.True(s.T(), exists, "Worker should be registered")
|
||||||
|
|
||||||
|
// Unregister worker
|
||||||
|
s.watchdog.UnregisterWorker("test-forward")
|
||||||
|
|
||||||
|
// Verify worker is unregistered
|
||||||
|
_, _, exists = s.watchdog.GetWorkerState("test-forward")
|
||||||
|
assert.False(s.T(), exists, "Worker should be unregistered")
|
||||||
|
assert.False(s.T(), callbackCalled, "Callback should not have been called")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHeartbeat tests that heartbeats update worker state
|
||||||
|
func (s *WatchdogTestSuite) TestHeartbeat() {
|
||||||
|
s.watchdog.RegisterWorker("test-forward", nil)
|
||||||
|
|
||||||
|
// Send initial heartbeat
|
||||||
|
s.watchdog.Heartbeat("test-forward")
|
||||||
|
|
||||||
|
lastHeartbeat1, count1, exists := s.watchdog.GetWorkerState("test-forward")
|
||||||
|
require.True(s.T(), exists)
|
||||||
|
assert.Equal(s.T(), uint64(1), count1)
|
||||||
|
|
||||||
|
// Wait a bit
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// Send another heartbeat
|
||||||
|
s.watchdog.Heartbeat("test-forward")
|
||||||
|
|
||||||
|
lastHeartbeat2, count2, exists := s.watchdog.GetWorkerState("test-forward")
|
||||||
|
require.True(s.T(), exists)
|
||||||
|
assert.Equal(s.T(), uint64(2), count2)
|
||||||
|
assert.True(s.T(), lastHeartbeat2.After(lastHeartbeat1), "Second heartbeat should be after first")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHungWorkerDetection tests that hung workers are detected
|
||||||
|
func (s *WatchdogTestSuite) TestHungWorkerDetection() {
|
||||||
|
callbackCalled := make(chan string, 1)
|
||||||
|
callback := func(forwardID string) {
|
||||||
|
callbackCalled <- forwardID
|
||||||
|
}
|
||||||
|
|
||||||
|
s.watchdog.RegisterWorker("test-forward", callback)
|
||||||
|
|
||||||
|
// Send initial heartbeat
|
||||||
|
s.watchdog.Heartbeat("test-forward")
|
||||||
|
|
||||||
|
// Wait for worker to be considered hung (300ms threshold + 100ms check interval)
|
||||||
|
timeout := time.After(1 * time.Second)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case forwardID := <-callbackCalled:
|
||||||
|
assert.Equal(s.T(), "test-forward", forwardID)
|
||||||
|
case <-timeout:
|
||||||
|
s.T().Fatal("Timeout waiting for hung worker callback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHealthyWorkerNotDetectedAsHung tests that workers sending heartbeats are not considered hung
|
||||||
|
func (s *WatchdogTestSuite) TestHealthyWorkerNotDetectedAsHung() {
|
||||||
|
callbackCalled := false
|
||||||
|
var mu sync.Mutex
|
||||||
|
callback := func(forwardID string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
callbackCalled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
s.watchdog.RegisterWorker("test-forward", callback)
|
||||||
|
|
||||||
|
// Send periodic heartbeats (faster than hang threshold)
|
||||||
|
ticker := time.NewTicker(50 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
<-ticker.C
|
||||||
|
s.watchdog.Heartbeat("test-forward")
|
||||||
|
}
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for all heartbeats to complete
|
||||||
|
<-done
|
||||||
|
|
||||||
|
// Check that callback was not called
|
||||||
|
mu.Lock()
|
||||||
|
assert.False(s.T(), callbackCalled, "Callback should not be called for healthy worker")
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMultipleWorkers tests monitoring multiple workers simultaneously
|
||||||
|
func (s *WatchdogTestSuite) TestMultipleWorkers() {
|
||||||
|
callbacks := make(map[string]int)
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
makeCallback := func(id string) func(string) {
|
||||||
|
return func(forwardID string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
callbacks[id]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register multiple workers
|
||||||
|
s.watchdog.RegisterWorker("worker-1", makeCallback("worker-1"))
|
||||||
|
s.watchdog.RegisterWorker("worker-2", makeCallback("worker-2"))
|
||||||
|
s.watchdog.RegisterWorker("worker-3", makeCallback("worker-3"))
|
||||||
|
|
||||||
|
// worker-1: Keep sending heartbeats (healthy)
|
||||||
|
ticker1 := time.NewTicker(50 * time.Millisecond)
|
||||||
|
defer ticker1.Stop()
|
||||||
|
go func() {
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
<-ticker1.C
|
||||||
|
s.watchdog.Heartbeat("worker-1")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// worker-2: Send initial heartbeat then stop (will become hung)
|
||||||
|
s.watchdog.Heartbeat("worker-2")
|
||||||
|
|
||||||
|
// worker-3: Send initial heartbeat then stop (will become hung)
|
||||||
|
s.watchdog.Heartbeat("worker-3")
|
||||||
|
|
||||||
|
// Wait for hung workers to be detected
|
||||||
|
time.Sleep(600 * time.Millisecond)
|
||||||
|
|
||||||
|
// Check results
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
assert.Equal(s.T(), 0, callbacks["worker-1"], "worker-1 should not trigger callback (healthy)")
|
||||||
|
assert.Greater(s.T(), callbacks["worker-2"], 0, "worker-2 should trigger callback (hung)")
|
||||||
|
assert.Greater(s.T(), callbacks["worker-3"], 0, "worker-3 should trigger callback (hung)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCallbackOnlyOnFirstDetection tests that callback is only called once when hung is first detected
|
||||||
|
func (s *WatchdogTestSuite) TestCallbackOnlyOnFirstDetection() {
|
||||||
|
callbackCount := 0
|
||||||
|
var mu sync.Mutex
|
||||||
|
callback := func(forwardID string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
callbackCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
s.watchdog.RegisterWorker("test-forward", callback)
|
||||||
|
|
||||||
|
// Send initial heartbeat
|
||||||
|
s.watchdog.Heartbeat("test-forward")
|
||||||
|
|
||||||
|
// Wait for multiple check cycles
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
// Check that callback was only called once
|
||||||
|
mu.Lock()
|
||||||
|
assert.Equal(s.T(), 1, callbackCount, "Callback should only be called once")
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHeartbeatResetsHungState tests that sending heartbeat after hung detection resets state
|
||||||
|
func (s *WatchdogTestSuite) TestHeartbeatResetsHungState() {
|
||||||
|
callbackCount := 0
|
||||||
|
var mu sync.Mutex
|
||||||
|
callback := func(forwardID string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
callbackCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
s.watchdog.RegisterWorker("test-forward", callback)
|
||||||
|
|
||||||
|
// Send initial heartbeat
|
||||||
|
s.watchdog.Heartbeat("test-forward")
|
||||||
|
|
||||||
|
// Wait for hung detection
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
firstCount := callbackCount
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
assert.Equal(s.T(), 1, firstCount, "First hung detection should trigger callback")
|
||||||
|
|
||||||
|
// Send heartbeat to reset hung state
|
||||||
|
s.watchdog.Heartbeat("test-forward")
|
||||||
|
|
||||||
|
// Wait for worker to become hung again
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
secondCount := callbackCount
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
assert.Equal(s.T(), 2, secondCount, "Second hung detection should trigger callback again")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrentOperations tests thread safety
|
||||||
|
func (s *WatchdogTestSuite) TestConcurrentOperations() {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
numWorkers := 10
|
||||||
|
|
||||||
|
for i := 0; i < numWorkers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
forwardID := string(rune('a' + id))
|
||||||
|
s.watchdog.RegisterWorker(forwardID, nil)
|
||||||
|
for j := 0; j < 10; j++ {
|
||||||
|
s.watchdog.Heartbeat(forwardID)
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
s.watchdog.UnregisterWorker(forwardID)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
// If we get here without deadlocks or panics, test passes
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStopWatchdog tests that stopping watchdog cleans up properly
|
||||||
|
func TestStopWatchdog(t *testing.T) {
|
||||||
|
watchdog := NewWatchdog(100*time.Millisecond, 300*time.Millisecond)
|
||||||
|
watchdog.Start()
|
||||||
|
|
||||||
|
callbackCalled := false
|
||||||
|
callback := func(forwardID string) {
|
||||||
|
callbackCalled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
watchdog.RegisterWorker("test-forward", callback)
|
||||||
|
watchdog.Heartbeat("test-forward")
|
||||||
|
|
||||||
|
// Stop watchdog before hang detection
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
watchdog.Stop()
|
||||||
|
|
||||||
|
// Wait to ensure no more callbacks after stop
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
assert.False(t, callbackCalled, "Callback should not be called after watchdog is stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWatchdogWithZeroHeartbeats tests detecting hung worker that never sends heartbeats
|
||||||
|
func (s *WatchdogTestSuite) TestWatchdogWithZeroHeartbeats() {
|
||||||
|
callbackCalled := make(chan string, 1)
|
||||||
|
callback := func(forwardID string) {
|
||||||
|
callbackCalled <- forwardID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register worker but never send heartbeat
|
||||||
|
s.watchdog.RegisterWorker("test-forward", callback)
|
||||||
|
|
||||||
|
// Wait for hung detection
|
||||||
|
timeout := time.After(1 * time.Second)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case forwardID := <-callbackCalled:
|
||||||
|
assert.Equal(s.T(), "test-forward", forwardID)
|
||||||
|
case <-timeout:
|
||||||
|
s.T().Fatal("Timeout waiting for hung worker callback")
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
-13
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/nvm/kportal/internal/config"
|
"github.com/nvm/kportal/internal/config"
|
||||||
@@ -20,21 +21,25 @@ const (
|
|||||||
|
|
||||||
// ForwardWorker manages a single port-forward connection with automatic retry.
|
// ForwardWorker manages a single port-forward connection with automatic retry.
|
||||||
type ForwardWorker struct {
|
type ForwardWorker struct {
|
||||||
forward config.Forward
|
forward config.Forward
|
||||||
portForwarder *k8s.PortForwarder
|
portForwarder *k8s.PortForwarder
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
doneChan chan struct{}
|
doneChan chan struct{}
|
||||||
verbose bool
|
reconnectChan chan string // Channel to trigger reconnection
|
||||||
lastPod string // Track the last pod we connected to
|
verbose bool
|
||||||
statusUI StatusUpdater
|
lastPod string // Track the last pod we connected to
|
||||||
healthChecker *healthcheck.Checker
|
statusUI StatusUpdater
|
||||||
startTime time.Time // Track when the worker started
|
healthChecker *healthcheck.Checker
|
||||||
|
watchdog *Watchdog
|
||||||
|
startTime time.Time // Track when the worker started
|
||||||
|
forwardCancel context.CancelFunc // Cancel function for current forward attempt
|
||||||
|
forwardCancelMu sync.Mutex // Protects forwardCancel
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewForwardWorker creates a new ForwardWorker for a single forward configuration.
|
// NewForwardWorker creates a new ForwardWorker for a single forward configuration.
|
||||||
func NewForwardWorker(fwd config.Forward, portForwarder *k8s.PortForwarder, verbose bool, statusUI StatusUpdater, healthChecker *healthcheck.Checker) *ForwardWorker {
|
func NewForwardWorker(fwd config.Forward, portForwarder *k8s.PortForwarder, verbose bool, statusUI StatusUpdater, healthChecker *healthcheck.Checker, watchdog *Watchdog) *ForwardWorker {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
return &ForwardWorker{
|
return &ForwardWorker{
|
||||||
@@ -44,13 +49,32 @@ func NewForwardWorker(fwd config.Forward, portForwarder *k8s.PortForwarder, verb
|
|||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
doneChan: make(chan struct{}),
|
doneChan: make(chan struct{}),
|
||||||
|
reconnectChan: make(chan string, 1), // Buffered to avoid blocking
|
||||||
verbose: verbose,
|
verbose: verbose,
|
||||||
statusUI: statusUI,
|
statusUI: statusUI,
|
||||||
healthChecker: healthChecker,
|
healthChecker: healthChecker,
|
||||||
|
watchdog: watchdog,
|
||||||
startTime: time.Now(),
|
startTime: time.Now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TriggerReconnect triggers a reconnection (e.g., due to stale connection)
|
||||||
|
func (w *ForwardWorker) TriggerReconnect(reason string) {
|
||||||
|
// Cancel current forward if running
|
||||||
|
w.forwardCancelMu.Lock()
|
||||||
|
if w.forwardCancel != nil {
|
||||||
|
w.forwardCancel()
|
||||||
|
}
|
||||||
|
w.forwardCancelMu.Unlock()
|
||||||
|
|
||||||
|
// Send reconnect signal (non-blocking)
|
||||||
|
select {
|
||||||
|
case w.reconnectChan <- reason:
|
||||||
|
default:
|
||||||
|
// Channel already has pending reconnect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Start begins the port-forward worker in a goroutine.
|
// Start begins the port-forward worker in a goroutine.
|
||||||
// The worker will continuously retry on failures with exponential backoff.
|
// The worker will continuously retry on failures with exponential backoff.
|
||||||
func (w *ForwardWorker) Start() {
|
func (w *ForwardWorker) Start() {
|
||||||
@@ -71,6 +95,11 @@ func (w *ForwardWorker) run() {
|
|||||||
backoff := retry.NewBackoff()
|
backoff := retry.NewBackoff()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
// Send heartbeat to watchdog to indicate we're alive
|
||||||
|
if w.watchdog != nil {
|
||||||
|
w.watchdog.Heartbeat(w.forward.ID())
|
||||||
|
}
|
||||||
|
|
||||||
// Check if we should stop
|
// Check if we should stop
|
||||||
select {
|
select {
|
||||||
case <-w.ctx.Done():
|
case <-w.ctx.Done():
|
||||||
@@ -184,11 +213,24 @@ func (w *ForwardWorker) establishForward(podName string) error {
|
|||||||
forwardCtx, forwardCancel := context.WithCancel(w.ctx)
|
forwardCtx, forwardCancel := context.WithCancel(w.ctx)
|
||||||
defer forwardCancel()
|
defer forwardCancel()
|
||||||
|
|
||||||
// Start a goroutine to monitor for stop signal
|
// Store cancel function so TriggerReconnect can use it
|
||||||
|
w.forwardCancelMu.Lock()
|
||||||
|
w.forwardCancel = forwardCancel
|
||||||
|
w.forwardCancelMu.Unlock()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
w.forwardCancelMu.Lock()
|
||||||
|
w.forwardCancel = nil
|
||||||
|
w.forwardCancelMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Start a goroutine to monitor for stop signal and reconnect triggers
|
||||||
go func() {
|
go func() {
|
||||||
select {
|
select {
|
||||||
case <-w.stopChan:
|
case <-w.stopChan:
|
||||||
close(stopChan)
|
close(stopChan)
|
||||||
|
case <-w.reconnectChan:
|
||||||
|
close(stopChan)
|
||||||
case <-forwardCtx.Done():
|
case <-forwardCtx.Done():
|
||||||
close(stopChan)
|
close(stopChan)
|
||||||
}
|
}
|
||||||
@@ -230,6 +272,10 @@ func (w *ForwardWorker) establishForward(podName string) error {
|
|||||||
if w.verbose {
|
if w.verbose {
|
||||||
log.Printf("[%s] Port-forward connection established", w.forward.ID())
|
log.Printf("[%s] Port-forward connection established", w.forward.ID())
|
||||||
}
|
}
|
||||||
|
// Mark connection as established in health checker
|
||||||
|
if w.healthChecker != nil {
|
||||||
|
w.healthChecker.MarkConnected(w.forward.ID())
|
||||||
|
}
|
||||||
case err := <-errChan:
|
case err := <-errChan:
|
||||||
return fmt.Errorf("failed to establish forward: %w", err)
|
return fmt.Errorf("failed to establish forward: %w", err)
|
||||||
case <-w.ctx.Done():
|
case <-w.ctx.Done():
|
||||||
|
|||||||
+183
-43
@@ -3,6 +3,7 @@ package healthcheck
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
startupGracePeriod = 10 * time.Second
|
startupGracePeriod = 10 * time.Second
|
||||||
|
dataTransferSize = 1024 // bytes to read in data transfer test
|
||||||
)
|
)
|
||||||
|
|
||||||
// Status represents the health status of a port forward
|
// Status represents the health status of a port forward
|
||||||
@@ -20,15 +22,26 @@ const (
|
|||||||
StatusUnhealthy Status = "Error"
|
StatusUnhealthy Status = "Error"
|
||||||
StatusStarting Status = "Starting"
|
StatusStarting Status = "Starting"
|
||||||
StatusReconnect Status = "Reconnecting"
|
StatusReconnect Status = "Reconnecting"
|
||||||
|
StatusStale Status = "Stale" // Connection is old or idle
|
||||||
|
)
|
||||||
|
|
||||||
|
// CheckMethod represents the health check method
|
||||||
|
type CheckMethod string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CheckMethodTCPDial CheckMethod = "tcp-dial" // Simple TCP connection test
|
||||||
|
CheckMethodDataTransfer CheckMethod = "data-transfer" // Try to read data from connection
|
||||||
)
|
)
|
||||||
|
|
||||||
// PortHealth represents the health status of a single port
|
// PortHealth represents the health status of a single port
|
||||||
type PortHealth struct {
|
type PortHealth struct {
|
||||||
Port int
|
Port int
|
||||||
LastCheck time.Time
|
LastCheck time.Time
|
||||||
Status Status
|
Status Status
|
||||||
ErrorMessage string
|
ErrorMessage string
|
||||||
RegisteredAt time.Time // When this port was registered
|
RegisteredAt time.Time // When this port was registered
|
||||||
|
ConnectionTime time.Time // When current connection was established
|
||||||
|
LastActivity time.Time // Last time data was transferred
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatusCallback is called when a port's health status changes
|
// StatusCallback is called when a port's health status changes
|
||||||
@@ -36,26 +49,52 @@ type StatusCallback func(forwardID string, status Status, errorMsg string)
|
|||||||
|
|
||||||
// Checker performs periodic health checks on local ports
|
// Checker performs periodic health checks on local ports
|
||||||
type Checker struct {
|
type Checker struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
ports map[string]*PortHealth // key: forward ID
|
ports map[string]*PortHealth // key: forward ID
|
||||||
callbacks map[string]StatusCallback
|
callbacks map[string]StatusCallback
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
ctx context.Context
|
method CheckMethod
|
||||||
cancel context.CancelFunc
|
maxConnectionAge time.Duration
|
||||||
wg sync.WaitGroup
|
maxIdleTime time.Duration
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewChecker creates a new health checker
|
// CheckerOptions configures the health checker
|
||||||
|
type CheckerOptions struct {
|
||||||
|
Interval time.Duration
|
||||||
|
Timeout time.Duration
|
||||||
|
Method CheckMethod
|
||||||
|
MaxConnectionAge time.Duration
|
||||||
|
MaxIdleTime time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChecker creates a new health checker with default options
|
||||||
func NewChecker(interval, timeout time.Duration) *Checker {
|
func NewChecker(interval, timeout time.Duration) *Checker {
|
||||||
|
return NewCheckerWithOptions(CheckerOptions{
|
||||||
|
Interval: interval,
|
||||||
|
Timeout: timeout,
|
||||||
|
Method: CheckMethodDataTransfer,
|
||||||
|
MaxConnectionAge: 25 * time.Minute,
|
||||||
|
MaxIdleTime: 10 * time.Minute,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCheckerWithOptions creates a new health checker with custom options
|
||||||
|
func NewCheckerWithOptions(opts CheckerOptions) *Checker {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
return &Checker{
|
return &Checker{
|
||||||
ports: make(map[string]*PortHealth),
|
ports: make(map[string]*PortHealth),
|
||||||
callbacks: make(map[string]StatusCallback),
|
callbacks: make(map[string]StatusCallback),
|
||||||
interval: interval,
|
interval: opts.Interval,
|
||||||
timeout: timeout,
|
timeout: opts.Timeout,
|
||||||
ctx: ctx,
|
method: opts.Method,
|
||||||
cancel: cancel,
|
maxConnectionAge: opts.MaxConnectionAge,
|
||||||
|
maxIdleTime: opts.MaxIdleTime,
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,11 +103,14 @@ func (c *Checker) Register(forwardID string, port int, callback StatusCallback)
|
|||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
c.ports[forwardID] = &PortHealth{
|
c.ports[forwardID] = &PortHealth{
|
||||||
Port: port,
|
Port: port,
|
||||||
LastCheck: time.Time{},
|
LastCheck: time.Time{},
|
||||||
Status: StatusStarting,
|
Status: StatusStarting,
|
||||||
RegisteredAt: time.Now(),
|
RegisteredAt: now,
|
||||||
|
ConnectionTime: now,
|
||||||
|
LastActivity: now,
|
||||||
}
|
}
|
||||||
c.callbacks[forwardID] = callback
|
c.callbacks[forwardID] = callback
|
||||||
|
|
||||||
@@ -77,6 +119,28 @@ func (c *Checker) Register(forwardID string, port int, callback StatusCallback)
|
|||||||
go c.checkLoop(forwardID)
|
go c.checkLoop(forwardID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkConnected marks a forward as having established a new connection
|
||||||
|
func (c *Checker) MarkConnected(forwardID string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if health, exists := c.ports[forwardID]; exists {
|
||||||
|
now := time.Now()
|
||||||
|
health.ConnectionTime = now
|
||||||
|
health.LastActivity = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordActivity records data transfer activity for a forward
|
||||||
|
func (c *Checker) RecordActivity(forwardID string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if health, exists := c.ports[forwardID]; exists {
|
||||||
|
health.LastActivity = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Unregister removes a port from monitoring
|
// Unregister removes a port from monitoring
|
||||||
func (c *Checker) Unregister(forwardID string) {
|
func (c *Checker) Unregister(forwardID string) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
@@ -197,37 +261,57 @@ func (c *Checker) checkPort(forwardID string) {
|
|||||||
port := health.Port
|
port := health.Port
|
||||||
oldStatus := health.Status
|
oldStatus := health.Status
|
||||||
registeredAt := health.RegisteredAt
|
registeredAt := health.RegisteredAt
|
||||||
|
connectionTime := health.ConnectionTime
|
||||||
|
lastActivity := health.LastActivity
|
||||||
c.mu.RUnlock()
|
c.mu.RUnlock()
|
||||||
|
|
||||||
// Attempt to connect to the local port
|
now := time.Now()
|
||||||
ctx, cancel := context.WithTimeout(c.ctx, c.timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
var d net.Dialer
|
|
||||||
conn, err := d.DialContext(ctx, "tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
|
||||||
|
|
||||||
newStatus := StatusHealthy
|
newStatus := StatusHealthy
|
||||||
errorMsg := ""
|
errorMsg := ""
|
||||||
|
|
||||||
if err != nil {
|
// Check for stale connections based on age or idle time
|
||||||
// Grace period: if forward is less than 10 seconds old, keep it as "Starting"
|
connectionAge := now.Sub(connectionTime)
|
||||||
// This avoids scary "Error" messages during initial connection attempts
|
idleTime := now.Sub(lastActivity)
|
||||||
timeSinceStart := time.Since(registeredAt)
|
|
||||||
if timeSinceStart < startupGracePeriod {
|
// Only enforce max connection age if the connection is ALSO idle
|
||||||
newStatus = StatusStarting
|
// This prevents interrupting active transfers (e.g., database dumps)
|
||||||
} else {
|
if c.maxConnectionAge > 0 && connectionAge > c.maxConnectionAge && idleTime > c.maxIdleTime {
|
||||||
newStatus = StatusUnhealthy
|
newStatus = StatusStale
|
||||||
}
|
errorMsg = fmt.Sprintf("connection age %v exceeds max %v (and idle for %v)",
|
||||||
errorMsg = err.Error()
|
connectionAge.Round(time.Second), c.maxConnectionAge, idleTime.Round(time.Second))
|
||||||
|
} else if c.maxIdleTime > 0 && idleTime > c.maxIdleTime {
|
||||||
|
newStatus = StatusStale
|
||||||
|
errorMsg = fmt.Sprintf("idle time %v exceeds max %v", idleTime.Round(time.Second), c.maxIdleTime)
|
||||||
} else {
|
} else {
|
||||||
conn.Close()
|
// Perform connectivity check
|
||||||
|
var checkErr error
|
||||||
|
switch c.method {
|
||||||
|
case CheckMethodDataTransfer:
|
||||||
|
checkErr = c.checkDataTransfer(port)
|
||||||
|
case CheckMethodTCPDial:
|
||||||
|
checkErr = c.checkTCPDial(port)
|
||||||
|
default:
|
||||||
|
checkErr = c.checkTCPDial(port)
|
||||||
|
}
|
||||||
|
|
||||||
|
if checkErr != nil {
|
||||||
|
// Grace period: if forward is less than 10 seconds old, keep it as "Starting"
|
||||||
|
// This avoids scary "Error" messages during initial connection attempts
|
||||||
|
timeSinceStart := now.Sub(registeredAt)
|
||||||
|
if timeSinceStart < startupGracePeriod {
|
||||||
|
newStatus = StatusStarting
|
||||||
|
} else {
|
||||||
|
newStatus = StatusUnhealthy
|
||||||
|
}
|
||||||
|
errorMsg = checkErr.Error()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update health status
|
// Update health status
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
if health, exists := c.ports[forwardID]; exists {
|
if health, exists := c.ports[forwardID]; exists {
|
||||||
health.Status = newStatus
|
health.Status = newStatus
|
||||||
health.LastCheck = time.Now()
|
health.LastCheck = now
|
||||||
health.ErrorMessage = errorMsg
|
health.ErrorMessage = errorMsg
|
||||||
}
|
}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
@@ -238,6 +322,62 @@ func (c *Checker) checkPort(forwardID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkTCPDial performs a simple TCP dial test
|
||||||
|
func (c *Checker) checkTCPDial(port int) error {
|
||||||
|
ctx, cancel := context.WithTimeout(c.ctx, c.timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var d net.Dialer
|
||||||
|
conn, err := d.DialContext(ctx, "tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDataTransfer attempts to read data from the connection to verify tunnel health
|
||||||
|
func (c *Checker) checkDataTransfer(port int) error {
|
||||||
|
ctx, cancel := context.WithTimeout(c.ctx, c.timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var d net.Dialer
|
||||||
|
conn, err := d.DialContext(ctx, "tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Set a short read deadline to detect hung connections
|
||||||
|
// We don't expect to receive data, but we want to verify the connection isn't hung
|
||||||
|
conn.SetReadDeadline(time.Now().Add(c.timeout))
|
||||||
|
|
||||||
|
// Try to read a small amount of data
|
||||||
|
// Most servers will either:
|
||||||
|
// 1. Send a banner (SSH, FTP, etc) - we'll read it successfully
|
||||||
|
// 2. Wait for client to send first (HTTP, postgres) - we'll timeout (which is OK)
|
||||||
|
// 3. Hung/stale connection - will timeout with different error
|
||||||
|
buf := make([]byte, dataTransferSize)
|
||||||
|
_, err = conn.Read(buf)
|
||||||
|
|
||||||
|
// We expect either:
|
||||||
|
// - No error (banner received)
|
||||||
|
// - EOF (connection closed by server after connect)
|
||||||
|
// - Timeout (server waiting for client)
|
||||||
|
// All of these indicate the tunnel is working
|
||||||
|
if err == nil || err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout is acceptable - server is waiting for us to send data first
|
||||||
|
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other errors indicate a problem
|
||||||
|
return fmt.Errorf("data transfer check failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// notifyStatusChange calls the callback for a forward
|
// notifyStatusChange calls the callback for a forward
|
||||||
func (c *Checker) notifyStatusChange(forwardID string, status Status, errorMsg string) {
|
func (c *Checker) notifyStatusChange(forwardID string, status Status, errorMsg string) {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
|
|||||||
@@ -0,0 +1,553 @@
|
|||||||
|
package healthcheck
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HealthCheckTestSuite contains tests for the health checker
|
||||||
|
type HealthCheckTestSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
checker *Checker
|
||||||
|
listener net.Listener
|
||||||
|
port int
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthCheckSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(HealthCheckTestSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HealthCheckTestSuite) SetupTest() {
|
||||||
|
// Create a test listener on a random port
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
s.listener = ln
|
||||||
|
s.port = ln.Addr().(*net.TCPAddr).Port
|
||||||
|
|
||||||
|
// Create checker with fast intervals for testing
|
||||||
|
s.checker = NewCheckerWithOptions(CheckerOptions{
|
||||||
|
Interval: 100 * time.Millisecond,
|
||||||
|
Timeout: 50 * time.Millisecond,
|
||||||
|
Method: CheckMethodTCPDial,
|
||||||
|
MaxConnectionAge: 500 * time.Millisecond,
|
||||||
|
MaxIdleTime: 300 * time.Millisecond,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HealthCheckTestSuite) TearDownTest() {
|
||||||
|
if s.checker != nil {
|
||||||
|
s.checker.Stop()
|
||||||
|
}
|
||||||
|
if s.listener != nil {
|
||||||
|
s.listener.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegisterAndUnregister tests basic registration and unregistration
|
||||||
|
func (s *HealthCheckTestSuite) TestRegisterAndUnregister() {
|
||||||
|
callbackCalled := false
|
||||||
|
var callbackStatus Status
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
callback := func(forwardID string, status Status, errorMsg string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
callbackCalled = true
|
||||||
|
callbackStatus = status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register port
|
||||||
|
s.checker.Register("test-forward", s.port, callback)
|
||||||
|
|
||||||
|
// Wait for health check to run
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify callback was called with healthy status
|
||||||
|
mu.Lock()
|
||||||
|
assert.True(s.T(), callbackCalled, "Callback should have been called")
|
||||||
|
assert.Equal(s.T(), StatusHealthy, callbackStatus)
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
// Unregister
|
||||||
|
s.checker.Unregister("test-forward")
|
||||||
|
|
||||||
|
// Verify port is no longer monitored
|
||||||
|
status, exists := s.checker.GetStatus("test-forward")
|
||||||
|
assert.False(s.T(), exists, "Port should no longer exist after unregister")
|
||||||
|
assert.Equal(s.T(), StatusUnhealthy, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTCPDialMethod tests the TCP dial health check method
|
||||||
|
func (s *HealthCheckTestSuite) TestTCPDialMethod() {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setupPort bool
|
||||||
|
expectedStatus Status
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "port available - healthy",
|
||||||
|
setupPort: true,
|
||||||
|
expectedStatus: StatusHealthy,
|
||||||
|
description: "When port is listening, status should be healthy",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "port unavailable - unhealthy",
|
||||||
|
setupPort: false,
|
||||||
|
expectedStatus: StatusUnhealthy,
|
||||||
|
description: "When port is not listening, status should be unhealthy",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
s.Run(tt.name, func() {
|
||||||
|
var testPort int
|
||||||
|
var testListener net.Listener
|
||||||
|
|
||||||
|
if tt.setupPort {
|
||||||
|
// Use the existing listener
|
||||||
|
testPort = s.port
|
||||||
|
} else {
|
||||||
|
// Use a port that's not listening
|
||||||
|
testPort = 54321 // Likely unused port
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new checker for this test
|
||||||
|
checker := NewCheckerWithOptions(CheckerOptions{
|
||||||
|
Interval: 100 * time.Millisecond,
|
||||||
|
Timeout: 50 * time.Millisecond,
|
||||||
|
Method: CheckMethodTCPDial,
|
||||||
|
MaxConnectionAge: 0, // Disable for this test
|
||||||
|
MaxIdleTime: 0, // Disable for this test
|
||||||
|
})
|
||||||
|
defer checker.Stop()
|
||||||
|
|
||||||
|
checker.Register("test-forward", testPort, nil)
|
||||||
|
|
||||||
|
// Wait for health checks to complete
|
||||||
|
if !tt.setupPort {
|
||||||
|
// For unhealthy case, wait for grace period
|
||||||
|
time.Sleep(startupGracePeriod + 200*time.Millisecond)
|
||||||
|
} else {
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check status directly
|
||||||
|
status, exists := checker.GetStatus("test-forward")
|
||||||
|
assert.True(s.T(), exists)
|
||||||
|
assert.Equal(s.T(), tt.expectedStatus, status, tt.description)
|
||||||
|
|
||||||
|
if testListener != nil {
|
||||||
|
testListener.Close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDataTransferMethod tests the data transfer health check method
|
||||||
|
func (s *HealthCheckTestSuite) TestDataTransferMethod() {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
serverBehavior string // "banner", "silent", "close", "none"
|
||||||
|
expectedStatus Status
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "server sends banner - healthy",
|
||||||
|
serverBehavior: "banner",
|
||||||
|
expectedStatus: StatusHealthy,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "server waits silently - healthy (timeout OK)",
|
||||||
|
serverBehavior: "silent",
|
||||||
|
expectedStatus: StatusHealthy,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "server closes connection - healthy (EOF OK)",
|
||||||
|
serverBehavior: "close",
|
||||||
|
expectedStatus: StatusHealthy,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no server listening - unhealthy",
|
||||||
|
serverBehavior: "none",
|
||||||
|
expectedStatus: StatusUnhealthy,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
s.Run(tt.name, func() {
|
||||||
|
var testPort int
|
||||||
|
var testListener net.Listener
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if tt.serverBehavior != "none" {
|
||||||
|
// Start test server
|
||||||
|
testListener, err = net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(s.T(), err)
|
||||||
|
testPort = testListener.Addr().(*net.TCPAddr).Port
|
||||||
|
|
||||||
|
// Handle connections based on behavior
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := testListener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch tt.serverBehavior {
|
||||||
|
case "banner":
|
||||||
|
conn.Write([]byte("220 Welcome\r\n"))
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
conn.Close()
|
||||||
|
case "close":
|
||||||
|
conn.Close()
|
||||||
|
case "silent":
|
||||||
|
// Just keep connection open
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
defer testListener.Close()
|
||||||
|
} else {
|
||||||
|
testPort = 54322 // Unused port
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create checker with data transfer method
|
||||||
|
checker := NewCheckerWithOptions(CheckerOptions{
|
||||||
|
Interval: 100 * time.Millisecond,
|
||||||
|
Timeout: 50 * time.Millisecond,
|
||||||
|
Method: CheckMethodDataTransfer,
|
||||||
|
MaxConnectionAge: 0, // Disable for this test
|
||||||
|
MaxIdleTime: 0, // Disable for this test
|
||||||
|
})
|
||||||
|
defer checker.Stop()
|
||||||
|
|
||||||
|
checker.Register("test-forward", testPort, nil)
|
||||||
|
|
||||||
|
// Wait for health checks to complete
|
||||||
|
if tt.serverBehavior == "none" {
|
||||||
|
// For unhealthy case, wait for grace period
|
||||||
|
time.Sleep(startupGracePeriod + 200*time.Millisecond)
|
||||||
|
} else {
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check status directly
|
||||||
|
status, exists := checker.GetStatus("test-forward")
|
||||||
|
assert.True(s.T(), exists)
|
||||||
|
assert.Equal(s.T(), tt.expectedStatus, status)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnectionAgeDetection tests max connection age detection
|
||||||
|
func (s *HealthCheckTestSuite) TestConnectionAgeDetection() {
|
||||||
|
statusChanges := make(chan Status, 10)
|
||||||
|
callback := func(forwardID string, status Status, errorMsg string) {
|
||||||
|
statusChanges <- status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create checker with very short max connection age
|
||||||
|
checker := NewCheckerWithOptions(CheckerOptions{
|
||||||
|
Interval: 50 * time.Millisecond,
|
||||||
|
Timeout: 25 * time.Millisecond,
|
||||||
|
Method: CheckMethodTCPDial,
|
||||||
|
MaxConnectionAge: 150 * time.Millisecond, // Very short for testing
|
||||||
|
MaxIdleTime: 0, // Disable idle detection
|
||||||
|
})
|
||||||
|
defer checker.Stop()
|
||||||
|
|
||||||
|
checker.Register("test-forward", s.port, callback)
|
||||||
|
|
||||||
|
// Wait for initial healthy status
|
||||||
|
var gotHealthy, gotStale bool
|
||||||
|
timeout := time.After(1 * time.Second)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case status := <-statusChanges:
|
||||||
|
if status == StatusHealthy || status == StatusStarting {
|
||||||
|
gotHealthy = true
|
||||||
|
}
|
||||||
|
if status == StatusStale {
|
||||||
|
gotStale = true
|
||||||
|
}
|
||||||
|
if gotHealthy && gotStale {
|
||||||
|
return // Test passed
|
||||||
|
}
|
||||||
|
case <-timeout:
|
||||||
|
s.T().Fatalf("Expected StatusStale after max connection age exceeded. gotHealthy=%v, gotStale=%v",
|
||||||
|
gotHealthy, gotStale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIdleTimeDetection tests max idle time detection
|
||||||
|
func (s *HealthCheckTestSuite) TestIdleTimeDetection() {
|
||||||
|
statusChanges := make(chan Status, 10)
|
||||||
|
callback := func(forwardID string, status Status, errorMsg string) {
|
||||||
|
statusChanges <- status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create checker with very short max idle time
|
||||||
|
checker := NewCheckerWithOptions(CheckerOptions{
|
||||||
|
Interval: 50 * time.Millisecond,
|
||||||
|
Timeout: 25 * time.Millisecond,
|
||||||
|
Method: CheckMethodTCPDial,
|
||||||
|
MaxConnectionAge: 0, // Disable age detection
|
||||||
|
MaxIdleTime: 150 * time.Millisecond, // Very short for testing
|
||||||
|
})
|
||||||
|
defer checker.Stop()
|
||||||
|
|
||||||
|
checker.Register("test-forward", s.port, callback)
|
||||||
|
|
||||||
|
// Wait for initial healthy status
|
||||||
|
var gotHealthy, gotStale bool
|
||||||
|
timeout := time.After(1 * time.Second)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case status := <-statusChanges:
|
||||||
|
if status == StatusHealthy || status == StatusStarting {
|
||||||
|
gotHealthy = true
|
||||||
|
}
|
||||||
|
if status == StatusStale {
|
||||||
|
gotStale = true
|
||||||
|
}
|
||||||
|
if gotHealthy && gotStale {
|
||||||
|
return // Test passed
|
||||||
|
}
|
||||||
|
case <-timeout:
|
||||||
|
s.T().Fatalf("Expected StatusStale after max idle time exceeded. gotHealthy=%v, gotStale=%v",
|
||||||
|
gotHealthy, gotStale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMarkConnected tests that MarkConnected resets connection time
|
||||||
|
func (s *HealthCheckTestSuite) TestMarkConnected() {
|
||||||
|
checker := NewCheckerWithOptions(CheckerOptions{
|
||||||
|
Interval: 50 * time.Millisecond,
|
||||||
|
Timeout: 25 * time.Millisecond,
|
||||||
|
Method: CheckMethodTCPDial,
|
||||||
|
MaxConnectionAge: 200 * time.Millisecond,
|
||||||
|
MaxIdleTime: 0,
|
||||||
|
})
|
||||||
|
defer checker.Stop()
|
||||||
|
|
||||||
|
statusChanges := make(chan Status, 10)
|
||||||
|
callback := func(forwardID string, status Status, errorMsg string) {
|
||||||
|
statusChanges <- status
|
||||||
|
}
|
||||||
|
|
||||||
|
checker.Register("test-forward", s.port, callback)
|
||||||
|
|
||||||
|
// Wait a bit
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Mark as reconnected (resets connection time)
|
||||||
|
checker.MarkConnected("test-forward")
|
||||||
|
|
||||||
|
// Wait for connection age to exceed (relative to first connection time)
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
// Check status - should still be healthy because we reset connection time
|
||||||
|
status, exists := checker.GetStatus("test-forward")
|
||||||
|
assert.True(s.T(), exists)
|
||||||
|
// Note: Might be StatusStale by now, but the key is that MarkConnected delayed it
|
||||||
|
// This is a timing-sensitive test, so we just verify the functionality exists
|
||||||
|
_ = status
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecordActivity tests that RecordActivity resets idle time
|
||||||
|
func (s *HealthCheckTestSuite) TestRecordActivity() {
|
||||||
|
checker := NewCheckerWithOptions(CheckerOptions{
|
||||||
|
Interval: 50 * time.Millisecond,
|
||||||
|
Timeout: 25 * time.Millisecond,
|
||||||
|
Method: CheckMethodTCPDial,
|
||||||
|
MaxConnectionAge: 0,
|
||||||
|
MaxIdleTime: 200 * time.Millisecond,
|
||||||
|
})
|
||||||
|
defer checker.Stop()
|
||||||
|
|
||||||
|
statusChanges := make(chan Status, 10)
|
||||||
|
callback := func(forwardID string, status Status, errorMsg string) {
|
||||||
|
statusChanges <- status
|
||||||
|
}
|
||||||
|
|
||||||
|
checker.Register("test-forward", s.port, callback)
|
||||||
|
|
||||||
|
// Periodically record activity to prevent idle detection
|
||||||
|
ticker := time.NewTicker(80 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
<-ticker.C
|
||||||
|
checker.RecordActivity("test-forward")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait longer than idle timeout
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
// Should still be healthy due to activity
|
||||||
|
status, exists := checker.GetStatus("test-forward")
|
||||||
|
assert.True(s.T(), exists)
|
||||||
|
// May transition to stale eventually, but activity recording should have delayed it
|
||||||
|
_ = status
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMarkReconnecting tests the MarkReconnecting functionality
|
||||||
|
func (s *HealthCheckTestSuite) TestMarkReconnecting() {
|
||||||
|
statusChanges := make(chan Status, 10)
|
||||||
|
callback := func(forwardID string, status Status, errorMsg string) {
|
||||||
|
statusChanges <- status
|
||||||
|
}
|
||||||
|
|
||||||
|
s.checker.Register("test-forward", s.port, callback)
|
||||||
|
|
||||||
|
// Wait for initial status
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
|
||||||
|
// Mark as reconnecting
|
||||||
|
s.checker.MarkReconnecting("test-forward")
|
||||||
|
|
||||||
|
// Should receive reconnecting status
|
||||||
|
timeout := time.After(500 * time.Millisecond)
|
||||||
|
gotReconnect := false
|
||||||
|
for !gotReconnect {
|
||||||
|
select {
|
||||||
|
case status := <-statusChanges:
|
||||||
|
if status == StatusReconnect {
|
||||||
|
gotReconnect = true
|
||||||
|
}
|
||||||
|
case <-timeout:
|
||||||
|
s.T().Fatal("Expected StatusReconnect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStartingGracePeriod tests that errors during grace period show as "Starting"
|
||||||
|
func (s *HealthCheckTestSuite) TestStartingGracePeriod() {
|
||||||
|
// Use a port that's not listening
|
||||||
|
unavailablePort := 54323
|
||||||
|
|
||||||
|
checker := NewCheckerWithOptions(CheckerOptions{
|
||||||
|
Interval: 50 * time.Millisecond,
|
||||||
|
Timeout: 25 * time.Millisecond,
|
||||||
|
Method: CheckMethodTCPDial,
|
||||||
|
MaxConnectionAge: 0,
|
||||||
|
MaxIdleTime: 0,
|
||||||
|
})
|
||||||
|
defer checker.Stop()
|
||||||
|
|
||||||
|
// Register without callback - we'll check status directly
|
||||||
|
checker.Register("test-forward", unavailablePort, nil)
|
||||||
|
|
||||||
|
// Immediately check status - should be Starting or not yet checked
|
||||||
|
status, exists := checker.GetStatus("test-forward")
|
||||||
|
assert.True(s.T(), exists)
|
||||||
|
// Initially should be Starting
|
||||||
|
assert.Equal(s.T(), StatusStarting, status)
|
||||||
|
|
||||||
|
// Wait for grace period to expire
|
||||||
|
time.Sleep(startupGracePeriod + 200*time.Millisecond)
|
||||||
|
|
||||||
|
// Now should be Unhealthy
|
||||||
|
status, exists = checker.GetStatus("test-forward")
|
||||||
|
assert.True(s.T(), exists)
|
||||||
|
assert.Equal(s.T(), StatusUnhealthy, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetAllErrors tests retrieving all error messages
|
||||||
|
func (s *HealthCheckTestSuite) TestGetAllErrors() {
|
||||||
|
// Create a new checker with faster intervals for this test
|
||||||
|
checker := NewCheckerWithOptions(CheckerOptions{
|
||||||
|
Interval: 100 * time.Millisecond,
|
||||||
|
Timeout: 50 * time.Millisecond,
|
||||||
|
Method: CheckMethodTCPDial,
|
||||||
|
MaxConnectionAge: 0,
|
||||||
|
MaxIdleTime: 0,
|
||||||
|
})
|
||||||
|
defer checker.Stop()
|
||||||
|
|
||||||
|
// Register multiple forwards
|
||||||
|
checker.Register("forward1", s.port, nil)
|
||||||
|
checker.Register("forward2", 54324, nil) // Unavailable port
|
||||||
|
|
||||||
|
// Wait for grace period to expire
|
||||||
|
time.Sleep(startupGracePeriod + 300*time.Millisecond)
|
||||||
|
|
||||||
|
errors := checker.GetAllErrors()
|
||||||
|
|
||||||
|
// forward2 should have an error
|
||||||
|
_, hasError := errors["forward2"]
|
||||||
|
assert.True(s.T(), hasError, "forward2 should have an error")
|
||||||
|
|
||||||
|
// forward1 should not have an error
|
||||||
|
_, hasError = errors["forward1"]
|
||||||
|
assert.False(s.T(), hasError, "forward1 should not have an error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrentOperations tests thread safety
|
||||||
|
func (s *HealthCheckTestSuite) TestConcurrentOperations() {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
numGoroutines := 10
|
||||||
|
|
||||||
|
for i := 0; i < numGoroutines; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
forwardID := fmt.Sprintf("forward-%d", id)
|
||||||
|
s.checker.Register(forwardID, s.port, nil)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
s.checker.MarkConnected(forwardID)
|
||||||
|
s.checker.RecordActivity(forwardID)
|
||||||
|
status, _ := s.checker.GetStatus(forwardID)
|
||||||
|
_ = status
|
||||||
|
s.checker.Unregister(forwardID)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
// If we get here without deadlocks or panics, test passes
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDefaultOptions tests that NewChecker uses sensible defaults
|
||||||
|
func TestDefaultOptions(t *testing.T) {
|
||||||
|
checker := NewChecker(5*time.Second, 2*time.Second)
|
||||||
|
defer checker.Stop()
|
||||||
|
|
||||||
|
assert.Equal(t, 5*time.Second, checker.interval)
|
||||||
|
assert.Equal(t, 2*time.Second, checker.timeout)
|
||||||
|
assert.Equal(t, CheckMethodDataTransfer, checker.method)
|
||||||
|
assert.Equal(t, 25*time.Minute, checker.maxConnectionAge)
|
||||||
|
assert.Equal(t, 10*time.Minute, checker.maxIdleTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCustomOptions tests NewCheckerWithOptions
|
||||||
|
func TestCustomOptions(t *testing.T) {
|
||||||
|
opts := CheckerOptions{
|
||||||
|
Interval: 1 * time.Second,
|
||||||
|
Timeout: 500 * time.Millisecond,
|
||||||
|
Method: CheckMethodTCPDial,
|
||||||
|
MaxConnectionAge: 5 * time.Minute,
|
||||||
|
MaxIdleTime: 2 * time.Minute,
|
||||||
|
}
|
||||||
|
|
||||||
|
checker := NewCheckerWithOptions(opts)
|
||||||
|
defer checker.Stop()
|
||||||
|
|
||||||
|
assert.Equal(t, 1*time.Second, checker.interval)
|
||||||
|
assert.Equal(t, 500*time.Millisecond, checker.timeout)
|
||||||
|
assert.Equal(t, CheckMethodTCPDial, checker.method)
|
||||||
|
assert.Equal(t, 5*time.Minute, checker.maxConnectionAge)
|
||||||
|
assert.Equal(t, 2*time.Minute, checker.maxIdleTime)
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
@@ -17,18 +19,32 @@ import (
|
|||||||
|
|
||||||
// PortForwarder handles Kubernetes port-forwarding operations.
|
// PortForwarder handles Kubernetes port-forwarding operations.
|
||||||
type PortForwarder struct {
|
type PortForwarder struct {
|
||||||
clientPool *ClientPool
|
clientPool *ClientPool
|
||||||
resolver *ResourceResolver
|
resolver *ResourceResolver
|
||||||
|
tcpKeepalive time.Duration // TCP keepalive interval
|
||||||
|
dialTimeout time.Duration // Connection dial timeout
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPortForwarder creates a new PortForwarder instance.
|
// NewPortForwarder creates a new PortForwarder instance with default settings.
|
||||||
func NewPortForwarder(clientPool *ClientPool, resolver *ResourceResolver) *PortForwarder {
|
func NewPortForwarder(clientPool *ClientPool, resolver *ResourceResolver) *PortForwarder {
|
||||||
return &PortForwarder{
|
return &PortForwarder{
|
||||||
clientPool: clientPool,
|
clientPool: clientPool,
|
||||||
resolver: resolver,
|
resolver: resolver,
|
||||||
|
tcpKeepalive: 30 * time.Second, // Default: 30 second keepalive
|
||||||
|
dialTimeout: 30 * time.Second, // Default: 30 second dial timeout
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetTCPKeepalive configures the TCP keepalive interval for new connections.
|
||||||
|
func (pf *PortForwarder) SetTCPKeepalive(keepalive time.Duration) {
|
||||||
|
pf.tcpKeepalive = keepalive
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDialTimeout configures the connection dial timeout.
|
||||||
|
func (pf *PortForwarder) SetDialTimeout(timeout time.Duration) {
|
||||||
|
pf.dialTimeout = timeout
|
||||||
|
}
|
||||||
|
|
||||||
// ForwardRequest contains the parameters for a port-forward request.
|
// ForwardRequest contains the parameters for a port-forward request.
|
||||||
type ForwardRequest struct {
|
type ForwardRequest struct {
|
||||||
ContextName string // Kubernetes context name
|
ContextName string // Kubernetes context name
|
||||||
@@ -164,6 +180,19 @@ func (pf *PortForwarder) forwardToService(ctx context.Context, req *ForwardReque
|
|||||||
|
|
||||||
// executePortForward performs the actual port-forward operation.
|
// executePortForward performs the actual port-forward operation.
|
||||||
func (pf *PortForwarder) executePortForward(config *rest.Config, url *url.URL, req *ForwardRequest) error {
|
func (pf *PortForwarder) executePortForward(config *rest.Config, url *url.URL, req *ForwardRequest) error {
|
||||||
|
// Configure TCP settings on the underlying connection
|
||||||
|
// This is set in the rest.Config which will be used by the SPDY transport
|
||||||
|
if config.Dial == nil {
|
||||||
|
// Create a custom dialer with configurable timeout and keepalive
|
||||||
|
// - Timeout: How long to wait for connection to establish
|
||||||
|
// - KeepAlive: TCP keepalive helps OS detect dead connections at network layer
|
||||||
|
dialer := &net.Dialer{
|
||||||
|
Timeout: pf.dialTimeout, // Configurable dial timeout
|
||||||
|
KeepAlive: pf.tcpKeepalive, // Configurable keepalive interval
|
||||||
|
}
|
||||||
|
config.Dial = dialer.DialContext
|
||||||
|
}
|
||||||
|
|
||||||
// Create SPDY roundtripper
|
// Create SPDY roundtripper
|
||||||
transport, upgrader, err := spdy.RoundTripperFor(config)
|
transport, upgrader, err := spdy.RoundTripperFor(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user