mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-05 23:03:40 +00:00
fixup! Update go.mod and go.sum (#48)
This commit is contained in:
+9
-9
@@ -15,15 +15,15 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/nvm/kportal/internal/converter"
|
||||
"github.com/nvm/kportal/internal/forward"
|
||||
"github.com/nvm/kportal/internal/httplog"
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
"github.com/nvm/kportal/internal/logger"
|
||||
"github.com/nvm/kportal/internal/mdns"
|
||||
"github.com/nvm/kportal/internal/ui"
|
||||
"github.com/nvm/kportal/internal/version"
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/converter"
|
||||
"github.com/lukaszraczylo/kportal/internal/forward"
|
||||
"github.com/lukaszraczylo/kportal/internal/httplog"
|
||||
"github.com/lukaszraczylo/kportal/internal/k8s"
|
||||
"github.com/lukaszraczylo/kportal/internal/logger"
|
||||
"github.com/lukaszraczylo/kportal/internal/mdns"
|
||||
"github.com/lukaszraczylo/kportal/internal/ui"
|
||||
"github.com/lukaszraczylo/kportal/internal/version"
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module github.com/nvm/kportal
|
||||
module github.com/lukaszraczylo/kportal
|
||||
|
||||
go 1.25.0
|
||||
|
||||
|
||||
+335
-18
@@ -2,12 +2,39 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
MinPort = 1
|
||||
MaxPort = 65535
|
||||
|
||||
// DNS1123LabelMaxLength is the maximum length of a DNS label (RFC 1123)
|
||||
DNS1123LabelMaxLength = 63
|
||||
// DNS1123SubdomainMaxLength is the maximum length of a DNS subdomain name
|
||||
DNS1123SubdomainMaxLength = 253
|
||||
)
|
||||
|
||||
var (
|
||||
// dns1123LabelRegexp matches valid DNS labels (RFC 1123)
|
||||
// Must consist of lowercase alphanumeric characters or '-', start with alphanumeric, end with alphanumeric
|
||||
dns1123LabelRegexp = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
|
||||
|
||||
// dns1123SubdomainRegexp matches valid DNS subdomain names
|
||||
// A series of DNS labels separated by dots (no consecutive dots allowed)
|
||||
dns1123SubdomainRegexp = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`)
|
||||
|
||||
// contextNameRegexp matches valid context names
|
||||
// Allows alphanumeric characters, hyphens, and underscores (to support various kubeconfig naming conventions)
|
||||
contextNameRegexp = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$`)
|
||||
|
||||
// validResourceTypes contains the allowed Kubernetes resource types
|
||||
validResourceTypes = []string{"pod", "service"}
|
||||
|
||||
// validHealthCheckMethods contains the allowed health check methods
|
||||
validHealthCheckMethods = []string{"tcp-dial", "data-transfer"}
|
||||
)
|
||||
|
||||
// IsValidPort returns true if the port number is within the valid range (1-65535).
|
||||
@@ -51,6 +78,7 @@ func (v *Validator) ValidateConfigWithOptions(cfg *Config, allowEmpty bool) []Va
|
||||
// If empty configs are allowed and this config is empty, skip structure validation
|
||||
if allowEmpty && cfg.IsEmpty() {
|
||||
// Still validate health check and reliability if present (they don't require forwards)
|
||||
errs = append(errs, v.validateSpecDurations(cfg)...)
|
||||
return errs
|
||||
}
|
||||
|
||||
@@ -74,6 +102,9 @@ func (v *Validator) ValidateConfigWithOptions(cfg *Config, allowEmpty bool) []Va
|
||||
errs = append(errs, v.validateMDNS(cfg)...)
|
||||
}
|
||||
|
||||
// Validate duration fields in specs
|
||||
errs = append(errs, v.validateSpecDurations(cfg)...)
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
@@ -95,6 +126,11 @@ func (v *Validator) validateStructure(cfg *Config) []ValidationError {
|
||||
Field: fmt.Sprintf("contexts[%d].name", i),
|
||||
Message: "Context name cannot be empty",
|
||||
})
|
||||
} else {
|
||||
// Validate context name format (alphanumeric, hyphens, underscores)
|
||||
if err := validateContextName(ctx.Name, fmt.Sprintf("contexts[%d].name", i)); err != nil {
|
||||
errs = append(errs, *err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(ctx.Namespaces) == 0 {
|
||||
@@ -111,6 +147,11 @@ func (v *Validator) validateStructure(cfg *Config) []ValidationError {
|
||||
Field: fmt.Sprintf("contexts[%d].namespaces[%d].name", i, j),
|
||||
Message: fmt.Sprintf("Namespace name cannot be empty in context '%s'", ctx.Name),
|
||||
})
|
||||
} else {
|
||||
// Validate namespace name follows DNS subdomain conventions (Kubernetes requirement)
|
||||
if err := validateNamespaceName(ns.Name, fmt.Sprintf("contexts[%d].namespaces[%d].name", i, j)); err != nil {
|
||||
errs = append(errs, *err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(ns.Forwards) == 0 {
|
||||
@@ -139,29 +180,38 @@ func (v *Validator) validateForward(fwd *Forward) []ValidationError {
|
||||
errs = append(errs, v.validateResource(fwd)...)
|
||||
}
|
||||
|
||||
// Validate protocol
|
||||
if fwd.Protocol != "" && fwd.Protocol != "tcp" && fwd.Protocol != "udp" {
|
||||
// Validate protocol - only "tcp" is currently supported
|
||||
if fwd.Protocol != "" && fwd.Protocol != "tcp" {
|
||||
errs = append(errs, ValidationError{
|
||||
Field: "protocol",
|
||||
Message: fmt.Sprintf("Invalid protocol '%s' for forward %s (must be 'tcp' or 'udp')", fwd.Protocol, fwd.ID()),
|
||||
Message: fmt.Sprintf("Invalid protocol '%s' for forward %s (only 'tcp' is supported)", fwd.Protocol, fwd.ID()),
|
||||
})
|
||||
}
|
||||
|
||||
// Validate ports
|
||||
if fwd.Port < MinPort || fwd.Port > MaxPort {
|
||||
if !IsValidPort(fwd.Port) {
|
||||
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 {
|
||||
if !IsValidPort(fwd.LocalPort) {
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
// Note: Alias validation is handled in validateMDNS since aliases are primarily
|
||||
// used for mDNS hostname registration. We only validate alias format when mDNS
|
||||
// is enabled to avoid unnecessary restrictions on non-mDNS usage.
|
||||
|
||||
// Validate HTTP log configuration if enabled
|
||||
if fwd.HTTPLog != nil && fwd.HTTPLog.Enabled {
|
||||
errs = append(errs, v.validateHTTPLog(fwd)...)
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
@@ -169,18 +219,44 @@ func (v *Validator) validateForward(fwd *Forward) []ValidationError {
|
||||
func (v *Validator) validateResource(fwd *Forward) []ValidationError {
|
||||
var errs []ValidationError
|
||||
|
||||
// Validate resource format (must be "type/name" or just "type" for pod with selector)
|
||||
parts := strings.SplitN(fwd.Resource, "/", 2)
|
||||
resourceType := parts[0]
|
||||
|
||||
// Valid resource types: pod, service
|
||||
if resourceType != "pod" && resourceType != "service" {
|
||||
// Validate resource type
|
||||
if !isValidResourceType(resourceType) {
|
||||
errs = append(errs, ValidationError{
|
||||
Field: "resource",
|
||||
Message: fmt.Sprintf("Invalid resource type '%s' for forward %s (must be 'pod' or 'service')", resourceType, fwd.ID()),
|
||||
Message: fmt.Sprintf("Invalid resource type '%s' for forward %s (must be one of: %s)", resourceType, fwd.ID(), strings.Join(validResourceTypes, ", ")),
|
||||
})
|
||||
return errs
|
||||
}
|
||||
|
||||
// Validate resource name if provided
|
||||
if len(parts) == 2 {
|
||||
resourceName := parts[1]
|
||||
if resourceName == "" {
|
||||
// Use resource-type-specific error message for better clarity
|
||||
entityType := "Resource"
|
||||
switch resourceType {
|
||||
case "pod":
|
||||
entityType = "Pod"
|
||||
case "service":
|
||||
entityType = "Service"
|
||||
}
|
||||
errs = append(errs, ValidationError{
|
||||
Field: "resource",
|
||||
Message: fmt.Sprintf("%s name cannot be empty for forward %s", entityType, fwd.ID()),
|
||||
})
|
||||
} else {
|
||||
// Validate resource name follows DNS subdomain conventions
|
||||
if err := validateDNS1123Subdomain(resourceName, "resource", "Resource name"); err != nil {
|
||||
err.Message = fmt.Sprintf("%s for forward %s", err.Message, fwd.ID())
|
||||
errs = append(errs, *err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For pod resources
|
||||
if resourceType == "pod" {
|
||||
if len(parts) == 2 {
|
||||
@@ -191,14 +267,6 @@ func (v *Validator) validateResource(fwd *Forward) []ValidationError {
|
||||
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 if fwd.Selector == "" {
|
||||
// pod (no name) - must have selector
|
||||
errs = append(errs, ValidationError{
|
||||
@@ -213,7 +281,7 @@ func (v *Validator) validateResource(fwd *Forward) []ValidationError {
|
||||
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()),
|
||||
Message: fmt.Sprintf("Service name cannot be empty for forward %s (format: service/name)", fwd.ID()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -259,6 +327,109 @@ func (v *Validator) validateDuplicatePorts(cfg *Config) []ValidationError {
|
||||
return errs
|
||||
}
|
||||
|
||||
// validateSpecDurations validates duration strings in HealthCheck and Reliability specs.
|
||||
func (v *Validator) validateSpecDurations(cfg *Config) []ValidationError {
|
||||
var errs []ValidationError
|
||||
|
||||
// Validate HealthCheck durations
|
||||
if cfg.HealthCheck != nil {
|
||||
if cfg.HealthCheck.Interval != "" {
|
||||
if _, err := time.ParseDuration(cfg.HealthCheck.Interval); err != nil {
|
||||
errs = append(errs, ValidationError{
|
||||
Field: "healthCheck.interval",
|
||||
Message: fmt.Sprintf("Invalid health check interval '%s': %v", cfg.HealthCheck.Interval, err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.HealthCheck.Timeout != "" {
|
||||
if _, err := time.ParseDuration(cfg.HealthCheck.Timeout); err != nil {
|
||||
errs = append(errs, ValidationError{
|
||||
Field: "healthCheck.timeout",
|
||||
Message: fmt.Sprintf("Invalid health check timeout '%s': %v", cfg.HealthCheck.Timeout, err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.HealthCheck.MaxConnectionAge != "" {
|
||||
if _, err := time.ParseDuration(cfg.HealthCheck.MaxConnectionAge); err != nil {
|
||||
errs = append(errs, ValidationError{
|
||||
Field: "healthCheck.maxConnectionAge",
|
||||
Message: fmt.Sprintf("Invalid max connection age '%s': %v", cfg.HealthCheck.MaxConnectionAge, err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.HealthCheck.MaxIdleTime != "" {
|
||||
if _, err := time.ParseDuration(cfg.HealthCheck.MaxIdleTime); err != nil {
|
||||
errs = append(errs, ValidationError{
|
||||
Field: "healthCheck.maxIdleTime",
|
||||
Message: fmt.Sprintf("Invalid max idle time '%s': %v", cfg.HealthCheck.MaxIdleTime, err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Validate health check method
|
||||
if cfg.HealthCheck.Method != "" && !isValidHealthCheckMethod(cfg.HealthCheck.Method) {
|
||||
errs = append(errs, ValidationError{
|
||||
Field: "healthCheck.method",
|
||||
Message: fmt.Sprintf("Invalid health check method '%s' (must be one of: %s)", cfg.HealthCheck.Method, strings.Join(validHealthCheckMethods, ", ")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Reliability durations
|
||||
if cfg.Reliability != nil {
|
||||
if cfg.Reliability.TCPKeepalive != "" {
|
||||
if _, err := time.ParseDuration(cfg.Reliability.TCPKeepalive); err != nil {
|
||||
errs = append(errs, ValidationError{
|
||||
Field: "reliability.tcpKeepalive",
|
||||
Message: fmt.Sprintf("Invalid TCP keepalive duration '%s': %v", cfg.Reliability.TCPKeepalive, err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Reliability.DialTimeout != "" {
|
||||
if _, err := time.ParseDuration(cfg.Reliability.DialTimeout); err != nil {
|
||||
errs = append(errs, ValidationError{
|
||||
Field: "reliability.dialTimeout",
|
||||
Message: fmt.Sprintf("Invalid dial timeout '%s': %v", cfg.Reliability.DialTimeout, err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Reliability.WatchdogPeriod != "" {
|
||||
if _, err := time.ParseDuration(cfg.Reliability.WatchdogPeriod); err != nil {
|
||||
errs = append(errs, ValidationError{
|
||||
Field: "reliability.watchdogPeriod",
|
||||
Message: fmt.Sprintf("Invalid watchdog period '%s': %v", cfg.Reliability.WatchdogPeriod, err),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
// validateHTTPLog validates HTTP log configuration.
|
||||
func (v *Validator) validateHTTPLog(fwd *Forward) []ValidationError {
|
||||
var errs []ValidationError
|
||||
|
||||
if fwd.HTTPLog == nil {
|
||||
return errs
|
||||
}
|
||||
|
||||
// Validate maxBodySize is non-negative
|
||||
if fwd.HTTPLog.MaxBodySize < 0 {
|
||||
errs = append(errs, ValidationError{
|
||||
Field: "httpLog.maxBodySize",
|
||||
Message: fmt.Sprintf("Invalid maxBodySize %d for forward %s (must be non-negative)", fwd.HTTPLog.MaxBodySize, fwd.ID()),
|
||||
})
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
// FormatValidationErrors formats validation errors into a human-readable string.
|
||||
func FormatValidationErrors(errs []ValidationError) string {
|
||||
if len(errs) == 0 {
|
||||
@@ -334,7 +505,7 @@ func (v *Validator) validateMDNS(cfg *Config) []ValidationError {
|
||||
// Hostnames must start with alphanumeric, contain only alphanumeric and hyphens,
|
||||
// and be 1-63 characters long.
|
||||
func isValidHostname(name string) bool {
|
||||
if len(name) == 0 || len(name) > 63 {
|
||||
if len(name) == 0 || len(name) > DNS1123LabelMaxLength {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -363,3 +534,149 @@ func isValidHostname(name string) bool {
|
||||
func isAlphanumeric(c byte) bool {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
|
||||
}
|
||||
|
||||
// isValidResourceType returns true if the resource type is valid.
|
||||
func isValidResourceType(resourceType string) bool {
|
||||
for _, rt := range validResourceTypes {
|
||||
if rt == resourceType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isValidHealthCheckMethod returns true if the health check method is valid.
|
||||
func isValidHealthCheckMethod(method string) bool {
|
||||
for _, m := range validHealthCheckMethods {
|
||||
if m == method {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validateContextName validates that a context name follows the allowed format.
|
||||
// Context names must consist of alphanumeric characters, hyphens, or underscores,
|
||||
// and must start and end with an alphanumeric character.
|
||||
// This more permissive validation supports various kubeconfig naming conventions
|
||||
// (e.g., "gke_project_zone_cluster", "minikube", "docker-desktop").
|
||||
func validateContextName(name, field string) *ValidationError {
|
||||
if len(name) > DNS1123SubdomainMaxLength {
|
||||
return &ValidationError{
|
||||
Field: field,
|
||||
Message: fmt.Sprintf("Context name '%s' exceeds maximum length of %d characters", name, DNS1123SubdomainMaxLength),
|
||||
}
|
||||
}
|
||||
|
||||
if !contextNameRegexp.MatchString(name) {
|
||||
return &ValidationError{
|
||||
Field: field,
|
||||
Message: fmt.Sprintf("Context name '%s' is not valid (must consist of alphanumeric characters, hyphens, or underscores, and start/end with alphanumeric)", name),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateNamespaceName validates that a namespace name is a valid DNS subdomain (RFC 1123).
|
||||
// Kubernetes namespaces must follow DNS subdomain format which allows dots for subdomain separation.
|
||||
// This is more permissive than DNS labels and supports names like "kube-system", "my-app.ns".
|
||||
func validateNamespaceName(name, field string) *ValidationError {
|
||||
if len(name) > DNS1123SubdomainMaxLength {
|
||||
return &ValidationError{
|
||||
Field: field,
|
||||
Message: fmt.Sprintf("Namespace name '%s' exceeds maximum length of %d characters", name, DNS1123SubdomainMaxLength),
|
||||
}
|
||||
}
|
||||
|
||||
if !dns1123SubdomainRegexp.MatchString(name) {
|
||||
return &ValidationError{
|
||||
Field: field,
|
||||
Message: fmt.Sprintf("Namespace name '%s' is not a valid DNS subdomain (must consist of lowercase alphanumeric characters, '-', or '.', start with alphanumeric, end with alphanumeric)", name),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDNS1123Label validates that a name is a valid DNS label (RFC 1123).
|
||||
// Used for context names and namespace names.
|
||||
func validateDNS1123Label(name, field, entityType string) *ValidationError {
|
||||
if len(name) > DNS1123LabelMaxLength {
|
||||
return &ValidationError{
|
||||
Field: field,
|
||||
Message: fmt.Sprintf("%s name '%s' exceeds maximum length of %d characters", entityType, name, DNS1123LabelMaxLength),
|
||||
}
|
||||
}
|
||||
|
||||
if !dns1123LabelRegexp.MatchString(name) {
|
||||
return &ValidationError{
|
||||
Field: field,
|
||||
Message: fmt.Sprintf("%s name '%s' is not a valid DNS label (must consist of lowercase alphanumeric characters or '-', start with alphanumeric, end with alphanumeric)", entityType, name),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateDNS1123Subdomain validates that a name is a valid DNS subdomain name (RFC 1123).
|
||||
// Used for resource names which can contain dots.
|
||||
func validateDNS1123Subdomain(name, field, entityType string) *ValidationError {
|
||||
if len(name) > DNS1123SubdomainMaxLength {
|
||||
return &ValidationError{
|
||||
Field: field,
|
||||
Message: fmt.Sprintf("%s '%s' exceeds maximum length of %d characters", entityType, name, DNS1123SubdomainMaxLength),
|
||||
}
|
||||
}
|
||||
|
||||
if !dns1123SubdomainRegexp.MatchString(name) {
|
||||
return &ValidationError{
|
||||
Field: field,
|
||||
Message: fmt.Sprintf("%s '%s' is not a valid DNS subdomain name (must consist of lowercase alphanumeric characters, '-', or '.', start with alphanumeric, end with alphanumeric)", entityType, name),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatePort validates a port number and returns an error if invalid.
|
||||
// This is a public function that can be used externally.
|
||||
func ValidatePort(port int, name string) error {
|
||||
if !IsValidPort(port) {
|
||||
return fmt.Errorf("%s must be between %d and %d, got %d", name, MinPort, MaxPort, port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateResourceFormat validates that a resource string is in the correct format.
|
||||
// This is a public function that can be used externally.
|
||||
func ValidateResourceFormat(resource string) error {
|
||||
parts := strings.SplitN(resource, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("resource must be in format 'type/name', got: %s", resource)
|
||||
}
|
||||
|
||||
resourceType := parts[0]
|
||||
if !isValidResourceType(resourceType) {
|
||||
return fmt.Errorf("invalid resource type '%s' (must be one of: %s)", resourceType, strings.Join(validResourceTypes, ", "))
|
||||
}
|
||||
|
||||
if parts[1] == "" {
|
||||
return fmt.Errorf("resource name cannot be empty in '%s'", resource)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateDuration validates that a string is a valid duration.
|
||||
// This is a public function that can be used externally.
|
||||
func ValidateDuration(duration, name string) error {
|
||||
if duration == "" {
|
||||
return nil // Empty durations are allowed (will use defaults)
|
||||
}
|
||||
_, err := time.ParseDuration(duration)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid %s '%s': %v", name, duration, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"github.com/nvm/kportal/internal/logger"
|
||||
"github.com/lukaszraczylo/kportal/internal/logger"
|
||||
)
|
||||
|
||||
// ReloadCallback is called when the configuration file changes.
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
|
||||
@@ -20,12 +20,12 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/nvm/kportal/internal/events"
|
||||
"github.com/nvm/kportal/internal/healthcheck"
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
"github.com/nvm/kportal/internal/logger"
|
||||
"github.com/nvm/kportal/internal/mdns"
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/events"
|
||||
"github.com/lukaszraczylo/kportal/internal/healthcheck"
|
||||
"github.com/lukaszraczylo/kportal/internal/k8s"
|
||||
"github.com/lukaszraczylo/kportal/internal/logger"
|
||||
"github.com/lukaszraczylo/kportal/internal/mdns"
|
||||
)
|
||||
|
||||
// StatusUpdater is an interface for updating forward status
|
||||
@@ -241,12 +241,17 @@ func (m *Manager) Stop() {
|
||||
}
|
||||
m.workersMu.Unlock()
|
||||
|
||||
// Stop all workers
|
||||
// Stop all workers with limited concurrency to avoid unbounded goroutine creation
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, 10) // Limit to 10 concurrent stops
|
||||
|
||||
for _, worker := range workers {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{} // Acquire semaphore
|
||||
|
||||
go func(w *ForwardWorker) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }() // Release semaphore
|
||||
w.Stop()
|
||||
}(worker)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/nvm/kportal/internal/events"
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/events"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -331,3 +332,45 @@ func TestManager_EventBusIntegration(t *testing.T) {
|
||||
// Handler
|
||||
})
|
||||
}
|
||||
|
||||
// TestManager_Stop_WithManyWorkers tests that shutdown limits concurrent stops
|
||||
func TestManager_Stop_WithManyWorkers(t *testing.T) {
|
||||
manager, err := NewManager(false)
|
||||
if err != nil {
|
||||
t.Skip("Skipping test - no kubeconfig available")
|
||||
}
|
||||
|
||||
// Create and add mock workers directly to test shutdown behavior
|
||||
numWorkers := 25
|
||||
manager.workersMu.Lock()
|
||||
for i := 0; i < numWorkers; i++ {
|
||||
fwd := config.Forward{
|
||||
Resource: fmt.Sprintf("pod/app-%d", i),
|
||||
Port: 8080,
|
||||
LocalPort: 10000 + i,
|
||||
}
|
||||
worker := NewForwardWorker(fwd, manager.portForwarder, false, nil, manager.healthChecker, manager.watchdog)
|
||||
manager.workers[fwd.ID()] = worker
|
||||
}
|
||||
manager.workersMu.Unlock()
|
||||
|
||||
// Stop should complete successfully with limited concurrency
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
manager.Stop()
|
||||
done <- true
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Success - all workers stopped
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("Stop timed out with many workers")
|
||||
}
|
||||
|
||||
// Verify workers map is cleared
|
||||
manager.workersMu.RLock()
|
||||
workerCount := len(manager.workers)
|
||||
manager.workersMu.RUnlock()
|
||||
assert.Equal(t, 0, workerCount, "Workers map should be empty after Stop")
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/nvm/kportal/internal/logger"
|
||||
"github.com/lukaszraczylo/kportal/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nvm/kportal/internal/events"
|
||||
"github.com/nvm/kportal/internal/logger"
|
||||
"github.com/lukaszraczylo/kportal/internal/events"
|
||||
"github.com/lukaszraczylo/kportal/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -8,12 +8,12 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/nvm/kportal/internal/healthcheck"
|
||||
"github.com/nvm/kportal/internal/httplog"
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
"github.com/nvm/kportal/internal/logger"
|
||||
"github.com/nvm/kportal/internal/retry"
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/healthcheck"
|
||||
"github.com/lukaszraczylo/kportal/internal/httplog"
|
||||
"github.com/lukaszraczylo/kportal/internal/k8s"
|
||||
"github.com/lukaszraczylo/kportal/internal/logger"
|
||||
"github.com/lukaszraczylo/kportal/internal/retry"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -132,8 +132,16 @@ func (w *ForwardWorker) GetForwardID() string {
|
||||
|
||||
// run is the main worker loop that handles retries.
|
||||
func (w *ForwardWorker) run() {
|
||||
defer close(w.doneChan)
|
||||
defer w.stopHTTPProxy() // Ensure proxy is stopped on exit
|
||||
// Use a combined defer with sync.Once to ensure doneChan is closed
|
||||
// even if stopHTTPProxy() panics. This prevents the worker from
|
||||
// getting stuck if cleanup operations fail.
|
||||
var closeDoneOnce sync.Once
|
||||
defer func() {
|
||||
w.stopHTTPProxy() // Ensure proxy is stopped on exit
|
||||
closeDoneOnce.Do(func() {
|
||||
close(w.doneChan)
|
||||
})
|
||||
}()
|
||||
|
||||
// Note: Heartbeat management is now centralized in the Watchdog.
|
||||
// The watchdog polls workers via the HeartbeatResponder interface (IsAlive method)
|
||||
@@ -266,14 +274,16 @@ func (w *ForwardWorker) establishForward(podName string) error {
|
||||
|
||||
// Create a context for this forward attempt
|
||||
forwardCtx, forwardCancel := context.WithCancel(w.ctx)
|
||||
defer forwardCancel()
|
||||
|
||||
// Store cancel function so TriggerReconnect can use it
|
||||
w.forwardCancelMu.Lock()
|
||||
w.forwardCancel = forwardCancel
|
||||
w.forwardCancelMu.Unlock()
|
||||
|
||||
// Combined cleanup: cancel context and clear the cancel function reference.
|
||||
// Using a single defer ensures both operations happen atomically.
|
||||
defer func() {
|
||||
forwardCancel()
|
||||
w.forwardCancelMu.Lock()
|
||||
w.forwardCancel = nil
|
||||
w.forwardCancelMu.Unlock()
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -284,3 +286,93 @@ func TestWorkerVerboseMode(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkerCleanupWithPanic verifies that doneChan is properly closed
|
||||
// even when cleanup functions panic. This tests the fix for the defer
|
||||
// ordering issue where stopHTTPProxy() could prevent doneChan from closing.
|
||||
func TestWorkerCleanupWithPanic(t *testing.T) {
|
||||
t.Run("doneChan closed after panic in cleanup", func(t *testing.T) {
|
||||
doneChan := make(chan struct{})
|
||||
|
||||
// Simulate the cleanup pattern used in run() with sync.Once
|
||||
var closeDoneOnce sync.Once
|
||||
cleanupWithPanic := func() {
|
||||
// Simulate stopHTTPProxy() that panics
|
||||
panic("simulated panic in cleanup")
|
||||
}
|
||||
|
||||
// Use defer with recovery to test the pattern
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// Expected panic - doneChan should still be closed
|
||||
_ = r // Suppress SA9003: empty branch warning
|
||||
}
|
||||
closeDoneOnce.Do(func() {
|
||||
close(doneChan)
|
||||
})
|
||||
}()
|
||||
|
||||
cleanupWithPanic()
|
||||
}()
|
||||
|
||||
// Verify doneChan was closed even though cleanup panicked
|
||||
select {
|
||||
case <-doneChan:
|
||||
// Success: channel was closed
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("doneChan should be closed even when cleanup panics")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("doneChan closed normally without panic", func(t *testing.T) {
|
||||
doneChan := make(chan struct{})
|
||||
|
||||
var closeDoneOnce sync.Once
|
||||
cleanupNormal := func() {
|
||||
// Normal cleanup, no panic
|
||||
}
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
cleanupNormal()
|
||||
closeDoneOnce.Do(func() {
|
||||
close(doneChan)
|
||||
})
|
||||
}()
|
||||
// Normal function execution
|
||||
}()
|
||||
|
||||
// Verify doneChan was closed
|
||||
select {
|
||||
case <-doneChan:
|
||||
// Success
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("doneChan should be closed after normal execution")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sync.Once prevents double close", func(t *testing.T) {
|
||||
doneChan := make(chan struct{})
|
||||
|
||||
var closeDoneOnce sync.Once
|
||||
closeFunc := func() {
|
||||
closeDoneOnce.Do(func() {
|
||||
close(doneChan)
|
||||
})
|
||||
}
|
||||
|
||||
// Call closeFunc multiple times
|
||||
closeFunc()
|
||||
closeFunc()
|
||||
closeFunc()
|
||||
|
||||
// Should not panic - sync.Once ensures close() is only called once
|
||||
select {
|
||||
case <-doneChan:
|
||||
// Success
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("doneChan should be closed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/nvm/kportal/internal/events"
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/events"
|
||||
)
|
||||
|
||||
// bufferPool is a sync.Pool for reusing buffers in data transfer health checks.
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
package httplog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// BenchmarkLoggerLog benchmarks the Log function with sync.Pool
|
||||
func BenchmarkLoggerLog(b *testing.B) {
|
||||
l := &Logger{
|
||||
forwardID: "benchmark",
|
||||
maxBodyLen: 1024,
|
||||
output: io.Discard,
|
||||
}
|
||||
|
||||
entry := Entry{
|
||||
Direction: "request",
|
||||
RequestID: "req-123",
|
||||
Method: "POST",
|
||||
Path: "/api/users",
|
||||
BodySize: 256,
|
||||
Body: `{"name":"test user","email":"test@example.com","data":"some payload data here"}`,
|
||||
StatusCode: 200,
|
||||
LatencyMs: 42,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = l.Log(entry)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkLoggerLogNoPool simulates logging without sync.Pool
|
||||
func BenchmarkLoggerLogNoPool(b *testing.B) {
|
||||
l := &Logger{
|
||||
forwardID: "benchmark",
|
||||
maxBodyLen: 1024,
|
||||
output: io.Discard,
|
||||
}
|
||||
|
||||
entry := Entry{
|
||||
Direction: "request",
|
||||
RequestID: "req-123",
|
||||
Method: "POST",
|
||||
Path: "/api/users",
|
||||
BodySize: 256,
|
||||
Body: `{"name":"test user","email":"test@example.com","data":"some payload data here"}`,
|
||||
StatusCode: 200,
|
||||
LatencyMs: 42,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Simulate old behavior: allocate new buffer each time
|
||||
data, _ := json.Marshal(entry)
|
||||
_, _ = l.output.Write(append(data, '\n'))
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkReadBodyLimited benchmarks reading body with sync.Pool
|
||||
func BenchmarkReadBodyLimited(b *testing.B) {
|
||||
bodyData := bytes.Repeat([]byte("a"), 1024)
|
||||
transport := &loggingTransport{}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Create a new ReadCloser for each iteration
|
||||
body := io.NopCloser(bytes.NewReader(bodyData))
|
||||
_, _ = transport.readBodyLimited(body, 2048)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkReadBodyLimitedSmall benchmarks with small bodies (typical API requests)
|
||||
func BenchmarkReadBodyLimitedSmall(b *testing.B) {
|
||||
bodyData := []byte(`{"id":123,"name":"test","active":true}`)
|
||||
transport := &loggingTransport{}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
body := io.NopCloser(bytes.NewReader(bodyData))
|
||||
_, _ = transport.readBodyLimited(body, 1024)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkReadBodyLimitedLarge benchmarks with large bodies
|
||||
func BenchmarkReadBodyLimitedLarge(b *testing.B) {
|
||||
bodyData := bytes.Repeat([]byte("x"), 65536) // 64KB
|
||||
transport := &loggingTransport{}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
body := io.NopCloser(bytes.NewReader(bodyData))
|
||||
_, _ = transport.readBodyLimited(body, 65536)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkBufferPoolGetPut benchmarks the buffer pool itself
|
||||
func BenchmarkBufferPoolGetPut(b *testing.B) {
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
bufPtr := bufferPool.Get().(*[]byte)
|
||||
// Reset and use the buffer to simulate real usage
|
||||
*bufPtr = (*bufPtr)[:0]
|
||||
*bufPtr = append(*bufPtr, "test data..."...)
|
||||
bufferPool.Put(bufPtr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkLogBufferPoolGetPut benchmarks the log buffer pool
|
||||
func BenchmarkLogBufferPoolGetPut(b *testing.B) {
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
buf := logBufferPool.Get().(*bytes.Buffer)
|
||||
buf.Reset()
|
||||
buf.WriteString("test log entry")
|
||||
logBufferPool.Put(buf)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkFlattenHeaders benchmarks header flattening with pooling
|
||||
func BenchmarkFlattenHeaders(b *testing.B) {
|
||||
headers := http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"Accept": []string{"text/html", "application/json"},
|
||||
"User-Agent": []string{"test-client/1.0"},
|
||||
"X-Request-ID": []string{"abc-123-def"},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = flattenHeaders(headers)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkTruncateBody benchmarks body truncation with pooled buffers
|
||||
func BenchmarkTruncateBody(b *testing.B) {
|
||||
body := "this is a very long body that should be truncated for logging purposes"
|
||||
maxLen := 20
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = truncateBody(body, maxLen)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkTruncateBodyNoPool simulates truncation without pooling
|
||||
func BenchmarkTruncateBodyNoPool(b *testing.B) {
|
||||
body := "this is a very long body that should be truncated for logging purposes"
|
||||
maxLen := 20
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if len(body) > maxLen {
|
||||
_ = body[:maxLen] + "...(truncated)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkLoggerLogWithTruncation benchmarks logging with body truncation
|
||||
func BenchmarkLoggerLogWithTruncation(b *testing.B) {
|
||||
l := &Logger{
|
||||
forwardID: "benchmark",
|
||||
maxBodyLen: 50,
|
||||
output: io.Discard,
|
||||
}
|
||||
|
||||
entry := Entry{
|
||||
Direction: "request",
|
||||
RequestID: "req-123",
|
||||
Method: "POST",
|
||||
Path: "/api/users",
|
||||
Body: `{"name":"test user","email":"test@example.com","data":"some payload data here for truncation"}`,
|
||||
BodySize: 100,
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = l.Log(entry)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkReadBufferPool benchmarks the read buffer pool
|
||||
func BenchmarkReadBufferPool(b *testing.B) {
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
bufPtr := readBufferPool.Get().(*[]byte)
|
||||
buf := *bufPtr
|
||||
_ = len(buf) // Use the buffer
|
||||
readBufferPool.Put(bufPtr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkReadBodyLimitedParallel benchmarks body reading under concurrent load
|
||||
func BenchmarkReadBodyLimitedParallel(b *testing.B) {
|
||||
bodyData := bytes.Repeat([]byte("x"), 4096)
|
||||
transport := &loggingTransport{}
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
body := io.NopCloser(bytes.NewReader(bodyData))
|
||||
_, _ = transport.readBodyLimited(body, 8192)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkLoggerLogParallel benchmarks logging under concurrent load
|
||||
func BenchmarkLoggerLogParallel(b *testing.B) {
|
||||
l := &Logger{
|
||||
forwardID: "benchmark",
|
||||
maxBodyLen: 1024,
|
||||
output: io.Discard,
|
||||
}
|
||||
|
||||
entry := Entry{
|
||||
Direction: "request",
|
||||
RequestID: "req-123",
|
||||
Method: "POST",
|
||||
Path: "/api/users",
|
||||
Body: `{"name":"test user"}`,
|
||||
BodySize: 100,
|
||||
}
|
||||
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
_ = l.Log(entry)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkCompleteFlow benchmarks the complete logging flow
|
||||
func BenchmarkCompleteFlow(b *testing.B) {
|
||||
l := &Logger{
|
||||
forwardID: "benchmark",
|
||||
maxBodyLen: 1024,
|
||||
output: io.Discard,
|
||||
}
|
||||
|
||||
headers := http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"Accept": []string{"application/json"},
|
||||
}
|
||||
|
||||
bodyData := []byte(`{"id":123,"name":"test"}`)
|
||||
transport := &loggingTransport{}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Simulate full request logging flow
|
||||
entry := Entry{
|
||||
Direction: "request",
|
||||
RequestID: "req-123",
|
||||
Method: "POST",
|
||||
Path: "/api/users",
|
||||
Headers: flattenHeaders(headers),
|
||||
BodySize: len(bodyData),
|
||||
Body: string(bodyData),
|
||||
}
|
||||
_ = l.Log(entry)
|
||||
|
||||
// Simulate body reading
|
||||
body := io.NopCloser(bytes.NewReader(bodyData))
|
||||
_, _ = transport.readBodyLimited(body, 2048)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
package httplog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
@@ -20,6 +21,14 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// logBufferPool is used to reuse byte buffers for JSON encoding.
|
||||
// This reduces allocations when serializing log entries.
|
||||
var logBufferPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
return bytes.NewBuffer(make([]byte, 0, 4096))
|
||||
},
|
||||
}
|
||||
|
||||
// Entry represents a single HTTP log entry
|
||||
type Entry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
@@ -89,18 +98,50 @@ func (l *Logger) ClearCallbacks() {
|
||||
l.callbacks = nil
|
||||
}
|
||||
|
||||
// Log writes a log entry as JSON
|
||||
// stringBuilderPool provides reusable string builders for body truncation.
|
||||
// This reduces allocations when building truncated body strings.
|
||||
var stringBuilderPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
return &bytes.Buffer{}
|
||||
},
|
||||
}
|
||||
|
||||
// truncateBody truncates a body string to maxLen, adding a suffix if truncated.
|
||||
// Uses a pooled buffer to avoid allocations during truncation.
|
||||
func truncateBody(body string, maxLen int) string {
|
||||
if len(body) <= maxLen {
|
||||
return body
|
||||
}
|
||||
|
||||
// Use pooled buffer for truncation
|
||||
buf := stringBuilderPool.Get().(*bytes.Buffer)
|
||||
buf.Reset()
|
||||
defer stringBuilderPool.Put(buf)
|
||||
|
||||
// Write truncated content
|
||||
buf.WriteString(body[:maxLen])
|
||||
buf.WriteString("...(truncated)")
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// Log writes a log entry as JSON using a pooled buffer to reduce allocations.
|
||||
func (l *Logger) Log(entry Entry) error {
|
||||
entry.ForwardID = l.forwardID
|
||||
entry.Timestamp = time.Now()
|
||||
|
||||
// Truncate body if too large
|
||||
// Truncate body if too large using pooled buffer
|
||||
if len(entry.Body) > l.maxBodyLen {
|
||||
entry.Body = entry.Body[:l.maxBodyLen] + "...(truncated)"
|
||||
entry.Body = truncateBody(entry.Body, l.maxBodyLen)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
// Get a buffer from the pool
|
||||
buf := logBufferPool.Get().(*bytes.Buffer)
|
||||
buf.Reset() // Clear any previous content
|
||||
defer logBufferPool.Put(buf)
|
||||
|
||||
// Encode JSON directly into the pooled buffer
|
||||
encoder := json.NewEncoder(buf)
|
||||
if err := encoder.Encode(entry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -112,7 +153,7 @@ func (l *Logger) Log(entry Entry) error {
|
||||
cb(entry)
|
||||
}
|
||||
|
||||
_, err = l.output.Write(append(data, '\n'))
|
||||
_, err := l.output.Write(buf.Bytes())
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,29 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/nvm/kportal/internal/logger"
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/logger"
|
||||
)
|
||||
|
||||
// bufferPool is used to reuse byte buffers for body reading.
|
||||
// This significantly reduces GC pressure under high load.
|
||||
// Using *([]byte) to avoid allocations when storing/retrieving from pool (SA6002).
|
||||
var bufferPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
buf := make([]byte, 0, 8192) // Start with 8KB capacity
|
||||
return &buf
|
||||
},
|
||||
}
|
||||
|
||||
// readBufferPool provides fixed-size buffers for io.Reader operations.
|
||||
// Using a pool eliminates per-read allocations of temporary buffers.
|
||||
var readBufferPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
buf := make([]byte, 4096) // 4KB fixed-size read buffer
|
||||
return &buf
|
||||
},
|
||||
}
|
||||
|
||||
// Proxy is an HTTP reverse proxy with logging capabilities
|
||||
type Proxy struct {
|
||||
listener net.Listener
|
||||
@@ -218,27 +237,73 @@ func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error)
|
||||
// Returns the body content (up to maxSize bytes) and the actual content length.
|
||||
// If the body exceeds maxSize, it reads only maxSize bytes for logging but
|
||||
// consumes the entire body to get the true size for BodySize reporting.
|
||||
// Uses sync.Pool to reuse buffers and reduce allocations.
|
||||
func (t *loggingTransport) readBodyLimited(body io.ReadCloser, maxSize int) ([]byte, int) {
|
||||
// Get a buffer from the pool for accumulating body content
|
||||
bufPtr := bufferPool.Get().(*[]byte)
|
||||
buf := *bufPtr
|
||||
buf = buf[:0] // Reset length but keep capacity
|
||||
defer bufferPool.Put(bufPtr)
|
||||
|
||||
// Get a pooled read buffer to eliminate per-read allocation
|
||||
tmpPtr := readBufferPool.Get().(*[]byte)
|
||||
tmp := *tmpPtr
|
||||
defer readBufferPool.Put(tmpPtr)
|
||||
|
||||
// Read up to maxSize+1 to detect if there's more
|
||||
limitedReader := io.LimitReader(body, int64(maxSize+1))
|
||||
data, err := io.ReadAll(limitedReader)
|
||||
if err != nil {
|
||||
return nil, 0
|
||||
|
||||
// Read into the pooled buffer
|
||||
var totalRead int
|
||||
for {
|
||||
n, err := limitedReader.Read(tmp)
|
||||
if n > 0 {
|
||||
buf = append(buf, tmp[:n]...)
|
||||
totalRead += n
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
actualSize := len(data)
|
||||
actualSize := len(buf)
|
||||
wasTruncated := actualSize > maxSize
|
||||
|
||||
// If we read exactly maxSize+1, there might be more data
|
||||
// Discard the rest but count the bytes for accurate BodySize
|
||||
if wasTruncated {
|
||||
data = data[:maxSize] // Keep only maxSize bytes for logging
|
||||
// Count remaining bytes without storing them
|
||||
remaining, _ := io.Copy(io.Discard, body)
|
||||
actualSize = maxSize + int(remaining)
|
||||
// Return a copy of just the maxSize bytes for logging
|
||||
resultPtr := bufferPool.Get().(*[]byte)
|
||||
result := *resultPtr
|
||||
result = result[:maxSize]
|
||||
copy(result, buf)
|
||||
return result, actualSize
|
||||
}
|
||||
|
||||
return data, actualSize
|
||||
// For small results, allocate minimally. For larger results, use pooled buffer.
|
||||
resultLen := len(buf)
|
||||
var result []byte
|
||||
if resultLen <= 4096 {
|
||||
// Small body: allocate exact size to avoid holding large buffers
|
||||
result = make([]byte, resultLen)
|
||||
copy(result, buf)
|
||||
} else {
|
||||
// Larger body: try to use pooled buffer
|
||||
resultPtr := bufferPool.Get().(*[]byte)
|
||||
result = *resultPtr
|
||||
if cap(result) >= resultLen {
|
||||
result = result[:resultLen]
|
||||
copy(result, buf)
|
||||
} else {
|
||||
// Pooled buffer too small, allocate new and don't return to pool
|
||||
result = make([]byte, resultLen)
|
||||
copy(result, buf)
|
||||
}
|
||||
}
|
||||
return result, actualSize
|
||||
}
|
||||
|
||||
// shouldLog checks if the request path matches the filter
|
||||
@@ -274,7 +339,8 @@ func (p *Proxy) logError(req *http.Request, err error) {
|
||||
_ = p.logger.Log(entry)
|
||||
}
|
||||
|
||||
// flattenHeaders converts http.Header to map[string]string
|
||||
// flattenHeaders converts http.Header to map[string]string.
|
||||
// Pre-allocates the map with the exact size needed to avoid reallocations.
|
||||
func flattenHeaders(h http.Header) map[string]string {
|
||||
result := make(map[string]string, len(h))
|
||||
for k, v := range h {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
+16
-4
@@ -24,7 +24,7 @@ import (
|
||||
// ClientPool manages Kubernetes clients per context with thread-safe access.
|
||||
type ClientPool struct {
|
||||
loader clientcmd.ClientConfig
|
||||
clients map[string]*kubernetes.Clientset
|
||||
clients map[string]kubernetes.Interface
|
||||
configs map[string]*rest.Config
|
||||
mu sync.RWMutex
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func NewClientPool() (*ClientPool, error) {
|
||||
loader := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides)
|
||||
|
||||
return &ClientPool{
|
||||
clients: make(map[string]*kubernetes.Clientset),
|
||||
clients: make(map[string]kubernetes.Interface),
|
||||
configs: make(map[string]*rest.Config),
|
||||
loader: loader,
|
||||
}, nil
|
||||
@@ -47,7 +47,7 @@ func NewClientPool() (*ClientPool, error) {
|
||||
// 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) {
|
||||
func (p *ClientPool) GetClient(contextName string) (kubernetes.Interface, error) {
|
||||
// Try to get cached client (read lock)
|
||||
p.mu.RLock()
|
||||
client, exists := p.clients[contextName]
|
||||
@@ -183,7 +183,7 @@ func (p *ClientPool) ClearCache() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.clients = make(map[string]*kubernetes.Clientset)
|
||||
p.clients = make(map[string]kubernetes.Interface)
|
||||
p.configs = make(map[string]*rest.Config)
|
||||
}
|
||||
|
||||
@@ -216,3 +216,15 @@ func (p *ClientPool) GetNamespace(contextName string) (string, error) {
|
||||
|
||||
return context.Namespace, nil
|
||||
}
|
||||
|
||||
// setTestClient is a test helper that injects a fake client for a context.
|
||||
// This is only used in tests to enable testing without real kubeconfig.
|
||||
func (p *ClientPool) setTestClient(contextName string, client kubernetes.Interface) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.clients == nil {
|
||||
p.clients = make(map[string]kubernetes.Interface)
|
||||
}
|
||||
p.clients[contextName] = client
|
||||
}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// ClientPool Extended Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestClientPool_GetClient_Caching(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-pod",
|
||||
Namespace: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// First call - should create and cache
|
||||
client1, err := pool.GetClient("test-context")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, client1)
|
||||
|
||||
// Second call - should return cached
|
||||
client2, err := pool.GetClient("test-context")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, client1, client2)
|
||||
}
|
||||
|
||||
func TestClientPool_GetRestConfig_Caching(t *testing.T) {
|
||||
// This test would require actual kubeconfig context
|
||||
// Skip it for unit testing - covered by integration tests
|
||||
t.Skip("Requires actual kubeconfig context - skipping in unit tests")
|
||||
}
|
||||
|
||||
func TestClientPool_ClearCache_ThreadSafe(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context")
|
||||
|
||||
// Populate client cache
|
||||
_, err := pool.GetClient("test-context")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Manually populate configs for testing
|
||||
pool.mu.Lock()
|
||||
pool.configs["test-context"] = nil
|
||||
pool.mu.Unlock()
|
||||
|
||||
// Clear cache multiple times concurrently
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
pool.ClearCache()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Verify cache is empty
|
||||
pool.mu.RLock()
|
||||
assert.Empty(t, pool.clients)
|
||||
assert.Empty(t, pool.configs)
|
||||
pool.mu.RUnlock()
|
||||
}
|
||||
|
||||
func TestClientPool_RemoveContext_ThreadSafe(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context")
|
||||
|
||||
// Populate cache
|
||||
_, err := pool.GetClient("test-context")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Remove from multiple goroutines
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
pool.RemoveContext("test-context")
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Verify removed
|
||||
pool.mu.RLock()
|
||||
_, exists := pool.clients["test-context"]
|
||||
pool.mu.RUnlock()
|
||||
assert.False(t, exists)
|
||||
}
|
||||
|
||||
func TestClientPool_ConcurrentGetClient(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Concurrent reads
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = pool.GetClient("test-context")
|
||||
}()
|
||||
}
|
||||
|
||||
// Concurrent config reads
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = pool.GetRestConfig("test-context")
|
||||
}()
|
||||
}
|
||||
|
||||
// Concurrent cache operations
|
||||
for i := 0; i < 3; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
pool.ClearCache()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// If we got here without panic/deadlock, the test passed
|
||||
assert.NotNil(t, pool)
|
||||
}
|
||||
|
||||
func TestClientPool_GetClient_MultipleContexts(t *testing.T) {
|
||||
fakeClient1 := fake.NewClientset()
|
||||
fakeClient2 := fake.NewClientset()
|
||||
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
|
||||
pool.setTestClient("context-1", fakeClient1)
|
||||
pool.setTestClient("context-2", fakeClient2)
|
||||
|
||||
client1, err := pool.GetClient("context-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fakeClient1, client1)
|
||||
|
||||
client2, err := pool.GetClient("context-2")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, fakeClient2, client2)
|
||||
|
||||
// Verify they are different
|
||||
assert.NotEqual(t, client1, client2)
|
||||
}
|
||||
|
||||
func TestClientPool_GetRestConfig_MultipleContexts(t *testing.T) {
|
||||
// This test would require actual kubeconfig contexts
|
||||
// Skip it for unit testing - covered by integration tests
|
||||
t.Skip("Requires actual kubeconfig contexts - skipping in unit tests")
|
||||
}
|
||||
|
||||
func TestClientPool_RemoveContext_Specific(t *testing.T) {
|
||||
pool := setupTestPool(t, "context-1")
|
||||
pool.setTestClient("context-2", fake.NewClientset())
|
||||
|
||||
// Populate both caches
|
||||
_, err := pool.GetClient("context-1")
|
||||
require.NoError(t, err)
|
||||
_, err = pool.GetClient("context-2")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Remove only context-1
|
||||
pool.RemoveContext("context-1")
|
||||
|
||||
// Verify context-1 removed but context-2 still there
|
||||
pool.mu.RLock()
|
||||
_, exists1 := pool.clients["context-1"]
|
||||
_, exists2 := pool.clients["context-2"]
|
||||
pool.mu.RUnlock()
|
||||
|
||||
assert.False(t, exists1)
|
||||
assert.True(t, exists2)
|
||||
}
|
||||
|
||||
func TestClientPool_setTestClient_NilMap(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Clear the map manually to simulate nil case
|
||||
pool.mu.Lock()
|
||||
pool.clients = nil
|
||||
pool.mu.Unlock()
|
||||
|
||||
// Should handle nil map
|
||||
pool.setTestClient("test-context", fake.NewClientset())
|
||||
|
||||
// Verify it was set
|
||||
pool.mu.RLock()
|
||||
_, exists := pool.clients["test-context"]
|
||||
pool.mu.RUnlock()
|
||||
assert.True(t, exists)
|
||||
}
|
||||
|
||||
func TestClientPool_GetNamespace_WithTestClient(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context")
|
||||
|
||||
// The GetNamespace method uses the loader to get namespace from kubeconfig context
|
||||
// Since we're using test client, this may fail depending on kubeconfig
|
||||
_, err := pool.GetNamespace("test-context")
|
||||
// May succeed or fail depending on environment
|
||||
// Just verify it doesn't panic
|
||||
_ = err
|
||||
}
|
||||
|
||||
func TestClientPool_GetClient_NotFound(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to get client for non-existent context without setting test client
|
||||
_, err = pool.GetClient("non-existent-context")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found in kubeconfig")
|
||||
}
|
||||
|
||||
func TestClientPool_GetRestConfig_NotFound(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to get rest config for non-existent context
|
||||
_, err = pool.GetRestConfig("non-existent-context")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found in kubeconfig")
|
||||
}
|
||||
|
||||
func TestClientPool_DoubleCheckCache(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context")
|
||||
|
||||
// Simulate race where two goroutines try to get the same client
|
||||
// One creates it, the other should get cached version
|
||||
|
||||
var client1, client2 interface{}
|
||||
var err1, err2 error
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
client1, err1 = pool.GetClient("test-context")
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
client2, err2 = pool.GetClient("test-context")
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
require.NoError(t, err1)
|
||||
require.NoError(t, err2)
|
||||
assert.Equal(t, client1, client2)
|
||||
}
|
||||
|
||||
func TestClientPool_DoubleCheckRestConfig(t *testing.T) {
|
||||
// This test would require actual kubeconfig context
|
||||
// Skip it for unit testing - covered by integration tests
|
||||
t.Skip("Requires actual kubeconfig context - skipping in unit tests")
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Test Helpers
|
||||
// =============================================================================
|
||||
|
||||
func setupTestPool(t *testing.T, contextName string, objects ...runtime.Object) *ClientPool {
|
||||
t.Helper()
|
||||
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
|
||||
fakeClient := fake.NewClientset(objects...)
|
||||
// Type assertion to convert fake client to *kubernetes.Clientset
|
||||
// Note: This works because fake.Clientset embeds *kubernetes.Clientset
|
||||
pool.setTestClient(contextName, fakeClient)
|
||||
|
||||
return pool
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Discovery API Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestDiscovery_ListNamespaces_WithClient(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "default"},
|
||||
},
|
||||
&corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "kube-system"},
|
||||
},
|
||||
&corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "production"},
|
||||
},
|
||||
)
|
||||
|
||||
d := NewDiscovery(pool)
|
||||
|
||||
namespaces, err := d.ListNamespaces(t.Context(), "test-context")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, namespaces, 3)
|
||||
assert.Contains(t, namespaces, "default")
|
||||
assert.Contains(t, namespaces, "kube-system")
|
||||
assert.Contains(t, namespaces, "production")
|
||||
}
|
||||
|
||||
func TestDiscovery_ListNamespaces_Error(t *testing.T) {
|
||||
// Pool without test client - should fail
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
|
||||
d := NewDiscovery(pool)
|
||||
|
||||
_, err = d.ListNamespaces(t.Context(), "non-existent-context")
|
||||
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDiscovery_ListPods_WithClient(t *testing.T) {
|
||||
baseTime := time.Now()
|
||||
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "running-pod",
|
||||
Namespace: "default",
|
||||
CreationTimestamp: metav1.Time{Time: baseTime},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "main",
|
||||
Ports: []corev1.ContainerPort{
|
||||
{Name: "http", ContainerPort: 8080},
|
||||
{Name: "metrics", ContainerPort: 9090},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pending-pod",
|
||||
Namespace: "default",
|
||||
CreationTimestamp: metav1.Time{Time: baseTime.Add(-time.Hour)},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodPending},
|
||||
},
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "succeeded-pod",
|
||||
Namespace: "default",
|
||||
CreationTimestamp: metav1.Time{Time: baseTime},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodSucceeded},
|
||||
},
|
||||
)
|
||||
|
||||
d := NewDiscovery(pool)
|
||||
|
||||
pods, err := d.ListPods(t.Context(), "test-context", "default")
|
||||
|
||||
require.NoError(t, err)
|
||||
// Only Running and Pending pods
|
||||
assert.Len(t, pods, 2)
|
||||
|
||||
// Should be sorted by creation time (newest first)
|
||||
assert.Equal(t, "running-pod", pods[0].Name)
|
||||
assert.Equal(t, "pending-pod", pods[1].Name)
|
||||
|
||||
// Check container info
|
||||
assert.Len(t, pods[0].Containers, 1)
|
||||
assert.Len(t, pods[0].Containers[0].Ports, 2)
|
||||
assert.Equal(t, "http", pods[0].Containers[0].Ports[0].Name)
|
||||
assert.Equal(t, int32(8080), pods[0].Containers[0].Ports[0].Port)
|
||||
}
|
||||
|
||||
func TestDiscovery_ListPods_EmptyNamespace(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context")
|
||||
|
||||
d := NewDiscovery(pool)
|
||||
|
||||
pods, err := d.ListPods(t.Context(), "test-context", "default")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, pods)
|
||||
}
|
||||
|
||||
func TestDiscovery_ListPodsWithSelector_WithClient(t *testing.T) {
|
||||
baseTime := time.Now()
|
||||
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "app-pod-1",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "myapp"},
|
||||
CreationTimestamp: metav1.Time{Time: baseTime},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "app-pod-2",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "myapp"},
|
||||
CreationTimestamp: metav1.Time{Time: baseTime.Add(-time.Hour)},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "other-pod",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "other"},
|
||||
CreationTimestamp: metav1.Time{Time: baseTime},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
)
|
||||
|
||||
d := NewDiscovery(pool)
|
||||
|
||||
pods, err := d.ListPodsWithSelector(t.Context(), "test-context", "default", "app=myapp")
|
||||
|
||||
require.NoError(t, err)
|
||||
// Only Running pods with matching selector
|
||||
assert.Len(t, pods, 2)
|
||||
|
||||
names := []string{pods[0].Name, pods[1].Name}
|
||||
assert.Contains(t, names, "app-pod-1")
|
||||
assert.Contains(t, names, "app-pod-2")
|
||||
}
|
||||
|
||||
func TestDiscovery_ListPodsWithSelector_EmptySelector(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context")
|
||||
|
||||
d := NewDiscovery(pool)
|
||||
|
||||
_, err := d.ListPodsWithSelector(t.Context(), "test-context", "default", "")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "selector cannot be empty")
|
||||
}
|
||||
|
||||
func TestDiscovery_ListPodsWithSelector_NoRunningPods(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pending-pod",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "myapp"},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodPending},
|
||||
},
|
||||
)
|
||||
|
||||
d := NewDiscovery(pool)
|
||||
|
||||
pods, err := d.ListPodsWithSelector(t.Context(), "test-context", "default", "app=myapp")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, pods)
|
||||
}
|
||||
|
||||
func TestDiscovery_ListServices_WithClient(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "web-pod",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "web"},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "main",
|
||||
Ports: []corev1.ContainerPort{
|
||||
{Name: "http", ContainerPort: 8080},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
&corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "web-svc",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
Selector: map[string]string{"app": "web"},
|
||||
Ports: []corev1.ServicePort{
|
||||
{Name: "http", Port: 80, TargetPort: intstr.FromString("http")},
|
||||
},
|
||||
},
|
||||
},
|
||||
&corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "api-svc",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
Selector: map[string]string{"app": "api"},
|
||||
Ports: []corev1.ServicePort{
|
||||
{Port: 8080, TargetPort: intstr.FromInt(8080)},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
d := NewDiscovery(pool)
|
||||
|
||||
services, err := d.ListServices(t.Context(), "test-context", "default")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, services, 2)
|
||||
|
||||
// Should be sorted alphabetically
|
||||
assert.Equal(t, "api-svc", services[0].Name)
|
||||
assert.Equal(t, "web-svc", services[1].Name)
|
||||
|
||||
// Check port resolution for named port
|
||||
assert.Len(t, services[1].Ports, 1)
|
||||
assert.Equal(t, int32(8080), services[1].Ports[0].TargetPort) // Resolved from pod
|
||||
}
|
||||
|
||||
func TestDiscovery_ListServices_Empty(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context")
|
||||
|
||||
d := NewDiscovery(pool)
|
||||
|
||||
services, err := d.ListServices(t.Context(), "test-context", "default")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, services)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ResourceResolver API Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestResourceResolver_ResolvePodPrefix_WithClient(t *testing.T) {
|
||||
baseTime := time.Now()
|
||||
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "my-app-xyz789",
|
||||
Namespace: "default",
|
||||
CreationTimestamp: metav1.Time{Time: baseTime},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "my-app-abc123",
|
||||
Namespace: "default",
|
||||
CreationTimestamp: metav1.Time{Time: baseTime.Add(-time.Hour)},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "other-app",
|
||||
Namespace: "default",
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
result, err := r.Resolve(t.Context(), "test-context", "default", "pod/my-app", "")
|
||||
|
||||
require.NoError(t, err)
|
||||
// Should return newest pod matching prefix
|
||||
assert.Equal(t, "pod/my-app-xyz789", result)
|
||||
}
|
||||
|
||||
func TestResourceResolver_ResolvePodPrefix_NotFound(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "other-app",
|
||||
Namespace: "default",
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
_, err := r.Resolve(t.Context(), "test-context", "default", "pod/my-app", "")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no running pods found matching prefix")
|
||||
}
|
||||
|
||||
func TestResourceResolver_ResolvePodSelector_WithClient(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "app-pod",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "myapp"},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "other-pod",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "other"},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
result, err := r.Resolve(t.Context(), "test-context", "default", "pod", "app=myapp")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "pod/app-pod", result)
|
||||
}
|
||||
|
||||
func TestResourceResolver_ResolvePodSelector_NotFound(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "other-pod",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "other"},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
_, err := r.Resolve(t.Context(), "test-context", "default", "pod", "app=myapp")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no running pods found matching selector")
|
||||
}
|
||||
|
||||
func TestResourceResolver_Resolve_Caching(t *testing.T) {
|
||||
baseTime := time.Now()
|
||||
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "my-app-xyz789",
|
||||
Namespace: "default",
|
||||
CreationTimestamp: metav1.Time{Time: baseTime},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
r.SetCacheTTL(100 * time.Millisecond)
|
||||
|
||||
// First call - hits API
|
||||
result1, err := r.Resolve(t.Context(), "test-context", "default", "pod/my-app", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Second call - uses cache
|
||||
result2, err := r.Resolve(t.Context(), "test-context", "default", "pod/my-app", "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, result1, result2)
|
||||
|
||||
// Wait for expiry
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// Third call - hits API again
|
||||
result3, err := r.Resolve(t.Context(), "test-context", "default", "pod/my-app", "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, result1, result3)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PortForwarder API Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestPortForwarder_GetPodForResource_Pod(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "my-pod",
|
||||
Namespace: "default",
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
podName, err := pf.GetPodForResource(t.Context(), "test-context", "default", "pod/my-pod", "")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "my-pod", podName)
|
||||
}
|
||||
|
||||
func TestPortForwarder_GetPodForResource_Service(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "backend-pod",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "backend"},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
&corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "backend-svc",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{"app": "backend"},
|
||||
Ports: []corev1.ServicePort{
|
||||
{Port: 80, TargetPort: intstr.FromInt(8080)},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
podName, err := pf.GetPodForResource(t.Context(), "test-context", "default", "service/backend-svc", "")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "backend-pod", podName)
|
||||
}
|
||||
|
||||
func TestPortForwarder_GetPodForResource_ServiceNoSelector(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "headless-svc",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
// No selector
|
||||
Ports: []corev1.ServicePort{
|
||||
{Port: 80, TargetPort: intstr.FromInt(8080)},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
_, err := pf.GetPodForResource(t.Context(), "test-context", "default", "service/headless-svc", "")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no selector")
|
||||
}
|
||||
|
||||
func TestPortForwarder_GetPodForResource_ServiceNoRunningPods(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pending-pod",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "backend"},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodPending},
|
||||
},
|
||||
&corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "backend-svc",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{"app": "backend"},
|
||||
Ports: []corev1.ServicePort{
|
||||
{Port: 80, TargetPort: intstr.FromInt(8080)},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
_, err := pf.GetPodForResource(t.Context(), "test-context", "default", "service/backend-svc", "")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no running pods found")
|
||||
}
|
||||
|
||||
func TestPortForwarder_Forward_ServiceResolution(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "backend-pod",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "backend"},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
&corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "backend-svc",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{"app": "backend"},
|
||||
Ports: []corev1.ServicePort{
|
||||
{Port: 80, TargetPort: intstr.FromInt(8080)},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
// Test that service resolution works (Forward will fail on actual port-forward,
|
||||
// but we can test the resolution part)
|
||||
stopChan := make(chan struct{})
|
||||
req := &ForwardRequest{
|
||||
StopChan: stopChan,
|
||||
ContextName: "test-context",
|
||||
Namespace: "default",
|
||||
Resource: "service/backend-svc",
|
||||
LocalPort: 8080,
|
||||
RemotePort: 80,
|
||||
}
|
||||
|
||||
err := pf.Forward(t.Context(), req)
|
||||
|
||||
// Will fail on port-forward setup, but should have resolved the service
|
||||
assert.Error(t, err)
|
||||
// Error should not be about resource resolution
|
||||
assert.NotContains(t, err.Error(), "failed to resolve resource")
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// ForwardRequest Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestForwardRequest_Fields(t *testing.T) {
|
||||
stopChan := make(chan struct{})
|
||||
readyChan := make(chan struct{})
|
||||
outWriter := &mockWriter{}
|
||||
errWriter := &mockWriter{}
|
||||
|
||||
req := &ForwardRequest{
|
||||
Out: outWriter,
|
||||
ErrOut: errWriter,
|
||||
StopChan: stopChan,
|
||||
ReadyChan: readyChan,
|
||||
ContextName: "test-context",
|
||||
Namespace: "test-namespace",
|
||||
Resource: "pod/test-pod",
|
||||
Selector: "app=test",
|
||||
LocalPort: 8080,
|
||||
RemotePort: 80,
|
||||
}
|
||||
|
||||
assert.Equal(t, outWriter, req.Out)
|
||||
assert.Equal(t, errWriter, req.ErrOut)
|
||||
assert.Equal(t, stopChan, req.StopChan)
|
||||
assert.Equal(t, readyChan, req.ReadyChan)
|
||||
assert.Equal(t, "test-context", req.ContextName)
|
||||
assert.Equal(t, "test-namespace", req.Namespace)
|
||||
assert.Equal(t, "pod/test-pod", req.Resource)
|
||||
assert.Equal(t, "app=test", req.Selector)
|
||||
assert.Equal(t, 8080, req.LocalPort)
|
||||
assert.Equal(t, 80, req.RemotePort)
|
||||
}
|
||||
|
||||
func TestForwardRequest_NilWriters(t *testing.T) {
|
||||
stopChan := make(chan struct{})
|
||||
readyChan := make(chan struct{})
|
||||
|
||||
req := &ForwardRequest{
|
||||
Out: nil,
|
||||
ErrOut: nil,
|
||||
StopChan: stopChan,
|
||||
ReadyChan: readyChan,
|
||||
ContextName: "test-context",
|
||||
Namespace: "default",
|
||||
Resource: "pod/test-pod",
|
||||
LocalPort: 8080,
|
||||
RemotePort: 80,
|
||||
}
|
||||
|
||||
// nil writers should be acceptable
|
||||
assert.Nil(t, req.Out)
|
||||
assert.Nil(t, req.ErrOut)
|
||||
}
|
||||
|
||||
// mockWriter is a test double for io.Writer
|
||||
type mockWriter struct {
|
||||
written []byte
|
||||
}
|
||||
|
||||
func (m *mockWriter) Write(p []byte) (n int, err error) {
|
||||
m.written = append(m.written, p...)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PortForwarder Extended Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestPortForwarder_ForwardRequestValidation(t *testing.T) {
|
||||
pool, _ := NewClientPool()
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
resource string
|
||||
errContains string
|
||||
expectedErr bool
|
||||
}{
|
||||
{
|
||||
name: "invalid resource format - no slash",
|
||||
resource: "invalid",
|
||||
expectedErr: true,
|
||||
errContains: "unsupported resource type",
|
||||
},
|
||||
{
|
||||
name: "unsupported resource type",
|
||||
resource: "deployment/my-deployment",
|
||||
expectedErr: true,
|
||||
errContains: "unsupported resource type",
|
||||
},
|
||||
{
|
||||
name: "empty resource",
|
||||
resource: "",
|
||||
expectedErr: true,
|
||||
errContains: "unsupported resource type",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
stopChan := make(chan struct{})
|
||||
req := &ForwardRequest{
|
||||
StopChan: stopChan,
|
||||
ContextName: "test-context",
|
||||
Namespace: "default",
|
||||
Resource: tt.resource,
|
||||
LocalPort: 8080,
|
||||
RemotePort: 80,
|
||||
}
|
||||
|
||||
err := pf.Forward(ctx, req)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Discovery Method Tests (with fake client integration)
|
||||
// =============================================================================
|
||||
|
||||
func TestDiscovery_ListNamespaces_WithFakeClient(t *testing.T) {
|
||||
objects := []runtime.Object{
|
||||
createTestNamespace("default"),
|
||||
createTestNamespace("kube-system"),
|
||||
createTestNamespace("production"),
|
||||
}
|
||||
|
||||
fakeClient := fake.NewClientset(objects...)
|
||||
|
||||
ctx := t.Context()
|
||||
nsList, err := fakeClient.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
namespaces := make([]string, 0, len(nsList.Items))
|
||||
for _, ns := range nsList.Items {
|
||||
namespaces = append(namespaces, ns.Name)
|
||||
}
|
||||
|
||||
assert.Len(t, namespaces, 3)
|
||||
assert.Contains(t, namespaces, "default")
|
||||
assert.Contains(t, namespaces, "kube-system")
|
||||
assert.Contains(t, namespaces, "production")
|
||||
}
|
||||
|
||||
func TestDiscovery_ListServices_WithPorts(t *testing.T) {
|
||||
objects := []runtime.Object{
|
||||
createTestService("web-svc", "default", map[string]string{"app": "web"}, []corev1.ServicePort{
|
||||
{Name: "http", Port: 80, TargetPort: intstr.FromInt(8080)},
|
||||
{Name: "https", Port: 443, TargetPort: intstr.FromInt(8443)},
|
||||
}),
|
||||
createTestService("api-svc", "default", map[string]string{"app": "api"}, []corev1.ServicePort{
|
||||
{Port: 8080, TargetPort: intstr.FromInt(8080)},
|
||||
}),
|
||||
}
|
||||
|
||||
fakeClient := fake.NewClientset(objects...)
|
||||
|
||||
ctx := t.Context()
|
||||
svcList, err := fakeClient.CoreV1().Services("default").List(ctx, metav1.ListOptions{})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, svcList.Items, 2)
|
||||
|
||||
// Verify service with multiple ports
|
||||
var webSvc *corev1.Service
|
||||
for i := range svcList.Items {
|
||||
if svcList.Items[i].Name == "web-svc" {
|
||||
webSvc = &svcList.Items[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, webSvc)
|
||||
assert.Len(t, webSvc.Spec.Ports, 2)
|
||||
|
||||
// Verify port details
|
||||
foundHTTP := false
|
||||
foundHTTPS := false
|
||||
for _, port := range webSvc.Spec.Ports {
|
||||
if port.Name == "http" {
|
||||
foundHTTP = true
|
||||
assert.Equal(t, int32(80), port.Port)
|
||||
assert.Equal(t, int32(8080), port.TargetPort.IntVal)
|
||||
}
|
||||
if port.Name == "https" {
|
||||
foundHTTPS = true
|
||||
assert.Equal(t, int32(443), port.Port)
|
||||
assert.Equal(t, int32(8443), port.TargetPort.IntVal)
|
||||
}
|
||||
}
|
||||
assert.True(t, foundHTTP, "http port not found")
|
||||
assert.True(t, foundHTTPS, "https port not found")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ContainerInfo and PortInfo Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestContainerInfo_Struct(t *testing.T) {
|
||||
container := ContainerInfo{
|
||||
Name: "test-container",
|
||||
Ports: []PortInfo{
|
||||
{Name: "http", Port: 8080, Protocol: "TCP"},
|
||||
{Name: "grpc", Port: 50051, Protocol: "TCP"},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, "test-container", container.Name)
|
||||
assert.Len(t, container.Ports, 2)
|
||||
assert.Equal(t, "http", container.Ports[0].Name)
|
||||
assert.Equal(t, int32(8080), container.Ports[0].Port)
|
||||
assert.Equal(t, "TCP", container.Ports[0].Protocol)
|
||||
}
|
||||
|
||||
func TestPortInfo_Struct(t *testing.T) {
|
||||
port := PortInfo{
|
||||
Name: "test-port",
|
||||
Protocol: "TCP",
|
||||
Port: 8080,
|
||||
TargetPort: 80,
|
||||
}
|
||||
|
||||
assert.Equal(t, "test-port", port.Name)
|
||||
assert.Equal(t, "TCP", port.Protocol)
|
||||
assert.Equal(t, int32(8080), port.Port)
|
||||
assert.Equal(t, int32(80), port.TargetPort)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GetUniquePorts Edge Cases
|
||||
// =============================================================================
|
||||
|
||||
func TestGetUniquePorts_MultipleContainers(t *testing.T) {
|
||||
pods := []PodInfo{
|
||||
{
|
||||
Name: "pod1",
|
||||
Containers: []ContainerInfo{
|
||||
{
|
||||
Name: "app",
|
||||
Ports: []PortInfo{
|
||||
{Name: "http", Port: 8080},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "sidecar",
|
||||
Ports: []PortInfo{
|
||||
{Name: "metrics", Port: 9090},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := GetUniquePorts(pods)
|
||||
assert.Len(t, result, 2)
|
||||
|
||||
ports := make([]int32, len(result))
|
||||
for i, p := range result {
|
||||
ports[i] = p.Port
|
||||
}
|
||||
assert.Contains(t, ports, int32(8080))
|
||||
assert.Contains(t, ports, int32(9090))
|
||||
}
|
||||
|
||||
func TestGetUniquePorts_DuplicateAcrossPods(t *testing.T) {
|
||||
pods := []PodInfo{
|
||||
{
|
||||
Name: "pod1",
|
||||
Containers: []ContainerInfo{
|
||||
{
|
||||
Name: "main",
|
||||
Ports: []PortInfo{
|
||||
{Name: "http", Port: 8080},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "pod2",
|
||||
Containers: []ContainerInfo{
|
||||
{
|
||||
Name: "main",
|
||||
Ports: []PortInfo{
|
||||
{Name: "http", Port: 8080}, // Same port, same name
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := GetUniquePorts(pods)
|
||||
assert.Len(t, result, 1)
|
||||
assert.Equal(t, int32(8080), result[0].Port)
|
||||
assert.Equal(t, "http", result[0].Name)
|
||||
}
|
||||
|
||||
func TestGetUniquePorts_NamedVsUnnamedDuplicate(t *testing.T) {
|
||||
pods := []PodInfo{
|
||||
{
|
||||
Name: "pod1",
|
||||
Containers: []ContainerInfo{
|
||||
{
|
||||
Name: "main",
|
||||
Ports: []PortInfo{
|
||||
{Port: 8080}, // Unnamed - generates "port-8080"
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "pod2",
|
||||
Containers: []ContainerInfo{
|
||||
{
|
||||
Name: "main",
|
||||
Ports: []PortInfo{
|
||||
{Name: "http", Port: 8080}, // Named - should take precedence
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := GetUniquePorts(pods)
|
||||
assert.Len(t, result, 1)
|
||||
assert.Equal(t, int32(8080), result[0].Port)
|
||||
assert.Equal(t, "http", result[0].Name, "named port should take precedence over generated name")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Cache Entry Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestCacheEntry_Struct(t *testing.T) {
|
||||
now := time.Now()
|
||||
entry := cacheEntry{
|
||||
expiresAt: now.Add(30 * time.Second),
|
||||
resource: ResolvedResource{
|
||||
Timestamp: now,
|
||||
Name: "test-pod",
|
||||
Namespace: "default",
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, now.Add(30*time.Second), entry.expiresAt)
|
||||
assert.Equal(t, "test-pod", entry.resource.Name)
|
||||
assert.Equal(t, "default", entry.resource.Namespace)
|
||||
assert.Equal(t, now, entry.resource.Timestamp)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ClientPool Extended Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestClientPool_ConcurrentAccess(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Concurrent reads and writes to cache
|
||||
for i := 0; i < 20; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
pool.ClearCache()
|
||||
pool.RemoveContext("context")
|
||||
_, _ = pool.GetCurrentContext()
|
||||
_, _ = pool.ListContexts()
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
// If we get here without panic, concurrent access is safe
|
||||
}
|
||||
|
||||
func TestClientPool_MultipleContexts(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test that multiple contexts can be tracked
|
||||
pool.mu.Lock()
|
||||
pool.clients["context1"] = nil
|
||||
pool.clients["context2"] = nil
|
||||
pool.clients["context3"] = nil
|
||||
pool.mu.Unlock()
|
||||
|
||||
// Remove one context
|
||||
pool.RemoveContext("context2")
|
||||
|
||||
// Verify context2 is removed
|
||||
pool.mu.RLock()
|
||||
_, exists1 := pool.clients["context1"]
|
||||
_, exists2 := pool.clients["context2"]
|
||||
_, exists3 := pool.clients["context3"]
|
||||
pool.mu.RUnlock()
|
||||
|
||||
assert.True(t, exists1)
|
||||
assert.False(t, exists2)
|
||||
assert.True(t, exists3)
|
||||
|
||||
// Clear all
|
||||
pool.ClearCache()
|
||||
|
||||
pool.mu.RLock()
|
||||
assert.Equal(t, 0, len(pool.clients))
|
||||
pool.mu.RUnlock()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ResourceResolver Resolve Tests (using internal methods)
|
||||
// =============================================================================
|
||||
|
||||
func TestResourceResolver_Resolve_InvalidFormat(t *testing.T) {
|
||||
pool, _ := NewClientPool()
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
resource string
|
||||
selector string
|
||||
errContains string
|
||||
}{
|
||||
{
|
||||
name: "unsupported resource type",
|
||||
resource: "configmap/my-config",
|
||||
selector: "",
|
||||
errContains: "unsupported resource type",
|
||||
},
|
||||
{
|
||||
name: "pod without prefix or selector",
|
||||
resource: "pod",
|
||||
selector: "",
|
||||
errContains: "pod resource requires either a name prefix",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := r.Resolve(ctx, "test-context", "default", tt.resource, tt.selector)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceResolver_Resolve_ServiceVariations(t *testing.T) {
|
||||
pool, _ := NewClientPool()
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
ctx := t.Context()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
resource string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple service",
|
||||
resource: "service/my-service",
|
||||
expected: "service/my-service",
|
||||
},
|
||||
{
|
||||
name: "service with namespace in name",
|
||||
resource: "service/my-service.namespace",
|
||||
expected: "service/my-service.namespace",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := r.Resolve(ctx, "test-context", "default", tt.resource, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// resolveTargetPort Extended Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestResolveTargetPort_EdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
service *corev1.Service
|
||||
servicePort corev1.ServicePort
|
||||
pods []corev1.Pod
|
||||
expected int32
|
||||
}{
|
||||
{
|
||||
name: "zero value targetPort returns service port",
|
||||
service: &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "svc", Namespace: "default"},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{"app": "test"},
|
||||
Ports: []corev1.ServicePort{{Port: 80}},
|
||||
},
|
||||
},
|
||||
servicePort: corev1.ServicePort{
|
||||
Port: 80,
|
||||
// TargetPort is zero value
|
||||
},
|
||||
pods: nil,
|
||||
expected: 80,
|
||||
},
|
||||
{
|
||||
name: "empty named port returns service port",
|
||||
service: &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "svc", Namespace: "default"},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{"app": "test"},
|
||||
},
|
||||
},
|
||||
servicePort: corev1.ServicePort{
|
||||
Port: 80,
|
||||
TargetPort: intstr.FromString(""), // Empty string
|
||||
},
|
||||
pods: nil,
|
||||
expected: 80,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var objects []runtime.Object
|
||||
for i := range tt.pods {
|
||||
objects = append(objects, &tt.pods[i])
|
||||
}
|
||||
fakeClient := fake.NewClientset(objects...)
|
||||
d := &Discovery{}
|
||||
|
||||
result := d.resolveTargetPort(t.Context(), fakeClient, "default", tt.service, &tt.servicePort)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PortForwarder Settings Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestPortForwarder_DefaultSettings(t *testing.T) {
|
||||
pool, _ := NewClientPool()
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
// Verify defaults are set
|
||||
assert.NotZero(t, pf.tcpKeepalive)
|
||||
assert.NotZero(t, pf.dialTimeout)
|
||||
}
|
||||
|
||||
func TestPortForwarder_SettingsChain(t *testing.T) {
|
||||
pool, _ := NewClientPool()
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
// Chain multiple settings
|
||||
pf.SetTCPKeepalive(60 * time.Second)
|
||||
pf.SetDialTimeout(45 * time.Second)
|
||||
pf.SetTCPKeepalive(30 * time.Second) // Override
|
||||
|
||||
assert.Equal(t, 30*time.Second, pf.tcpKeepalive)
|
||||
assert.Equal(t, 45*time.Second, pf.dialTimeout)
|
||||
}
|
||||
@@ -0,0 +1,932 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Test Helpers
|
||||
// =============================================================================
|
||||
|
||||
func createTestPod(name, namespace string, labels map[string]string, phase corev1.PodPhase, creationTime time.Time) *corev1.Pod {
|
||||
return &corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Labels: labels,
|
||||
CreationTimestamp: metav1.Time{Time: creationTime},
|
||||
},
|
||||
Status: corev1.PodStatus{
|
||||
Phase: phase,
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "main",
|
||||
Ports: []corev1.ContainerPort{
|
||||
{Name: "http", ContainerPort: 8080},
|
||||
{Name: "metrics", ContainerPort: 9090},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestService(name, namespace string, selector map[string]string, ports []corev1.ServicePort) *corev1.Service {
|
||||
return &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: selector,
|
||||
Ports: ports,
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestNamespace(name string) *corev1.Namespace {
|
||||
return &corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Discovery Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestNewDiscovery(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
|
||||
d := NewDiscovery(pool)
|
||||
|
||||
assert.NotNil(t, d)
|
||||
assert.Equal(t, pool, d.pool)
|
||||
}
|
||||
|
||||
func TestDiscovery_ListNamespaces(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errContains string
|
||||
objects []runtime.Object
|
||||
expectedNS []string
|
||||
expectedErr bool
|
||||
}{
|
||||
{
|
||||
name: "successful namespace listing",
|
||||
objects: []runtime.Object{
|
||||
createTestNamespace("default"),
|
||||
createTestNamespace("kube-system"),
|
||||
createTestNamespace("production"),
|
||||
},
|
||||
expectedNS: []string{"default", "kube-system", "production"},
|
||||
},
|
||||
{
|
||||
name: "empty namespace list",
|
||||
objects: []runtime.Object{},
|
||||
expectedNS: []string{},
|
||||
},
|
||||
{
|
||||
name: "single namespace",
|
||||
objects: []runtime.Object{createTestNamespace("default")},
|
||||
expectedNS: []string{"default"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fakeClient := fake.NewClientset(tt.objects...)
|
||||
|
||||
// Directly test with fake client
|
||||
ctx := context.Background()
|
||||
nsList, err := fakeClient.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
namespaces := make([]string, 0, len(nsList.Items))
|
||||
for _, ns := range nsList.Items {
|
||||
namespaces = append(namespaces, ns.Name)
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.expectedNS, namespaces)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscovery_ListPods(t *testing.T) {
|
||||
baseTime := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
validateFn func(t *testing.T, pods *corev1.PodList)
|
||||
name string
|
||||
objects []runtime.Object
|
||||
expectedLen int
|
||||
}{
|
||||
{
|
||||
name: "list all pods in namespace",
|
||||
objects: []runtime.Object{
|
||||
createTestPod("running-pod", "default", nil, corev1.PodRunning, baseTime),
|
||||
createTestPod("pending-pod", "default", nil, corev1.PodPending, baseTime.Add(-time.Hour)),
|
||||
createTestPod("succeeded-pod", "default", nil, corev1.PodSucceeded, baseTime),
|
||||
},
|
||||
expectedLen: 3,
|
||||
validateFn: func(t *testing.T, pods *corev1.PodList) {
|
||||
// Verify all pods are returned
|
||||
names := make([]string, len(pods.Items))
|
||||
for i, p := range pods.Items {
|
||||
names[i] = p.Name
|
||||
}
|
||||
assert.Contains(t, names, "running-pod")
|
||||
assert.Contains(t, names, "pending-pod")
|
||||
assert.Contains(t, names, "succeeded-pod")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty pod list",
|
||||
objects: []runtime.Object{},
|
||||
expectedLen: 0,
|
||||
},
|
||||
{
|
||||
name: "pods in different namespaces",
|
||||
objects: []runtime.Object{
|
||||
createTestPod("pod-default", "default", nil, corev1.PodRunning, baseTime),
|
||||
createTestPod("pod-kube-system", "kube-system", nil, corev1.PodRunning, baseTime),
|
||||
},
|
||||
expectedLen: 1,
|
||||
validateFn: func(t *testing.T, pods *corev1.PodList) {
|
||||
assert.Equal(t, "default", pods.Items[0].Namespace)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fakeClient := fake.NewClientset(tt.objects...)
|
||||
|
||||
ctx := context.Background()
|
||||
var listOpts metav1.ListOptions
|
||||
// List pods in the default namespace (test name indicates filtering intent)
|
||||
pods, err := fakeClient.CoreV1().Pods("default").List(ctx, listOpts)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, pods.Items, tt.expectedLen)
|
||||
|
||||
if tt.validateFn != nil {
|
||||
tt.validateFn(t, pods)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscovery_ListPodsWithSelector(t *testing.T) {
|
||||
baseTime := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
validateFn func(t *testing.T, pods *corev1.PodList)
|
||||
name string
|
||||
selector string
|
||||
objects []runtime.Object
|
||||
expectedLen int
|
||||
}{
|
||||
{
|
||||
name: "match pods by label selector",
|
||||
objects: []runtime.Object{
|
||||
createTestPod("app1-pod", "default", map[string]string{"app": "myapp"}, corev1.PodRunning, baseTime),
|
||||
createTestPod("app2-pod", "default", map[string]string{"app": "myapp"}, corev1.PodRunning, baseTime.Add(-time.Hour)),
|
||||
createTestPod("other-pod", "default", map[string]string{"app": "other"}, corev1.PodRunning, baseTime),
|
||||
},
|
||||
selector: "app=myapp",
|
||||
expectedLen: 2,
|
||||
validateFn: func(t *testing.T, pods *corev1.PodList) {
|
||||
names := make([]string, len(pods.Items))
|
||||
for i, p := range pods.Items {
|
||||
names[i] = p.Name
|
||||
}
|
||||
assert.Contains(t, names, "app1-pod")
|
||||
assert.Contains(t, names, "app2-pod")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "only running pods returned",
|
||||
objects: []runtime.Object{
|
||||
createTestPod("running-pod", "default", map[string]string{"app": "test"}, corev1.PodRunning, baseTime),
|
||||
createTestPod("pending-pod", "default", map[string]string{"app": "test"}, corev1.PodPending, baseTime),
|
||||
},
|
||||
selector: "app=test",
|
||||
expectedLen: 2, // Fake client returns all, filtering is done in ListPodsWithSelector
|
||||
},
|
||||
{
|
||||
name: "no matching pods",
|
||||
objects: []runtime.Object{},
|
||||
selector: "app=nonexistent",
|
||||
expectedLen: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fakeClient := fake.NewClientset(tt.objects...)
|
||||
|
||||
ctx := context.Background()
|
||||
pods, err := fakeClient.CoreV1().Pods("default").List(ctx, metav1.ListOptions{
|
||||
LabelSelector: tt.selector,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, pods.Items, tt.expectedLen)
|
||||
|
||||
if tt.validateFn != nil {
|
||||
tt.validateFn(t, pods)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscovery_ListServices(t *testing.T) {
|
||||
tests := []struct {
|
||||
validateFn func(t *testing.T, services *corev1.ServiceList)
|
||||
name string
|
||||
objects []runtime.Object
|
||||
expectedLen int
|
||||
}{
|
||||
{
|
||||
name: "list services",
|
||||
objects: []runtime.Object{
|
||||
createTestService("svc1", "default", map[string]string{"app": "test"}, []corev1.ServicePort{
|
||||
{Port: 80, TargetPort: intstr.FromInt(8080)},
|
||||
}),
|
||||
createTestService("svc2", "default", map[string]string{"app": "other"}, []corev1.ServicePort{
|
||||
{Port: 443, TargetPort: intstr.FromInt(8443)},
|
||||
}),
|
||||
},
|
||||
expectedLen: 2,
|
||||
validateFn: func(t *testing.T, services *corev1.ServiceList) {
|
||||
names := make([]string, len(services.Items))
|
||||
for i, s := range services.Items {
|
||||
names[i] = s.Name
|
||||
}
|
||||
assert.Contains(t, names, "svc1")
|
||||
assert.Contains(t, names, "svc2")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty service list",
|
||||
objects: []runtime.Object{},
|
||||
expectedLen: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fakeClient := fake.NewClientset(tt.objects...)
|
||||
|
||||
ctx := context.Background()
|
||||
services, err := fakeClient.CoreV1().Services("default").List(ctx, metav1.ListOptions{})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, services.Items, tt.expectedLen)
|
||||
|
||||
if tt.validateFn != nil {
|
||||
tt.validateFn(t, services)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CheckPortAvailability Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestCheckPortAvailability(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expectedErrMsg string
|
||||
port int
|
||||
expectedAvail bool
|
||||
expectedErr bool
|
||||
}{
|
||||
{
|
||||
name: "port 0 is invalid",
|
||||
port: 0,
|
||||
expectedAvail: false,
|
||||
expectedErr: true,
|
||||
expectedErrMsg: "invalid port",
|
||||
},
|
||||
{
|
||||
name: "negative port is invalid",
|
||||
port: -1,
|
||||
expectedAvail: false,
|
||||
expectedErr: true,
|
||||
expectedErrMsg: "invalid port",
|
||||
},
|
||||
{
|
||||
name: "port too high is invalid",
|
||||
port: 65536,
|
||||
expectedAvail: false,
|
||||
expectedErr: true,
|
||||
expectedErrMsg: "invalid port",
|
||||
},
|
||||
{
|
||||
name: "valid high port should be available",
|
||||
port: 65535,
|
||||
expectedAvail: true,
|
||||
expectedErr: false,
|
||||
expectedErrMsg: "",
|
||||
},
|
||||
{
|
||||
name: "common high port should be available",
|
||||
port: 8080,
|
||||
expectedAvail: true,
|
||||
expectedErr: false,
|
||||
expectedErrMsg: "",
|
||||
},
|
||||
{
|
||||
name: "lowest valid port",
|
||||
port: 1,
|
||||
expectedAvail: true,
|
||||
expectedErr: false,
|
||||
expectedErrMsg: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
available, processInfo, err := CheckPortAvailability(tt.port)
|
||||
|
||||
if tt.expectedErr {
|
||||
assert.False(t, available)
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, processInfo)
|
||||
assert.Contains(t, err.Error(), tt.expectedErrMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// For valid ports, we can only reliably test that no error occurs
|
||||
// Port might be in use by system or other tests
|
||||
require.NoError(t, err)
|
||||
|
||||
if available {
|
||||
assert.Empty(t, processInfo)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPortAvailability_PortInUse(t *testing.T) {
|
||||
// Start a listener on a specific port on all interfaces
|
||||
// #nosec G102 - Binding to all interfaces is intentional for this test
|
||||
listener, err := net.Listen("tcp", ":0")
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
_ = listener.Close() // Error ignored - best effort cleanup
|
||||
}()
|
||||
|
||||
// Get the port that was assigned
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
// Check that the port is reported as in use
|
||||
available, processInfo, err := CheckPortAvailability(port)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, available)
|
||||
assert.NotEmpty(t, processInfo)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ResourceResolver Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestNewResourceResolver(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
assert.NotNil(t, r)
|
||||
assert.Equal(t, pool, r.clientPool)
|
||||
assert.NotNil(t, r.cache)
|
||||
assert.Equal(t, defaultCacheTTL, r.cacheTTL)
|
||||
}
|
||||
|
||||
func TestResourceResolver_SetCacheTTL(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
newTTL := 5 * time.Minute
|
||||
r.SetCacheTTL(newTTL)
|
||||
|
||||
assert.Equal(t, newTTL, r.cacheTTL)
|
||||
}
|
||||
|
||||
func TestResourceResolver_Resolve_Service(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
resource string
|
||||
expected string
|
||||
errContains string
|
||||
expectedErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid service resource",
|
||||
resource: "service/my-service",
|
||||
expected: "service/my-service",
|
||||
},
|
||||
{
|
||||
// Note: "service/" returns the resource as-is (current behavior)
|
||||
name: "service with empty name part",
|
||||
resource: "service/",
|
||||
expected: "service/",
|
||||
},
|
||||
{
|
||||
name: "service without slash returns error",
|
||||
resource: "service",
|
||||
expectedErr: true,
|
||||
errContains: "invalid service resource format",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
result, err := r.Resolve(ctx, "test-context", "default", tt.resource, "")
|
||||
|
||||
if tt.expectedErr {
|
||||
assert.Error(t, err)
|
||||
if tt.errContains != "" {
|
||||
assert.Contains(t, err.Error(), tt.errContains)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceResolver_Resolve_UnsupportedType(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := r.Resolve(ctx, "test-context", "default", "deployment/my-deploy", "")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported resource type")
|
||||
assert.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestResourceResolver_Resolve_PodWithoutPrefixOrSelector(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
ctx := context.Background()
|
||||
result, err := r.Resolve(ctx, "test-context", "default", "pod", "")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "pod resource requires either a name prefix")
|
||||
assert.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestResourceResolver_Cache_Operations(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
// Test putInCache and getFromCache
|
||||
key := "test-context/default/pod/test"
|
||||
value := "test-pod-123"
|
||||
|
||||
// Initially empty
|
||||
result := r.getFromCache(key)
|
||||
assert.Empty(t, result)
|
||||
|
||||
// Put in cache
|
||||
r.putInCache(key, value)
|
||||
|
||||
// Should be retrievable
|
||||
result = r.getFromCache(key)
|
||||
assert.Equal(t, value, result)
|
||||
}
|
||||
|
||||
func TestResourceResolver_Cache_Expiry(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
// Set very short TTL
|
||||
r.SetCacheTTL(50 * time.Millisecond)
|
||||
|
||||
key := "test-context/default/pod/test"
|
||||
value := "test-pod-123"
|
||||
|
||||
// Put in cache
|
||||
r.putInCache(key, value)
|
||||
|
||||
// Should be immediately retrievable
|
||||
result := r.getFromCache(key)
|
||||
assert.Equal(t, value, result)
|
||||
|
||||
// Wait for expiry
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Should be expired
|
||||
result = r.getFromCache(key)
|
||||
assert.Empty(t, result)
|
||||
|
||||
// Cache entry should be cleaned up
|
||||
r.cacheMu.RLock()
|
||||
_, exists := r.cache[key]
|
||||
r.cacheMu.RUnlock()
|
||||
assert.False(t, exists)
|
||||
}
|
||||
|
||||
func TestResourceResolver_Cache_ConcurrentAccess(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
key := "key"
|
||||
value := "value"
|
||||
r.putInCache(key, value)
|
||||
_ = r.getFromCache(key)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify no race conditions occurred
|
||||
assert.NotNil(t, r.cache)
|
||||
}
|
||||
|
||||
func TestResourceResolver_ClearCache(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
// Populate cache
|
||||
r.putInCache("key1", "value1")
|
||||
r.putInCache("key2", "value2")
|
||||
|
||||
// Verify cache has entries
|
||||
r.cacheMu.RLock()
|
||||
assert.Greater(t, len(r.cache), 0)
|
||||
r.cacheMu.RUnlock()
|
||||
|
||||
// Clear cache
|
||||
r.ClearCache()
|
||||
|
||||
// Verify cache is empty
|
||||
r.cacheMu.RLock()
|
||||
assert.Equal(t, 0, len(r.cache))
|
||||
r.cacheMu.RUnlock()
|
||||
}
|
||||
|
||||
func TestResourceResolver_InvalidateCache(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
// Populate cache with multiple entries in same namespace
|
||||
r.putInCache("test-context/default/pod/app1", "pod1")
|
||||
r.putInCache("test-context/default/pod/app2", "pod2")
|
||||
r.putInCache("test-context/other/pod/app1", "pod3")
|
||||
|
||||
// Invalidate for specific namespace
|
||||
r.InvalidateCache("test-context", "default", "pod/app1")
|
||||
|
||||
// All entries for that namespace should be cleared
|
||||
r.cacheMu.RLock()
|
||||
_, exists1 := r.cache["test-context/default/pod/app1"]
|
||||
_, exists2 := r.cache["test-context/default/pod/app2"]
|
||||
_, exists3 := r.cache["test-context/other/pod/app1"]
|
||||
r.cacheMu.RUnlock()
|
||||
|
||||
assert.False(t, exists1)
|
||||
assert.False(t, exists2)
|
||||
assert.True(t, exists3, "other namespace should not be affected")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PortForwarder Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestNewPortForwarder(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
assert.NotNil(t, pf)
|
||||
assert.Equal(t, pool, pf.clientPool)
|
||||
assert.Equal(t, r, pf.resolver)
|
||||
assert.NotZero(t, pf.tcpKeepalive)
|
||||
assert.NotZero(t, pf.dialTimeout)
|
||||
}
|
||||
|
||||
func TestPortForwarder_SetTCPKeepalive(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
newKeepalive := 60 * time.Second
|
||||
pf.SetTCPKeepalive(newKeepalive)
|
||||
|
||||
assert.Equal(t, newKeepalive, pf.tcpKeepalive)
|
||||
}
|
||||
|
||||
func TestPortForwarder_SetDialTimeout(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
newTimeout := 45 * time.Second
|
||||
pf.SetDialTimeout(newTimeout)
|
||||
|
||||
assert.Equal(t, newTimeout, pf.dialTimeout)
|
||||
}
|
||||
|
||||
func TestPortForwarder_Forward_InvalidResource(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
ctx := context.Background()
|
||||
req := &ForwardRequest{
|
||||
ContextName: "test-context",
|
||||
Namespace: "default",
|
||||
Resource: "invalid-resource",
|
||||
}
|
||||
|
||||
err = pf.Forward(ctx, req)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported resource type")
|
||||
}
|
||||
|
||||
func TestForwardRequest_Struct(t *testing.T) {
|
||||
// Test that ForwardRequest struct fields are correctly accessible
|
||||
stopChan := make(chan struct{})
|
||||
readyChan := make(chan struct{})
|
||||
|
||||
req := &ForwardRequest{
|
||||
Out: nil,
|
||||
ErrOut: nil,
|
||||
StopChan: stopChan,
|
||||
ReadyChan: readyChan,
|
||||
ContextName: "test-context",
|
||||
Namespace: "default",
|
||||
Resource: "pod/my-pod",
|
||||
Selector: "",
|
||||
LocalPort: 8080,
|
||||
RemotePort: 80,
|
||||
}
|
||||
|
||||
assert.Equal(t, "test-context", req.ContextName)
|
||||
assert.Equal(t, "default", req.Namespace)
|
||||
assert.Equal(t, "pod/my-pod", req.Resource)
|
||||
assert.Equal(t, 8080, req.LocalPort)
|
||||
assert.Equal(t, 80, req.RemotePort)
|
||||
assert.Equal(t, stopChan, req.StopChan)
|
||||
assert.Equal(t, readyChan, req.ReadyChan)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PodInfo and ServiceInfo Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestPodInfo_Struct(t *testing.T) {
|
||||
now := time.Now()
|
||||
podInfo := PodInfo{
|
||||
Created: metav1.Time{Time: now},
|
||||
Name: "test-pod",
|
||||
Namespace: "default",
|
||||
Status: "Running",
|
||||
Containers: []ContainerInfo{
|
||||
{
|
||||
Name: "main",
|
||||
Ports: []PortInfo{
|
||||
{Name: "http", Port: 8080, Protocol: "TCP"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, "test-pod", podInfo.Name)
|
||||
assert.Equal(t, "default", podInfo.Namespace)
|
||||
assert.Equal(t, "Running", podInfo.Status)
|
||||
assert.Len(t, podInfo.Containers, 1)
|
||||
assert.Equal(t, "main", podInfo.Containers[0].Name)
|
||||
assert.Equal(t, int32(8080), podInfo.Containers[0].Ports[0].Port)
|
||||
}
|
||||
|
||||
func TestServiceInfo_Struct(t *testing.T) {
|
||||
svcInfo := ServiceInfo{
|
||||
Name: "test-svc",
|
||||
Namespace: "default",
|
||||
Type: "ClusterIP",
|
||||
Ports: []PortInfo{
|
||||
{Name: "http", Port: 80, TargetPort: 8080, Protocol: "TCP"},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, "test-svc", svcInfo.Name)
|
||||
assert.Equal(t, "default", svcInfo.Namespace)
|
||||
assert.Equal(t, "ClusterIP", svcInfo.Type)
|
||||
assert.Len(t, svcInfo.Ports, 1)
|
||||
assert.Equal(t, int32(80), svcInfo.Ports[0].Port)
|
||||
assert.Equal(t, int32(8080), svcInfo.Ports[0].TargetPort)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ResolvedResource Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestResolvedResource_Struct(t *testing.T) {
|
||||
now := time.Now()
|
||||
resource := ResolvedResource{
|
||||
Timestamp: now,
|
||||
Name: "my-pod",
|
||||
Namespace: "default",
|
||||
}
|
||||
|
||||
assert.Equal(t, "my-pod", resource.Name)
|
||||
assert.Equal(t, "default", resource.Namespace)
|
||||
assert.Equal(t, now, resource.Timestamp)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GetUniquePorts Additional Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestGetUniquePorts_EmptyInput(t *testing.T) {
|
||||
result := GetUniquePorts([]PodInfo{})
|
||||
assert.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestGetUniquePorts_SinglePod(t *testing.T) {
|
||||
pods := []PodInfo{
|
||||
{
|
||||
Name: "single-pod",
|
||||
Containers: []ContainerInfo{
|
||||
{
|
||||
Name: "main",
|
||||
Ports: []PortInfo{
|
||||
{Name: "http", Port: 8080},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := GetUniquePorts(pods)
|
||||
assert.Len(t, result, 1)
|
||||
assert.Equal(t, int32(8080), result[0].Port)
|
||||
assert.Equal(t, "http", result[0].Name)
|
||||
}
|
||||
|
||||
func TestGetUniquePorts_NoNamedPorts(t *testing.T) {
|
||||
pods := []PodInfo{
|
||||
{
|
||||
Name: "pod1",
|
||||
Containers: []ContainerInfo{
|
||||
{
|
||||
Name: "main",
|
||||
Ports: []PortInfo{
|
||||
{Port: 8080}, // No name
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := GetUniquePorts(pods)
|
||||
assert.Len(t, result, 1)
|
||||
assert.Equal(t, int32(8080), result[0].Port)
|
||||
assert.Equal(t, "port-8080", result[0].Name)
|
||||
}
|
||||
|
||||
func TestGetUniquePorts_PreferNamedOverGenerated(t *testing.T) {
|
||||
pods := []PodInfo{
|
||||
{
|
||||
Name: "pod1",
|
||||
Containers: []ContainerInfo{
|
||||
{
|
||||
Name: "main",
|
||||
Ports: []PortInfo{
|
||||
{Port: 8080}, // No name, generates "port-8080"
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "pod2",
|
||||
Containers: []ContainerInfo{
|
||||
{
|
||||
Name: "main",
|
||||
Ports: []PortInfo{
|
||||
{Name: "http", Port: 8080}, // Named port
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := GetUniquePorts(pods)
|
||||
assert.Len(t, result, 1)
|
||||
assert.Equal(t, int32(8080), result[0].Port)
|
||||
assert.Equal(t, "http", result[0].Name, "named port should take precedence")
|
||||
}
|
||||
|
||||
func TestGetUniquePorts_SortedByPortNumber(t *testing.T) {
|
||||
pods := []PodInfo{
|
||||
{
|
||||
Name: "pod1",
|
||||
Containers: []ContainerInfo{
|
||||
{
|
||||
Name: "main",
|
||||
Ports: []PortInfo{
|
||||
{Name: "high", Port: 9000},
|
||||
{Name: "low", Port: 80},
|
||||
{Name: "mid", Port: 8080},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := GetUniquePorts(pods)
|
||||
assert.Len(t, result, 3)
|
||||
assert.Equal(t, int32(80), result[0].Port)
|
||||
assert.Equal(t, int32(8080), result[1].Port)
|
||||
assert.Equal(t, int32(9000), result[2].Port)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Discovery Context Operations Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestDiscovery_ListContexts(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
|
||||
d := NewDiscovery(pool)
|
||||
|
||||
// This will either succeed or fail based on kubeconfig availability
|
||||
contexts, err := d.ListContexts()
|
||||
|
||||
if err != nil {
|
||||
// Expected if no kubeconfig
|
||||
assert.Contains(t, err.Error(), "kubeconfig")
|
||||
} else {
|
||||
// If successful, should be a slice
|
||||
assert.NotNil(t, contexts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscovery_GetCurrentContext(t *testing.T) {
|
||||
pool, err := NewClientPool()
|
||||
require.NoError(t, err)
|
||||
|
||||
d := NewDiscovery(pool)
|
||||
|
||||
// This will either succeed or fail based on kubeconfig availability
|
||||
context, err := d.GetCurrentContext()
|
||||
|
||||
if err != nil {
|
||||
// Expected if no kubeconfig
|
||||
assert.Contains(t, err.Error(), "kubeconfig")
|
||||
} else {
|
||||
// If successful, should be a string
|
||||
assert.NotEmpty(t, context)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// PortForwarder Extended Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestPortForwarder_Forward_ServiceResolutionError(t *testing.T) {
|
||||
// Create pool without any pods/services
|
||||
pool := setupTestPool(t, "test-context")
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
stopChan := make(chan struct{})
|
||||
req := &ForwardRequest{
|
||||
StopChan: stopChan,
|
||||
ContextName: "test-context",
|
||||
Namespace: "default",
|
||||
Resource: "service/nonexistent-svc",
|
||||
LocalPort: 8080,
|
||||
RemotePort: 80,
|
||||
}
|
||||
|
||||
err := pf.Forward(t.Context(), req)
|
||||
assert.Error(t, err)
|
||||
// Should fail trying to get the service
|
||||
assert.Contains(t, err.Error(), "failed to get service")
|
||||
}
|
||||
|
||||
func TestPortForwarder_Forward_PodNotRunning(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pending-pod",
|
||||
Namespace: "default",
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodPending},
|
||||
},
|
||||
)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
stopChan := make(chan struct{})
|
||||
req := &ForwardRequest{
|
||||
StopChan: stopChan,
|
||||
ContextName: "test-context",
|
||||
Namespace: "default",
|
||||
Resource: "pod/pending-pod",
|
||||
LocalPort: 8080,
|
||||
RemotePort: 80,
|
||||
}
|
||||
|
||||
err := pf.Forward(t.Context(), req)
|
||||
assert.Error(t, err)
|
||||
// Since pod is not running, it won't be found during resolution
|
||||
assert.Contains(t, err.Error(), "no running pods found")
|
||||
}
|
||||
|
||||
func TestPortForwarder_Forward_PodPhaseCheck(t *testing.T) {
|
||||
// Create a running pod for resolution
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-pod",
|
||||
Namespace: "default",
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
stopChan := make(chan struct{})
|
||||
req := &ForwardRequest{
|
||||
StopChan: stopChan,
|
||||
ContextName: "test-context",
|
||||
Namespace: "default",
|
||||
Resource: "pod/test-pod",
|
||||
LocalPort: 8080,
|
||||
RemotePort: 80,
|
||||
}
|
||||
|
||||
err := pf.Forward(t.Context(), req)
|
||||
// Will fail on port-forward since we can't actually forward
|
||||
// but the pod phase check should have passed
|
||||
assert.Error(t, err)
|
||||
// Error should not be about pod not running
|
||||
assert.NotContains(t, err.Error(), "pod is not running")
|
||||
}
|
||||
|
||||
func TestPortForwarder_Forward_UnsupportedResourceType(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context")
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
stopChan := make(chan struct{})
|
||||
req := &ForwardRequest{
|
||||
StopChan: stopChan,
|
||||
ContextName: "test-context",
|
||||
Namespace: "default",
|
||||
Resource: "deployment/my-deploy",
|
||||
LocalPort: 8080,
|
||||
RemotePort: 80,
|
||||
}
|
||||
|
||||
err := pf.Forward(t.Context(), req)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported resource type")
|
||||
}
|
||||
|
||||
func TestPortForwarder_Forward_GetClientError(t *testing.T) {
|
||||
// Create pool without setting test client
|
||||
pool, _ := NewClientPool()
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
stopChan := make(chan struct{})
|
||||
req := &ForwardRequest{
|
||||
StopChan: stopChan,
|
||||
ContextName: "non-existent-context",
|
||||
Namespace: "default",
|
||||
Resource: "service/my-service",
|
||||
LocalPort: 8080,
|
||||
RemotePort: 80,
|
||||
}
|
||||
|
||||
err := pf.Forward(t.Context(), req)
|
||||
assert.Error(t, err)
|
||||
// Will fail trying to get client (via resolver)
|
||||
assert.Contains(t, err.Error(), "failed to get client")
|
||||
}
|
||||
|
||||
func TestPortForwarder_GetPodForResource_ServiceNotFound(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context")
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
_, err := pf.GetPodForResource(t.Context(), "test-context", "default", "service/nonexistent", "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to get service")
|
||||
}
|
||||
|
||||
func TestPortForwarder_GetPodForResource_UnsupportedType(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context")
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
_, err := pf.GetPodForResource(t.Context(), "test-context", "default", "deployment/my-deploy", "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported resource type")
|
||||
}
|
||||
|
||||
func TestPortForwarder_GetPodForResource_DirectPod(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-pod",
|
||||
Namespace: "default",
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
// For pod resources, GetPodForResource returns the pod name directly
|
||||
podName, err := pf.GetPodForResource(t.Context(), "test-context", "default", "pod/test-pod", "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test-pod", podName)
|
||||
}
|
||||
|
||||
func TestPortForwarder_ForwardRequest_DefaultChannels(t *testing.T) {
|
||||
// Test that ForwardRequest can be created without channels
|
||||
req := &ForwardRequest{
|
||||
ContextName: "test-context",
|
||||
Namespace: "default",
|
||||
Resource: "pod/my-pod",
|
||||
LocalPort: 8080,
|
||||
RemotePort: 80,
|
||||
// StopChan and ReadyChan not set
|
||||
}
|
||||
|
||||
assert.Nil(t, req.StopChan)
|
||||
assert.Nil(t, req.ReadyChan)
|
||||
assert.Nil(t, req.Out)
|
||||
assert.Nil(t, req.ErrOut)
|
||||
}
|
||||
|
||||
func TestPortForwarder_Settings(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context")
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
// Test TCP keepalive setting
|
||||
pf.SetTCPKeepalive(30 * 1000000000) // 30 seconds in nanoseconds
|
||||
|
||||
// Test dial timeout setting
|
||||
pf.SetDialTimeout(10 * 1000000000) // 10 seconds in nanoseconds
|
||||
|
||||
// Just verify they don't panic
|
||||
assert.NotNil(t, pf)
|
||||
}
|
||||
|
||||
func TestPortForwarder_Forward_GetPodError(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context")
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
stopChan := make(chan struct{})
|
||||
req := &ForwardRequest{
|
||||
StopChan: stopChan,
|
||||
ContextName: "test-context",
|
||||
Namespace: "default",
|
||||
Resource: "pod/nonexistent-prefix-xyz",
|
||||
LocalPort: 8080,
|
||||
RemotePort: 80,
|
||||
}
|
||||
|
||||
err := pf.Forward(t.Context(), req)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to resolve resource")
|
||||
}
|
||||
|
||||
func TestPortForwarder_ForwardToService_NoRunningPods(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pending-pod",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "backend"},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodPending},
|
||||
},
|
||||
&corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "backend-svc",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{"app": "backend"},
|
||||
Ports: []corev1.ServicePort{
|
||||
{Port: 80},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
stopChan := make(chan struct{})
|
||||
req := &ForwardRequest{
|
||||
StopChan: stopChan,
|
||||
ContextName: "test-context",
|
||||
Namespace: "default",
|
||||
Resource: "service/backend-svc",
|
||||
LocalPort: 8080,
|
||||
RemotePort: 80,
|
||||
}
|
||||
|
||||
err := pf.Forward(t.Context(), req)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no running pods found for service")
|
||||
}
|
||||
|
||||
func TestPortForwarder_GetPodForResource_ServiceWithRunningPod(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "running-pod",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "backend"},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
&corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "backend-svc",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{"app": "backend"},
|
||||
Ports: []corev1.ServicePort{
|
||||
{Port: 80},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
podName, err := pf.GetPodForResource(t.Context(), "test-context", "default", "service/backend-svc", "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "running-pod", podName)
|
||||
}
|
||||
|
||||
func TestPortForwarder_GetPodForResource_ServicePendingPod(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pending-pod",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "backend"},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodPending},
|
||||
},
|
||||
&corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "backend-svc",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{"app": "backend"},
|
||||
Ports: []corev1.ServicePort{
|
||||
{Port: 80},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
pf := NewPortForwarder(pool, r)
|
||||
|
||||
_, err := pf.GetPodForResource(t.Context(), "test-context", "default", "service/backend-svc", "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no running pods found for service")
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// ResourceResolver Extended Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestResourceResolver_ResolvePodPrefix_CacheHit(t *testing.T) {
|
||||
baseTime := time.Now()
|
||||
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "my-app-xyz789",
|
||||
Namespace: "default",
|
||||
CreationTimestamp: metav1.Time{Time: baseTime},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
// First call - hits API
|
||||
result1, err := r.Resolve(t.Context(), "test-context", "default", "pod/my-app", "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "pod/my-app-xyz789", result1)
|
||||
|
||||
// Second call - should use cache (instant)
|
||||
start := time.Now()
|
||||
result2, err := r.Resolve(t.Context(), "test-context", "default", "pod/my-app", "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, result1, result2)
|
||||
// Should be very fast since it's cached
|
||||
assert.Less(t, time.Since(start), 10*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestResourceResolver_ResolvePodSelector_CacheHit(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "app-pod",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "myapp"},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
// First call - hits API
|
||||
result1, err := r.Resolve(t.Context(), "test-context", "default", "pod", "app=myapp")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "pod/app-pod", result1)
|
||||
|
||||
// Second call - should use cache
|
||||
result2, err := r.Resolve(t.Context(), "test-context", "default", "pod", "app=myapp")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, result1, result2)
|
||||
}
|
||||
|
||||
func TestResourceResolver_ResolvePodPrefix_ExcludesNonRunning(t *testing.T) {
|
||||
baseTime := time.Now()
|
||||
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "my-app-pending",
|
||||
Namespace: "default",
|
||||
CreationTimestamp: metav1.Time{Time: baseTime},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodPending},
|
||||
},
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "my-app-succeeded",
|
||||
Namespace: "default",
|
||||
CreationTimestamp: metav1.Time{Time: baseTime},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodSucceeded},
|
||||
},
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "my-app-failed",
|
||||
Namespace: "default",
|
||||
CreationTimestamp: metav1.Time{Time: baseTime},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodFailed},
|
||||
},
|
||||
)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
_, err := r.Resolve(t.Context(), "test-context", "default", "pod/my-app", "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no running pods found matching prefix")
|
||||
}
|
||||
|
||||
func TestResourceResolver_ResolvePodSelector_ExcludesNonRunning(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "app-pod-pending",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "myapp"},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodPending},
|
||||
},
|
||||
)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
_, err := r.Resolve(t.Context(), "test-context", "default", "pod", "app=myapp")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no running pods found matching selector")
|
||||
}
|
||||
|
||||
func TestResourceResolver_getFromCache_NotFound(t *testing.T) {
|
||||
pool, _ := NewClientPool()
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
result := r.getFromCache("non-existent-key")
|
||||
assert.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestResourceResolver_getFromCache_ExpiredEntry(t *testing.T) {
|
||||
pool, _ := NewClientPool()
|
||||
r := NewResourceResolver(pool)
|
||||
r.SetCacheTTL(1 * time.Millisecond)
|
||||
|
||||
// Put entry in cache
|
||||
r.putInCache("test-key", "test-value")
|
||||
|
||||
// Verify it's there
|
||||
result := r.getFromCache("test-key")
|
||||
assert.Equal(t, "test-value", result)
|
||||
|
||||
// Wait for expiry
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Should be expired and cleaned up
|
||||
result = r.getFromCache("test-key")
|
||||
assert.Empty(t, result)
|
||||
|
||||
// Verify entry was deleted
|
||||
r.cacheMu.RLock()
|
||||
_, exists := r.cache["test-key"]
|
||||
r.cacheMu.RUnlock()
|
||||
assert.False(t, exists)
|
||||
}
|
||||
|
||||
func TestResourceResolver_InvalidateCache_NoEntries(t *testing.T) {
|
||||
pool, _ := NewClientPool()
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
// Should not panic on empty cache
|
||||
r.InvalidateCache("test-context", "default", "pod/app")
|
||||
|
||||
assert.NotNil(t, r.cache)
|
||||
}
|
||||
|
||||
func TestResourceResolver_Resolve_GetClientError(t *testing.T) {
|
||||
// Create pool without test client - should fail when trying to get client
|
||||
pool, _ := NewClientPool()
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
_, err := r.Resolve(t.Context(), "non-existent-context", "default", "pod/test", "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to get client")
|
||||
}
|
||||
|
||||
func TestResourceResolver_ResolvePodPrefix_MultipleMatchesReturnsNewest(t *testing.T) {
|
||||
baseTime := time.Now()
|
||||
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "my-app-oldest",
|
||||
Namespace: "default",
|
||||
CreationTimestamp: metav1.Time{Time: baseTime.Add(-2 * time.Hour)},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "my-app-middle",
|
||||
Namespace: "default",
|
||||
CreationTimestamp: metav1.Time{Time: baseTime.Add(-1 * time.Hour)},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "my-app-newest",
|
||||
Namespace: "default",
|
||||
CreationTimestamp: metav1.Time{Time: baseTime},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
result, err := r.Resolve(t.Context(), "test-context", "default", "pod/my-app", "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "pod/my-app-newest", result)
|
||||
}
|
||||
|
||||
func TestResourceResolver_ResolvePodSelector_FirstRunning(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "app-pod-1",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "myapp"},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "app-pod-2",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "myapp"},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
)
|
||||
|
||||
r := NewResourceResolver(pool)
|
||||
|
||||
result, err := r.Resolve(t.Context(), "test-context", "default", "pod", "app=myapp")
|
||||
require.NoError(t, err)
|
||||
// Should return the first running pod found
|
||||
assert.Equal(t, "pod/app-pod-1", result)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Discovery Extended Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestDiscovery_ListPods_FilteringAndSorting(t *testing.T) {
|
||||
baseTime := time.Now()
|
||||
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "newer-running-pod",
|
||||
Namespace: "default",
|
||||
CreationTimestamp: metav1.Time{Time: baseTime},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "main",
|
||||
Ports: []corev1.ContainerPort{
|
||||
{ContainerPort: 8080, Protocol: corev1.ProtocolTCP},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "older-pending-pod",
|
||||
Namespace: "default",
|
||||
CreationTimestamp: metav1.Time{Time: baseTime.Add(-time.Hour)},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodPending},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{Name: "main"},
|
||||
},
|
||||
},
|
||||
},
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "older-running-pod",
|
||||
Namespace: "default",
|
||||
CreationTimestamp: metav1.Time{Time: baseTime.Add(-2 * time.Hour)},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{Name: "main"},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Pods in other namespaces should not appear
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "other-namespace-pod",
|
||||
Namespace: "kube-system",
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
)
|
||||
|
||||
d := NewDiscovery(pool)
|
||||
|
||||
pods, err := d.ListPods(t.Context(), "test-context", "default")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, pods, 3) // 2 running + 1 pending
|
||||
|
||||
// Should be sorted by creation time (newest first)
|
||||
assert.Equal(t, "newer-running-pod", pods[0].Name)
|
||||
assert.Equal(t, "older-pending-pod", pods[1].Name)
|
||||
assert.Equal(t, "older-running-pod", pods[2].Name)
|
||||
|
||||
// Check protocol is set correctly
|
||||
assert.Equal(t, "TCP", pods[0].Containers[0].Ports[0].Protocol)
|
||||
}
|
||||
|
||||
func TestDiscovery_ListPodsWithSelector_OnlyRunning(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "running-pod",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "myapp"},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
},
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pending-pod",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "myapp"},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodPending},
|
||||
},
|
||||
)
|
||||
|
||||
d := NewDiscovery(pool)
|
||||
|
||||
pods, err := d.ListPodsWithSelector(t.Context(), "test-context", "default", "app=myapp")
|
||||
require.NoError(t, err)
|
||||
// Only running pods should be returned for selector-based queries
|
||||
assert.Len(t, pods, 1)
|
||||
assert.Equal(t, "running-pod", pods[0].Name)
|
||||
}
|
||||
|
||||
func TestDiscovery_ListServices_WithNamedPortResolution(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "backend-pod",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "backend"},
|
||||
},
|
||||
Status: corev1.PodStatus{Phase: corev1.PodRunning},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "main",
|
||||
Ports: []corev1.ContainerPort{
|
||||
{Name: "http", ContainerPort: 8080},
|
||||
{Name: "grpc", ContainerPort: 50051},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
&corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "backend-svc",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
Selector: map[string]string{"app": "backend"},
|
||||
Ports: []corev1.ServicePort{
|
||||
{Name: "http", Port: 80, TargetPort: intstr.FromString("http")},
|
||||
{Name: "grpc", Port: 50051, TargetPort: intstr.FromString("grpc")},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
d := NewDiscovery(pool)
|
||||
|
||||
services, err := d.ListServices(t.Context(), "test-context", "default")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, services, 1)
|
||||
|
||||
// Named ports should be resolved
|
||||
assert.Len(t, services[0].Ports, 2)
|
||||
assert.Equal(t, int32(80), services[0].Ports[0].Port)
|
||||
assert.Equal(t, int32(8080), services[0].Ports[0].TargetPort) // Resolved from pod
|
||||
assert.Equal(t, int32(50051), services[0].Ports[1].Port)
|
||||
assert.Equal(t, int32(50051), services[0].Ports[1].TargetPort) // Resolved from pod
|
||||
}
|
||||
|
||||
func TestDiscovery_ListServices_NoBackingPods(t *testing.T) {
|
||||
pool := setupTestPool(t, "test-context",
|
||||
&corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "backend-svc",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
Selector: map[string]string{"app": "nonexistent"},
|
||||
Ports: []corev1.ServicePort{
|
||||
{Name: "http", Port: 80, TargetPort: intstr.FromString("http")},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
d := NewDiscovery(pool)
|
||||
|
||||
services, err := d.ListServices(t.Context(), "test-context", "default")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, services, 1)
|
||||
|
||||
// When no backing pods, falls back to service port
|
||||
assert.Equal(t, int32(80), services[0].Ports[0].TargetPort)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/nvm/kportal/internal/logger"
|
||||
"github.com/lukaszraczylo/kportal/internal/logger"
|
||||
)
|
||||
|
||||
// This test demonstrates the logger output formats
|
||||
|
||||
@@ -97,10 +97,21 @@ func (l *Logger) log(level Level, msg string, fields map[string]interface{}) {
|
||||
Message: msg,
|
||||
Fields: fields,
|
||||
}
|
||||
data, _ := json.Marshal(entry)
|
||||
_, _ = fmt.Fprintln(l.output, string(data))
|
||||
data, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
// Fall back to simple text format on marshal error
|
||||
// Error intentionally ignored - best effort fallback logging
|
||||
_, _ = fmt.Fprintf(l.output, "[%s] %s (json marshal error: %v)\n", levelStr, msg, err)
|
||||
return
|
||||
}
|
||||
if _, err := fmt.Fprintln(l.output, string(data)); err != nil {
|
||||
// Write errors are typically unrecoverable (e.g., closed pipe, disk full)
|
||||
// We silently ignore them to prevent cascading failures in logging
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Text format
|
||||
// Write errors are silently ignored to prevent cascading failures
|
||||
if len(fields) > 0 {
|
||||
_, _ = fmt.Fprintf(l.output, "[%s] %s %v\n", levelStr, msg, fields)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// errorWriter is a writer that always returns an error
|
||||
type errorWriter struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *errorWriter) Write(p []byte) (n int, err error) {
|
||||
return 0, e.err
|
||||
}
|
||||
|
||||
func TestJSONMarshalErrorFallback(t *testing.T) {
|
||||
tests := []struct {
|
||||
fields map[string]interface{}
|
||||
name string
|
||||
message string
|
||||
expectContains []string
|
||||
expectFallback bool
|
||||
}{
|
||||
{
|
||||
name: "normal fields marshal successfully",
|
||||
message: "test message",
|
||||
fields: map[string]interface{}{
|
||||
"key": "value",
|
||||
"num": 123,
|
||||
},
|
||||
expectFallback: false,
|
||||
expectContains: []string{`"message":"test message"`, `"level":"INFO"`},
|
||||
},
|
||||
{
|
||||
name: "channel field causes marshal error",
|
||||
message: "marshal error message",
|
||||
fields: map[string]interface{}{
|
||||
"bad_field": make(chan int),
|
||||
},
|
||||
expectFallback: true,
|
||||
expectContains: []string{"[INFO]", "marshal error message", "json marshal error"},
|
||||
},
|
||||
{
|
||||
name: "nested unmarshalable field causes error",
|
||||
message: "nested error",
|
||||
fields: map[string]interface{}{
|
||||
"nested": map[string]interface{}{
|
||||
"channel": make(chan int),
|
||||
},
|
||||
},
|
||||
expectFallback: true,
|
||||
expectContains: []string{"[INFO]", "nested error", "json marshal error"},
|
||||
},
|
||||
{
|
||||
name: "empty fields marshal successfully",
|
||||
message: "no fields",
|
||||
fields: nil,
|
||||
expectFallback: false,
|
||||
expectContains: []string{`"message":"no fields"`},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf := &strings.Builder{}
|
||||
logger := New(LevelInfo, FormatJSON, &testWriter{Builder: buf})
|
||||
|
||||
logger.Info(tt.message, tt.fields)
|
||||
|
||||
output := buf.String()
|
||||
assert.NotEmpty(t, output, "Expected log output but got none")
|
||||
|
||||
if tt.expectFallback {
|
||||
// Should contain fallback text format indicators
|
||||
for _, expected := range tt.expectContains {
|
||||
assert.Contains(t, output, expected, "Expected fallback output to contain: %s", expected)
|
||||
}
|
||||
// Should NOT be valid JSON
|
||||
assert.False(t, strings.HasPrefix(output, "{"), "Fallback should not start with {")
|
||||
} else {
|
||||
// Should be valid JSON format
|
||||
for _, expected := range tt.expectContains {
|
||||
assert.Contains(t, output, expected, "Expected JSON output to contain: %s", expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteErrorHandling(t *testing.T) {
|
||||
tests := []struct {
|
||||
writeError error
|
||||
name string
|
||||
format Format
|
||||
expectPanic bool
|
||||
}{
|
||||
{
|
||||
name: "JSON format write error",
|
||||
format: FormatJSON,
|
||||
writeError: errors.New("write failed"),
|
||||
expectPanic: false, // Should silently ignore write errors
|
||||
},
|
||||
{
|
||||
name: "text format write error",
|
||||
format: FormatText,
|
||||
writeError: errors.New("disk full"),
|
||||
expectPanic: false, // Should silently ignore write errors
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Use a writer that always returns an error
|
||||
errWriter := &errorWriter{err: tt.writeError}
|
||||
logger := New(LevelInfo, tt.format, errWriter)
|
||||
|
||||
// This should not panic, even though write fails
|
||||
assert.NotPanics(t, func() {
|
||||
logger.Info("test message", map[string]interface{}{"key": "value"})
|
||||
}, "Logger should not panic on write error")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalErrorWithDifferentLevels(t *testing.T) {
|
||||
// Test that marshal error fallback works for all log levels
|
||||
levels := []struct {
|
||||
logFunc func(*Logger, string, map[string]interface{})
|
||||
levelStr string
|
||||
level Level
|
||||
}{
|
||||
{func(l *Logger, m string, f map[string]interface{}) { l.Debug(m, f) }, "DEBUG", LevelDebug},
|
||||
{func(l *Logger, m string, f map[string]interface{}) { l.Info(m, f) }, "INFO", LevelInfo},
|
||||
{func(l *Logger, m string, f map[string]interface{}) { l.Warn(m, f) }, "WARN", LevelWarn},
|
||||
{func(l *Logger, m string, f map[string]interface{}) { l.Error(m, f) }, "ERROR", LevelError},
|
||||
}
|
||||
|
||||
for _, lvl := range levels {
|
||||
t.Run(lvl.levelStr, func(t *testing.T) {
|
||||
buf := &strings.Builder{}
|
||||
logger := New(lvl.level, FormatJSON, &testWriter{Builder: buf})
|
||||
|
||||
// Use unmarshalable field to trigger error
|
||||
lvl.logFunc(logger, "error test", map[string]interface{}{
|
||||
"bad": make(chan int),
|
||||
})
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "["+lvl.levelStr+"]", "Fallback should contain correct level")
|
||||
assert.Contains(t, output, "error test", "Fallback should contain message")
|
||||
assert.Contains(t, output, "json marshal error", "Fallback should indicate marshal error")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// testWriter wraps strings.Builder to implement io.Writer
|
||||
type testWriter struct {
|
||||
*strings.Builder
|
||||
}
|
||||
|
||||
func (w *testWriter) Write(p []byte) (n int, err error) {
|
||||
return w.Builder.Write(p)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/grandcat/zeroconf"
|
||||
"github.com/nvm/kportal/internal/logger"
|
||||
"github.com/lukaszraczylo/kportal/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -28,8 +28,8 @@ import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/lipgloss/table"
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/k8s"
|
||||
)
|
||||
|
||||
// safeRecover recovers from panics and logs them
|
||||
|
||||
@@ -3,7 +3,7 @@ package ui
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
"github.com/lukaszraczylo/kportal/internal/k8s"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/k8s"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -3,8 +3,8 @@ package ui
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/k8s"
|
||||
)
|
||||
|
||||
// DiscoveryInterface defines the interface for Kubernetes discovery operations
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/k8s"
|
||||
)
|
||||
|
||||
// MockDiscovery is a mock implementation of DiscoveryInterface for testing
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
)
|
||||
|
||||
// ForwardStatus represents the current status of a port forward
|
||||
|
||||
@@ -6,10 +6,10 @@ import (
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/nvm/kportal/internal/benchmark"
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
"github.com/nvm/kportal/internal/logger"
|
||||
"github.com/lukaszraczylo/kportal/internal/benchmark"
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/k8s"
|
||||
"github.com/lukaszraczylo/kportal/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -3,7 +3,7 @@ package ui
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/k8s"
|
||||
)
|
||||
|
||||
// isFilterableStep returns true if the step supports search/filter
|
||||
|
||||
@@ -3,7 +3,7 @@ package ui
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
"github.com/lukaszraczylo/kportal/internal/k8s"
|
||||
)
|
||||
|
||||
// filterStrings filters a slice of strings by a search filter (case-insensitive substring match)
|
||||
|
||||
@@ -3,7 +3,7 @@ package ui
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
"github.com/lukaszraczylo/kportal/internal/k8s"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user