WIP - before the testing.

This commit is contained in:
2025-11-23 15:24:51 +00:00
parent 3fee780dea
commit f50f0a9b49
21 changed files with 4582 additions and 13 deletions
+46
View File
@@ -0,0 +1,46 @@
# Example kportal configuration
# Copy this file to your project and customize as needed
contexts:
# Production context
- name: production
namespaces:
- name: default
forwards:
# Forward to API service
- resource: service/api
protocol: tcp
port: 8080
localPort: 8080
# Forward to PostgreSQL database
- resource: service/postgres
protocol: tcp
port: 5432
localPort: 5432
- name: monitoring
forwards:
# Forward to Prometheus using label selector
- resource: pod
selector: app=prometheus
protocol: tcp
port: 9090
localPort: 9090
# Staging context
- name: staging
namespaces:
- name: default
forwards:
# Forward to staging app (prefix matching handles pod restarts)
- resource: pod/myapp
protocol: tcp
port: 8080
localPort: 8081
# Forward multiple ports from same pod
- resource: pod/myapp
protocol: tcp
port: 9090
localPort: 9091
+226
View File
@@ -0,0 +1,226 @@
.PHONY: build clean test vet staticcheck fmt install-tools all install uninstall version
# Binary name
BINARY=kportal
# Version management using semver-gen
# If semver-gen is available, use it; otherwise fallback to 0.1.0-dev
VERSION?=$(shell which semver-gen > /dev/null 2>&1 && semver-gen generate -l 2>/dev/null | sed 's/SEMVER //' || echo "0.1.0-dev")
# Build directory
BUILD_DIR=.
# Installation directories
PREFIX?=/usr/local
INSTALL_BIN_DIR=$(PREFIX)/bin
# Detect user's local bin directory
ifeq ($(shell uname), Darwin)
# macOS - prefer Homebrew location if it exists, otherwise /usr/local
USER_BIN_DIR=$(shell [ -d /opt/homebrew/bin ] && echo /opt/homebrew/bin || echo $(HOME)/.local/bin)
else ifeq ($(shell uname), Linux)
# Linux - use ~/.local/bin (typically in PATH)
USER_BIN_DIR=$(HOME)/.local/bin
else
# Fallback
USER_BIN_DIR=$(HOME)/bin
endif
# Go parameters
GOCMD=go
GOBUILD=$(GOCMD) build
GOCLEAN=$(GOCMD) clean
GOTEST=$(GOCMD) test
GOGET=$(GOCMD) get
GOVET=$(GOCMD) vet
GOFMT=$(GOCMD) fmt
# Build flags
BUILD_FLAGS=-buildvcs=false
LDFLAGS=-ldflags="-s -w -X main.version=$(VERSION)"
all: fmt vet staticcheck test build
build:
@echo "Building $(BINARY)..."
$(GOBUILD) $(BUILD_FLAGS) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY) ./cmd/kportal
@echo "Build complete: $(BUILD_DIR)/$(BINARY)"
clean:
@echo "Cleaning..."
$(GOCLEAN)
rm -f $(BUILD_DIR)/$(BINARY)
@echo "Clean complete"
test:
@echo "Running tests..."
$(GOTEST) -v -race -cover ./...
@echo "Tests complete"
test-short:
@echo "Running short tests..."
$(GOTEST) -short ./...
@echo "Short tests complete"
vet:
@echo "Running go vet..."
$(GOVET) ./...
@echo "go vet passed"
fmt:
@echo "Formatting code..."
$(GOFMT) ./...
@echo "Format complete"
staticcheck:
@echo "Running staticcheck..."
@which staticcheck > /dev/null || (echo "staticcheck not found. Run 'make install-tools' to install it" && exit 1)
staticcheck ./...
@echo "staticcheck passed"
install-tools:
@echo "Installing development tools..."
$(GOGET) honnef.co/go/tools/cmd/staticcheck@latest
$(GOGET) github.com/vektra/mockery/v2@latest
@echo "Installing semver-gen for version management..."
@which semver-gen > /dev/null 2>&1 || \
(curl -s https://api.github.com/repos/lukaszraczylo/semver-generator/releases/latest | \
grep "browser_download_url.*$(shell uname -s | tr '[:upper:]' '[:lower:]').*$(shell uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/')" | \
cut -d '"' -f 4 | \
xargs curl -L -o /tmp/semver-gen && \
chmod +x /tmp/semver-gen && \
mv /tmp/semver-gen $(USER_BIN_DIR)/semver-gen)
@echo "Tools installed"
run: build
@echo "Running $(BINARY)..."
./$(BINARY)
run-verbose: build
@echo "Running $(BINARY) in verbose mode..."
./$(BINARY) -v
version:
@echo "Current version: $(VERSION)"
@if which semver-gen > /dev/null 2>&1; then \
echo "Version generated by semver-gen"; \
else \
echo "semver-gen not installed (using fallback version)"; \
echo "Run 'make install-tools' to install semver-gen"; \
fi
check: fmt vet staticcheck
@echo "All checks passed"
install: build
@echo "Installing $(BINARY)..."
@mkdir -p $(USER_BIN_DIR)
@install -m 755 $(BUILD_DIR)/$(BINARY) $(USER_BIN_DIR)/$(BINARY)
@echo ""
@echo "$(BINARY) successfully installed to $(USER_BIN_DIR)/$(BINARY)"
@echo ""
@echo "Verify installation:"
@echo " $(USER_BIN_DIR)/$(BINARY) --help"
@echo ""
@if echo "$$PATH" | grep -q "$(USER_BIN_DIR)"; then \
echo "$(USER_BIN_DIR) is in your PATH"; \
echo "You can now run: $(BINARY)"; \
else \
echo "⚠️ $(USER_BIN_DIR) is not in your PATH"; \
echo "Add it to your PATH by adding this to your shell config:"; \
echo " export PATH=\"$(USER_BIN_DIR):\$$PATH\""; \
fi
@echo ""
install-system: build
@echo "Installing $(BINARY) system-wide..."
@if [ "$$(id -u)" -ne 0 ]; then \
echo "System installation requires sudo privileges."; \
sudo install -m 755 $(BUILD_DIR)/$(BINARY) $(INSTALL_BIN_DIR)/$(BINARY); \
else \
install -m 755 $(BUILD_DIR)/$(BINARY) $(INSTALL_BIN_DIR)/$(BINARY); \
fi
@echo ""
@echo "$(BINARY) successfully installed to $(INSTALL_BIN_DIR)/$(BINARY)"
@echo "You can now run: $(BINARY)"
@echo ""
uninstall:
@echo "Uninstalling $(BINARY)..."
@if [ -f "$(USER_BIN_DIR)/$(BINARY)" ]; then \
rm -f $(USER_BIN_DIR)/$(BINARY); \
echo "✅ Removed $(USER_BIN_DIR)/$(BINARY)"; \
fi
@if [ -f "$(INSTALL_BIN_DIR)/$(BINARY)" ]; then \
if [ "$$(id -u)" -ne 0 ]; then \
sudo rm -f $(INSTALL_BIN_DIR)/$(BINARY); \
else \
rm -f $(INSTALL_BIN_DIR)/$(BINARY); \
fi; \
echo "✅ Removed $(INSTALL_BIN_DIR)/$(BINARY)"; \
fi
@echo "Uninstall complete"
# Generate mocks
mocks:
@echo "Generating mocks..."
mockery --name=K8sClientInterface --dir=internal/k8s --output=internal/mocks
mockery --name=ResourceResolver --dir=internal/k8s --output=internal/mocks
mockery --name=PortForwarder --dir=internal/k8s --output=internal/mocks
@echo "Mocks generated"
# Run integration tests
test-integration:
@echo "Running integration tests..."
$(GOTEST) -v ./test/integration/...
@echo "Integration tests complete"
# Coverage report
coverage:
@echo "Generating coverage report..."
$(GOTEST) -coverprofile=coverage.out ./...
$(GOCMD) tool cover -html=coverage.out -o coverage.html
@echo "Coverage report generated: coverage.html"
# Show help
help:
@echo "kportal - Kubernetes Port Forwarding Tool"
@echo ""
@echo "Available targets:"
@echo ""
@echo "Build & Install:"
@echo " build - Build the binary in current directory"
@echo " install - Install to user bin directory ($(USER_BIN_DIR))"
@echo " install-system - Install system-wide ($(INSTALL_BIN_DIR), requires sudo)"
@echo " uninstall - Remove installed binary from all locations"
@echo " clean - Remove build artifacts"
@echo ""
@echo "Testing:"
@echo " test - Run all tests with race detection and coverage"
@echo " test-short - Run short tests only"
@echo " test-integration- Run integration tests"
@echo " coverage - Generate coverage report"
@echo ""
@echo "Code Quality:"
@echo " fmt - Format code with go fmt"
@echo " vet - Run go vet"
@echo " staticcheck - Run staticcheck"
@echo " check - Run fmt, vet, and staticcheck"
@echo " all - Run fmt, vet, staticcheck, test, and build"
@echo ""
@echo "Development:"
@echo " run - Build and run the binary"
@echo " run-verbose - Build and run with verbose logging"
@echo " version - Display current version (from semver-gen or fallback)"
@echo " install-tools - Install development tools (staticcheck, mockery, semver-gen)"
@echo " mocks - Generate test mocks"
@echo ""
@echo "Variables:"
@echo " VERSION - Set version (default: $(VERSION))"
@echo " PREFIX - Installation prefix (default: $(PREFIX))"
@echo ""
@echo "Examples:"
@echo " make build # Build binary"
@echo " make install # Install for current user"
@echo " sudo make install-system # Install system-wide"
@echo " make VERSION=1.0.0 build # Build with custom version"
+320
View File
@@ -0,0 +1,320 @@
# kportal
A robust Kubernetes port-forwarding tool that manages multiple concurrent port-forwards across different contexts, namespaces, and resources with automatic reconnection and failure recovery.
## Features
- **Multi-Context Support**: Forward ports from multiple Kubernetes contexts simultaneously
- **Automatic Pod Restart Handling**: Detects and reconnects to pods when they restart
- **Label Selector Support**: Dynamically target pods using label selectors
- **Prefix Matching**: Automatically find and reconnect to pods with name prefixes
- **Hot-Reload**: Configuration file changes are automatically detected and applied
- **Resilient Connections**: Infinite retry with exponential backoff (max 10s)
- **Port Conflict Detection**: Validates port availability before starting
- **Multiple Ports Per Resource**: Forward multiple ports from the same pod/service
## Installation
```bash
# Install development tools (including semver-gen for version management)
make install-tools
# Build from source (version automatically generated from git history)
make build
# Install to user bin directory
make install
# Install system-wide (requires sudo)
sudo make install-system
# Or build manually
go build -o kportal ./cmd/kportal
```
## Usage
### Basic Usage
```bash
# Use default config file (.kportal.yaml)
./kportal
# Use custom config file
./kportal -c myconfig.yaml
# Enable verbose logging
./kportal -v
# Validate configuration without starting
./kportal --check
```
### Configuration File
Create a `.kportal.yaml` file in your current directory:
```yaml
contexts:
- name: production
namespaces:
- name: default
forwards:
# Pod with prefix matching (auto-handles restarts)
- resource: pod/my-app
protocol: tcp
port: 8080
localPort: 8080
# Service forwarding
- resource: service/postgres
protocol: tcp
port: 5432
localPort: 5432
- name: monitoring
forwards:
# Pod with label selector
- resource: pod
selector: app=prometheus
protocol: tcp
port: 9090
localPort: 9090
- name: staging
namespaces:
- name: default
forwards:
# Multiple ports from same pod
- resource: pod/test-app
port: 8080
localPort: 8081
- resource: pod/test-app
port: 9090
localPort: 9091
```
### Resource Types
#### Pod with Prefix Matching
```yaml
- resource: pod/my-app # Matches my-app-xyz789, my-app-abc123, etc.
port: 8080
localPort: 8080
```
Automatically reconnects to new pods when they restart.
#### Pod with Label Selector
```yaml
- resource: pod
selector: app=nginx,env=prod
port: 80
localPort: 8080
```
Dynamically selects pods matching the label selector.
#### Service
```yaml
- resource: service/postgres
port: 5432
localPort: 5432
```
Most stable option - forwards to service endpoints.
## How It Works
### Pod Restart Handling
When a pod restarts:
1. The port-forward connection breaks
2. kportal immediately attempts to re-resolve the resource
3. For prefix matches: finds the newest pod with that prefix
4. For selectors: re-queries pods with matching labels
5. Reconnects to the new pod
6. Logs the switch: `Switched to new pod: old-pod → new-pod`
### Retry Strategy
Backoff intervals: **1s → 2s → 4s → 8s → 10s (max)**
- Connection failures trigger immediate resource re-resolution
- Retries continue indefinitely until successful
- Each forward has independent retry logic
### Hot-Reload
The config file is watched for changes:
1. File change detected
2. New config loaded and validated
3. Changes diff'd against current state
4. New forwards started, removed forwards stopped
5. Unchanged forwards continue running
If validation fails, the previous configuration remains active.
## Development
### Build Commands
```bash
# Build binary
make build
# Check current version (from semver-gen)
make version
# Run all checks (fmt, vet, staticcheck, test, build)
make all
# Run tests with race detection
make test
# Run code quality checks
make vet
make staticcheck
make fmt
# Install development tools (staticcheck, mockery, semver-gen)
make install-tools
# Generate test coverage report
make coverage
```
### Semantic Versioning
This project uses [semver-gen](https://github.com/lukaszraczylo/semver-generator) for automatic semantic version generation based on git commit messages.
**Version Keywords:**
- **Patch** (0.0.X): `fix`, `bugfix`, `hotfix`, `patch`, `docs`, `test`, `refactor`
- **Minor** (0.X.0): `feat`, `feature`, `add`, `enhance`, `update`, `improve`
- **Major** (X.0.0): `breaking`, `major`, `BREAKING CHANGE`
The version is automatically calculated from your git history and embedded in the binary at build time.
```bash
# Check current version
make version
# Build with auto-generated version
make build
# Verify version in binary
./kportal --version
```
Configuration is managed in `semver.yaml`. To manually install semver-gen:
```bash
# Automatically installed via make install-tools
# Or install manually from https://github.com/lukaszraczylo/semver-generator
```
### Project Structure
```
kportal/
├── cmd/kportal/ # CLI entry point
├── internal/
│ ├── config/ # Configuration parsing and validation
│ ├── forward/ # Port-forward workers and manager
│ ├── k8s/ # Kubernetes client, resolver, port-forward wrapper
│ └── retry/ # Exponential backoff logic
├── test/
│ ├── integration/ # Integration tests
│ ├── fixtures/ # Test configurations
│ └── helpers/ # Test utilities
├── .kportal.yaml # Example configuration
├── semver.yaml # Semantic version configuration
├── Makefile # Build automation
└── CLAUDE.md # Development guide
```
## Signal Handling
- `CTRL+C` / `SIGTERM`: Graceful shutdown (closes all forwards)
- `SIGHUP`: Reload configuration file
## Port Conflict Detection
kportal validates ports at multiple stages:
1. **Config Parse Time**: Detects duplicate local ports in configuration
2. **Startup Time**: Checks if ports are available on the system
3. **Hot-Reload Time**: Validates new ports before applying changes
Errors show which process is using conflicting ports (with PID).
## Examples
### Forward Multiple Services from Production
```yaml
contexts:
- name: production
namespaces:
- name: default
forwards:
- resource: service/api
port: 8080
localPort: 8080
- resource: service/postgres
port: 5432
localPort: 5432
- resource: service/redis
port: 6379
localPort: 6379
```
### Monitor Multiple Environments
```yaml
contexts:
- name: production
namespaces:
- name: monitoring
forwards:
- resource: service/prometheus
port: 9090
localPort: 9090
- name: staging
namespaces:
- name: monitoring
forwards:
- resource: service/prometheus
port: 9090
localPort: 9091 # Different local port
```
### Debug Specific Pods
```yaml
contexts:
- name: production
namespaces:
- name: default
forwards:
# Forward app HTTP and debug ports
- resource: pod
selector: app=myapp,version=v2
port: 8080
localPort: 8080
- resource: pod
selector: app=myapp,version=v2
port: 6060 # pprof
localPort: 6060
```
## License
MIT
## Contributing
Contributions welcome! Please ensure:
- Code passes `make check` (fmt, vet, staticcheck)
- Tests pass with `make test`
- New features include tests
+53
View File
@@ -0,0 +1,53 @@
module github.com/nvm/kportal
go 1.21
require (
github.com/fsnotify/fsnotify v1.7.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.29.0
k8s.io/apimachinery v0.29.0
k8s.io/client-go v0.29.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/go-logr/logr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.22.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/imdario/mergo v0.3.6 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/moby/spdystream v0.2.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/testify v1.11.1 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/oauth2 v0.12.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/term v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.3.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
k8s.io/klog/v2 v2.110.1 // indirect
k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect
k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)
+167
View File
@@ -0,0 +1,167 @@
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY=
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec=
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28=
github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8=
github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4=
github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o=
github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg=
github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4=
golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.12.0 h1:YW6HUoUmYBpwSgyaGaZq1fHjrBjX1rlpZ54T6mu2kss=
golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.29.0 h1:NiCdQMY1QOp1H8lfRyeEf8eOwV6+0xA6XEE44ohDX2A=
k8s.io/api v0.29.0/go.mod h1:sdVmXoz2Bo/cb77Pxi71IPTSErEW32xa4aXwKH7gfBA=
k8s.io/apimachinery v0.29.0 h1:+ACVktwyicPz0oc6MTMLwa2Pw3ouLAfAon1wPLtG48o=
k8s.io/apimachinery v0.29.0/go.mod h1:eVBxQ/cwiJxH58eK/jd/vAk4mrxmVlnpBH5J2GbMeis=
k8s.io/client-go v0.29.0 h1:KmlDtFcrdUzOYrBhXHgKw5ycWzc3ryPX5mQe0SkG3y8=
k8s.io/client-go v0.29.0/go.mod h1:yLkXH4HKMAywcrD82KMSmfYg2DlE8mepPR4JGSo5n38=
k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0=
k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo=
k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 h1:aVUu9fTY98ivBPKR9Y5w/AuzbMm96cd3YHRTU83I780=
k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA=
k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI=
k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4=
sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
+152
View File
@@ -0,0 +1,152 @@
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// Config represents the root configuration structure from .kportal.yaml
type Config struct {
Contexts []Context `yaml:"contexts"`
}
// Context represents a Kubernetes context with its namespaces
type Context struct {
Name string `yaml:"name"`
Namespaces []Namespace `yaml:"namespaces"`
}
// Namespace represents a Kubernetes namespace with its forwards
type Namespace struct {
Name string `yaml:"name"`
Forwards []Forward `yaml:"forwards"`
}
// Forward represents a single port-forward configuration
type Forward struct {
Resource string `yaml:"resource"` // e.g., "pod/my-app", "service/postgres", "pod"
Selector string `yaml:"selector"` // Label selector for pod resolution (e.g., "app=nginx,env=prod")
Protocol string `yaml:"protocol"` // tcp or udp
Port int `yaml:"port"` // Remote port
LocalPort int `yaml:"localPort"` // Local port
// Runtime fields (not in YAML)
contextName string
namespaceName string
}
// ID returns a unique identifier for this forward configuration.
// Format: context/namespace/resource:localPort
func (f *Forward) ID() string {
return fmt.Sprintf("%s/%s/%s:%d", f.contextName, f.namespaceName, f.Resource, f.LocalPort)
}
// String returns a human-readable representation of the forward.
// Format: context/namespace/resource:port→localPort
func (f *Forward) String() string {
if f.Selector != "" {
return fmt.Sprintf("%s/%s/%s[%s]:%d→%d",
f.contextName, f.namespaceName, f.Resource, f.Selector, f.Port, f.LocalPort)
}
return fmt.Sprintf("%s/%s/%s:%d→%d",
f.contextName, f.namespaceName, f.Resource, f.Port, f.LocalPort)
}
// SetContext sets the context and namespace names for this forward.
// This is used during config parsing to populate runtime fields.
func (f *Forward) SetContext(ctx, ns string) {
f.contextName = ctx
f.namespaceName = ns
}
// GetContext returns the context name for this forward.
func (f *Forward) GetContext() string {
return f.contextName
}
// GetNamespace returns the namespace name for this forward.
func (f *Forward) GetNamespace() string {
return f.namespaceName
}
// LoadConfig loads and parses the configuration file from the given path.
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
return ParseConfig(data)
}
// ParseConfig parses YAML configuration data into a Config struct.
func ParseConfig(data []byte) (*Config, error) {
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse YAML: %w", err)
}
// Populate runtime fields (context and namespace names)
for i := range cfg.Contexts {
ctx := &cfg.Contexts[i]
for j := range ctx.Namespaces {
ns := &ctx.Namespaces[j]
for k := range ns.Forwards {
fwd := &ns.Forwards[k]
fwd.SetContext(ctx.Name, ns.Name)
}
}
}
return &cfg, nil
}
// GetAllForwards returns a flat list of all forwards across all contexts and namespaces.
func (c *Config) GetAllForwards() []Forward {
var forwards []Forward
for _, ctx := range c.Contexts {
for _, ns := range ctx.Namespaces {
forwards = append(forwards, ns.Forwards...)
}
}
return forwards
}
// GetForwardsByContext returns all forwards for a specific context.
func (c *Config) GetForwardsByContext(contextName string) []Forward {
var forwards []Forward
for _, ctx := range c.Contexts {
if ctx.Name == contextName {
for _, ns := range ctx.Namespaces {
forwards = append(forwards, ns.Forwards...)
}
break
}
}
return forwards
}
// GetForwardsByNamespace returns all forwards for a specific context and namespace.
func (c *Config) GetForwardsByNamespace(contextName, namespaceName string) []Forward {
var forwards []Forward
for _, ctx := range c.Contexts {
if ctx.Name == contextName {
for _, ns := range ctx.Namespaces {
if ns.Name == namespaceName {
forwards = append(forwards, ns.Forwards...)
break
}
}
break
}
}
return forwards
}
+357
View File
@@ -0,0 +1,357 @@
package config
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestLoadConfig_ValidYAML(t *testing.T) {
// Create a temporary config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
validYAML := `contexts:
- name: dev-cluster
namespaces:
- name: default
forwards:
- resource: pod/my-app
protocol: tcp
port: 8080
localPort: 8080
- name: staging
forwards:
- resource: service/postgres
protocol: tcp
port: 5432
localPort: 5433
- name: prod-cluster
namespaces:
- name: production
forwards:
- resource: pod
selector: app=nginx,env=prod
protocol: tcp
port: 80
localPort: 8081
`
err := os.WriteFile(configPath, []byte(validYAML), 0644)
assert.NoError(t, err, "should write temp config file")
// Load the config
cfg, err := LoadConfig(configPath)
assert.NoError(t, err, "LoadConfig should succeed")
assert.NotNil(t, cfg, "config should not be nil")
// Verify structure
assert.Len(t, cfg.Contexts, 2, "should have 2 contexts")
// Verify first context
assert.Equal(t, "dev-cluster", cfg.Contexts[0].Name)
assert.Len(t, cfg.Contexts[0].Namespaces, 2, "dev-cluster should have 2 namespaces")
// Verify first namespace in first context
assert.Equal(t, "default", cfg.Contexts[0].Namespaces[0].Name)
assert.Len(t, cfg.Contexts[0].Namespaces[0].Forwards, 1)
// Verify forward details
fwd := cfg.Contexts[0].Namespaces[0].Forwards[0]
assert.Equal(t, "pod/my-app", fwd.Resource)
assert.Equal(t, "tcp", fwd.Protocol)
assert.Equal(t, 8080, fwd.Port)
assert.Equal(t, 8080, fwd.LocalPort)
assert.Equal(t, "", fwd.Selector)
// Verify runtime fields are populated
assert.Equal(t, "dev-cluster", fwd.GetContext())
assert.Equal(t, "default", fwd.GetNamespace())
}
func TestLoadConfig_InvalidYAML(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
invalidYAML := `contexts:
- name: dev-cluster
namespaces:
- name: default
forwards: [this is invalid yaml syntax
`
err := os.WriteFile(configPath, []byte(invalidYAML), 0644)
assert.NoError(t, err, "should write temp config file")
// Load the config
cfg, err := LoadConfig(configPath)
assert.Error(t, err, "LoadConfig should fail with invalid YAML")
assert.Nil(t, cfg, "config should be nil on error")
assert.Contains(t, err.Error(), "failed to parse YAML", "error should mention YAML parsing")
}
func TestLoadConfig_FileNotFound(t *testing.T) {
// Try to load a non-existent file
cfg, err := LoadConfig("/non/existent/path/.kportal.yaml")
assert.Error(t, err, "LoadConfig should fail with non-existent file")
assert.Nil(t, cfg, "config should be nil on error")
assert.Contains(t, err.Error(), "failed to read config file", "error should mention read failure")
}
func TestForward_ID(t *testing.T) {
tests := []struct {
name string
forward Forward
expectedID string
}{
{
name: "pod with explicit name",
forward: Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
expectedID: "dev-cluster/default/pod/my-app:8080",
},
{
name: "service resource",
forward: Forward{
Resource: "service/postgres",
Port: 5432,
LocalPort: 5433,
contextName: "prod-cluster",
namespaceName: "database",
},
expectedID: "prod-cluster/database/service/postgres:5433",
},
{
name: "pod with selector",
forward: Forward{
Resource: "pod",
Selector: "app=nginx",
Port: 80,
LocalPort: 8081,
contextName: "staging",
namespaceName: "web",
},
expectedID: "staging/web/pod:8081",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
id := tt.forward.ID()
assert.Equal(t, tt.expectedID, id, "ID() should return correct format")
})
}
}
func TestForward_String(t *testing.T) {
tests := []struct {
name string
forward Forward
expectedString string
}{
{
name: "pod without selector",
forward: Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
expectedString: "dev-cluster/default/pod/my-app:8080→8080",
},
{
name: "service resource",
forward: Forward{
Resource: "service/postgres",
Port: 5432,
LocalPort: 5433,
contextName: "prod-cluster",
namespaceName: "database",
},
expectedString: "prod-cluster/database/service/postgres:5432→5433",
},
{
name: "pod with selector",
forward: Forward{
Resource: "pod",
Selector: "app=nginx,env=prod",
Port: 80,
LocalPort: 8081,
contextName: "staging",
namespaceName: "web",
},
expectedString: "staging/web/pod[app=nginx,env=prod]:80→8081",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
str := tt.forward.String()
assert.Equal(t, tt.expectedString, str, "String() should return correct format")
})
}
}
func TestParseConfig_ValidYAML(t *testing.T) {
yamlData := []byte(`contexts:
- name: test-cluster
namespaces:
- name: default
forwards:
- resource: pod/app
protocol: tcp
port: 8080
localPort: 8080
`)
cfg, err := ParseConfig(yamlData)
assert.NoError(t, err, "ParseConfig should succeed")
assert.NotNil(t, cfg, "config should not be nil")
assert.Len(t, cfg.Contexts, 1)
assert.Equal(t, "test-cluster", cfg.Contexts[0].Name)
}
func TestParseConfig_PopulatesRuntimeFields(t *testing.T) {
yamlData := []byte(`contexts:
- name: my-cluster
namespaces:
- name: my-namespace
forwards:
- resource: pod/my-pod
port: 8080
localPort: 8080
`)
cfg, err := ParseConfig(yamlData)
assert.NoError(t, err)
assert.NotNil(t, cfg)
// Check that runtime fields are populated
fwd := cfg.Contexts[0].Namespaces[0].Forwards[0]
assert.Equal(t, "my-cluster", fwd.GetContext())
assert.Equal(t, "my-namespace", fwd.GetNamespace())
assert.Equal(t, "my-cluster/my-namespace/pod/my-pod:8080", fwd.ID())
}
func TestConfig_GetAllForwards(t *testing.T) {
yamlData := []byte(`contexts:
- name: cluster1
namespaces:
- name: ns1
forwards:
- resource: pod/app1
port: 8080
localPort: 8080
- resource: pod/app2
port: 8081
localPort: 8081
- name: ns2
forwards:
- resource: service/db
port: 5432
localPort: 5432
- name: cluster2
namespaces:
- name: ns3
forwards:
- resource: pod/app3
port: 9090
localPort: 9090
`)
cfg, err := ParseConfig(yamlData)
assert.NoError(t, err)
forwards := cfg.GetAllForwards()
assert.Len(t, forwards, 4, "should return all forwards from all contexts and namespaces")
}
func TestConfig_GetForwardsByContext(t *testing.T) {
yamlData := []byte(`contexts:
- name: cluster1
namespaces:
- name: ns1
forwards:
- resource: pod/app1
port: 8080
localPort: 8080
- resource: pod/app2
port: 8081
localPort: 8081
- name: cluster2
namespaces:
- name: ns2
forwards:
- resource: pod/app3
port: 9090
localPort: 9090
`)
cfg, err := ParseConfig(yamlData)
assert.NoError(t, err)
forwards := cfg.GetForwardsByContext("cluster1")
assert.Len(t, forwards, 2, "should return forwards only from cluster1")
forwards2 := cfg.GetForwardsByContext("cluster2")
assert.Len(t, forwards2, 1, "should return forwards only from cluster2")
forwards3 := cfg.GetForwardsByContext("non-existent")
assert.Len(t, forwards3, 0, "should return empty slice for non-existent context")
}
func TestConfig_GetForwardsByNamespace(t *testing.T) {
yamlData := []byte(`contexts:
- name: cluster1
namespaces:
- name: ns1
forwards:
- resource: pod/app1
port: 8080
localPort: 8080
- resource: pod/app2
port: 8081
localPort: 8081
- name: ns2
forwards:
- resource: pod/app3
port: 9090
localPort: 9090
`)
cfg, err := ParseConfig(yamlData)
assert.NoError(t, err)
forwards := cfg.GetForwardsByNamespace("cluster1", "ns1")
assert.Len(t, forwards, 2, "should return forwards only from cluster1/ns1")
forwards2 := cfg.GetForwardsByNamespace("cluster1", "ns2")
assert.Len(t, forwards2, 1, "should return forwards only from cluster1/ns2")
forwards3 := cfg.GetForwardsByNamespace("cluster1", "non-existent")
assert.Len(t, forwards3, 0, "should return empty slice for non-existent namespace")
}
func TestForward_SetContext(t *testing.T) {
fwd := Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
}
assert.Equal(t, "", fwd.GetContext(), "initial context should be empty")
assert.Equal(t, "", fwd.GetNamespace(), "initial namespace should be empty")
fwd.SetContext("my-cluster", "my-namespace")
assert.Equal(t, "my-cluster", fwd.GetContext())
assert.Equal(t, "my-namespace", fwd.GetNamespace())
}
+267
View File
@@ -0,0 +1,267 @@
package config
import (
"fmt"
"strings"
)
const (
minPort = 1
maxPort = 65535
)
// ValidationError represents a configuration validation error with context.
type ValidationError struct {
Field string // The field that failed validation
Message string // Error message
Context map[string]string // Additional context information
}
// Error implements the error interface.
func (e ValidationError) Error() string {
return e.Message
}
// Validator validates configuration files.
type Validator struct{}
// NewValidator creates a new Validator instance.
func NewValidator() *Validator {
return &Validator{}
}
// ValidateConfig validates the entire configuration and returns all errors found.
func (v *Validator) ValidateConfig(cfg *Config) []ValidationError {
var errs []ValidationError
if cfg == nil {
return []ValidationError{{
Field: "config",
Message: "Configuration is nil",
}}
}
// Validate structure
errs = append(errs, v.validateStructure(cfg)...)
// Validate each forward
for _, ctx := range cfg.Contexts {
for _, ns := range ctx.Namespaces {
for _, fwd := range ns.Forwards {
errs = append(errs, v.validateForward(&fwd)...)
}
}
}
// Check for duplicate local ports
errs = append(errs, v.validateDuplicatePorts(cfg)...)
return errs
}
// validateStructure validates the basic structure of the configuration.
func (v *Validator) validateStructure(cfg *Config) []ValidationError {
var errs []ValidationError
if len(cfg.Contexts) == 0 {
errs = append(errs, ValidationError{
Field: "contexts",
Message: "Configuration must have at least one context",
})
return errs
}
for i, ctx := range cfg.Contexts {
if ctx.Name == "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("contexts[%d].name", i),
Message: "Context name cannot be empty",
})
}
if len(ctx.Namespaces) == 0 {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("contexts[%d].namespaces", i),
Message: fmt.Sprintf("Context '%s' must have at least one namespace", ctx.Name),
})
continue
}
for j, ns := range ctx.Namespaces {
if ns.Name == "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("contexts[%d].namespaces[%d].name", i, j),
Message: fmt.Sprintf("Namespace name cannot be empty in context '%s'", ctx.Name),
})
}
if len(ns.Forwards) == 0 {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("contexts[%d].namespaces[%d].forwards", i, j),
Message: fmt.Sprintf("Namespace '%s/%s' must have at least one forward", ctx.Name, ns.Name),
})
}
}
}
return errs
}
// validateForward validates a single forward configuration.
func (v *Validator) validateForward(fwd *Forward) []ValidationError {
var errs []ValidationError
// Validate resource
if fwd.Resource == "" {
errs = append(errs, ValidationError{
Field: "resource",
Message: fmt.Sprintf("Resource cannot be empty for forward %s", fwd.ID()),
})
} else {
errs = append(errs, v.validateResource(fwd)...)
}
// Validate protocol
if fwd.Protocol != "" && fwd.Protocol != "tcp" && fwd.Protocol != "udp" {
errs = append(errs, ValidationError{
Field: "protocol",
Message: fmt.Sprintf("Invalid protocol '%s' for forward %s (must be 'tcp' or 'udp')", fwd.Protocol, fwd.ID()),
})
}
// Validate ports
if fwd.Port < minPort || fwd.Port > maxPort {
errs = append(errs, ValidationError{
Field: "port",
Message: fmt.Sprintf("Invalid port %d for forward %s (must be between %d and %d)", fwd.Port, fwd.ID(), minPort, maxPort),
})
}
if fwd.LocalPort < minPort || fwd.LocalPort > maxPort {
errs = append(errs, ValidationError{
Field: "localPort",
Message: fmt.Sprintf("Invalid localPort %d for forward %s (must be between %d and %d)", fwd.LocalPort, fwd.ID(), minPort, maxPort),
})
}
return errs
}
// validateResource validates the resource field format and selector usage.
func (v *Validator) validateResource(fwd *Forward) []ValidationError {
var errs []ValidationError
parts := strings.SplitN(fwd.Resource, "/", 2)
resourceType := parts[0]
// Valid resource types: pod, service
if resourceType != "pod" && resourceType != "service" {
errs = append(errs, ValidationError{
Field: "resource",
Message: fmt.Sprintf("Invalid resource type '%s' for forward %s (must be 'pod' or 'service')", resourceType, fwd.ID()),
})
return errs
}
// For pod resources
if resourceType == "pod" {
if len(parts) == 2 {
// pod/name format - should not have selector
if fwd.Selector != "" {
errs = append(errs, ValidationError{
Field: "selector",
Message: fmt.Sprintf("Forward %s uses explicit pod name (%s) and should not have a selector", fwd.ID(), fwd.Resource),
})
}
// Validate pod name is not empty
if parts[1] == "" {
errs = append(errs, ValidationError{
Field: "resource",
Message: fmt.Sprintf("Pod name cannot be empty for forward %s", fwd.ID()),
})
}
} else {
// pod (no name) - must have selector
if fwd.Selector == "" {
errs = append(errs, ValidationError{
Field: "selector",
Message: fmt.Sprintf("Forward %s uses generic 'pod' resource and must have a selector", fwd.ID()),
})
}
}
}
// For service resources
if resourceType == "service" {
if len(parts) < 2 || parts[1] == "" {
errs = append(errs, ValidationError{
Field: "resource",
Message: fmt.Sprintf("Service name cannot be empty for forward %s", fwd.ID()),
})
}
if fwd.Selector != "" {
errs = append(errs, ValidationError{
Field: "selector",
Message: fmt.Sprintf("Forward %s uses service resource and should not have a selector", fwd.ID()),
})
}
}
return errs
}
// validateDuplicatePorts checks for duplicate local ports across all forwards.
func (v *Validator) validateDuplicatePorts(cfg *Config) []ValidationError {
var errs []ValidationError
portMap := make(map[int][]string) // port -> list of forward IDs
for _, ctx := range cfg.Contexts {
for _, ns := range ctx.Namespaces {
for _, fwd := range ns.Forwards {
portMap[fwd.LocalPort] = append(portMap[fwd.LocalPort], fwd.ID())
}
}
}
// Find duplicates
for port, forwards := range portMap {
if len(forwards) > 1 {
errs = append(errs, ValidationError{
Field: "localPort",
Message: fmt.Sprintf("Duplicate local port %d used by multiple forwards", port),
Context: map[string]string{
"port": fmt.Sprintf("%d", port),
"forwards": strings.Join(forwards, ", "),
},
})
}
}
return errs
}
// FormatValidationErrors formats validation errors into a human-readable string.
func FormatValidationErrors(errs []ValidationError) string {
if len(errs) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("\nConfiguration Validation Errors:\n")
sb.WriteString(strings.Repeat("=", 50) + "\n\n")
for i, err := range errs {
sb.WriteString(fmt.Sprintf("%d. %s\n", i+1, err.Message))
if len(err.Context) > 0 {
for k, v := range err.Context {
sb.WriteString(fmt.Sprintf(" %s: %s\n", k, v))
}
}
sb.WriteString("\n")
}
return sb.String()
}
+703
View File
@@ -0,0 +1,703 @@
package config
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestValidator_ValidateConfig(t *testing.T) {
validator := NewValidator()
tests := []struct {
name string
config *Config
expectErrors bool
errorContains []string
}{
{
name: "valid config",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/my-app",
Protocol: "tcp",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: false,
},
{
name: "nil config",
config: nil,
expectErrors: true,
errorContains: []string{"Configuration is nil"},
},
{
name: "empty contexts",
config: &Config{
Contexts: []Context{},
},
expectErrors: true,
errorContains: []string{"must have at least one context"},
},
{
name: "empty namespaces",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{},
},
},
},
expectErrors: true,
errorContains: []string{"must have at least one namespace"},
},
{
name: "empty forwards",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"must have at least one forward"},
},
{
name: "invalid port - zero",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/my-app",
Protocol: "tcp",
Port: 0,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Invalid port 0"},
},
{
name: "invalid port - above max",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/my-app",
Protocol: "tcp",
Port: 8080,
LocalPort: 65536,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Invalid localPort 65536"},
},
{
name: "invalid protocol",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/my-app",
Protocol: "http",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Invalid protocol 'http'", "must be 'tcp' or 'udp'"},
},
{
name: "empty resource",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "",
Protocol: "tcp",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Resource cannot be empty"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.ValidateConfig(tt.config)
if tt.expectErrors {
assert.NotEmpty(t, errs, "expected validation errors")
// Check that expected error messages are present
for _, expectedMsg := range tt.errorContains {
found := false
for _, err := range errs {
if strings.Contains(err.Message, expectedMsg) {
found = true
break
}
}
assert.True(t, found, "expected error message '%s' not found", expectedMsg)
}
} else {
assert.Empty(t, errs, "expected no validation errors")
}
})
}
}
func TestValidator_ValidateResourceFormat(t *testing.T) {
validator := NewValidator()
tests := []struct {
name string
forward Forward
expectErrors bool
errorContains []string
}{
{
name: "valid pod with name",
forward: Forward{
Resource: "pod/my-app",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: false,
},
{
name: "valid service with name",
forward: Forward{
Resource: "service/postgres",
Port: 5432,
LocalPort: 5432,
contextName: "dev",
namespaceName: "default",
},
expectErrors: false,
},
{
name: "valid pod with selector (no name)",
forward: Forward{
Resource: "pod",
Selector: "app=nginx",
Port: 80,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: false,
},
{
name: "invalid resource type",
forward: Forward{
Resource: "deployment/my-app",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: true,
errorContains: []string{"Invalid resource type 'deployment'"},
},
{
name: "pod with name and selector (invalid)",
forward: Forward{
Resource: "pod/my-app",
Selector: "app=nginx",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: true,
errorContains: []string{"should not have a selector"},
},
{
name: "pod without name and without selector (invalid)",
forward: Forward{
Resource: "pod",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: true,
errorContains: []string{"must have a selector"},
},
{
name: "service without name (invalid)",
forward: Forward{
Resource: "service",
Port: 5432,
LocalPort: 5432,
contextName: "dev",
namespaceName: "default",
},
expectErrors: true,
errorContains: []string{"Service name cannot be empty"},
},
{
name: "service with selector (invalid)",
forward: Forward{
Resource: "service/postgres",
Selector: "app=db",
Port: 5432,
LocalPort: 5432,
contextName: "dev",
namespaceName: "default",
},
expectErrors: true,
errorContains: []string{"should not have a selector"},
},
{
name: "pod with empty name after slash",
forward: Forward{
Resource: "pod/",
Port: 8080,
LocalPort: 8080,
contextName: "dev",
namespaceName: "default",
},
expectErrors: true,
errorContains: []string{"Pod name cannot be empty"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.validateForward(&tt.forward)
if tt.expectErrors {
assert.NotEmpty(t, errs, "expected validation errors")
// Check that expected error messages are present
for _, expectedMsg := range tt.errorContains {
found := false
for _, err := range errs {
if strings.Contains(err.Message, expectedMsg) {
found = true
break
}
}
assert.True(t, found, "expected error message '%s' not found", expectedMsg)
}
} else {
assert.Empty(t, errs, "expected no validation errors")
}
})
}
}
func TestValidator_CheckDuplicatePorts(t *testing.T) {
validator := NewValidator()
tests := []struct {
name string
config *Config
expectErrors bool
errorContains []string
}{
{
name: "no duplicate ports",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/app1",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
{
Resource: "pod/app2",
Port: 8081,
LocalPort: 8081,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: false,
},
{
name: "duplicate ports in same namespace",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/app1",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
{
Resource: "pod/app2",
Port: 8081,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Duplicate local port 8080"},
},
{
name: "duplicate ports across namespaces",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "ns1",
Forwards: []Forward{
{
Resource: "pod/app1",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "ns1",
},
},
},
{
Name: "ns2",
Forwards: []Forward{
{
Resource: "pod/app2",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "ns2",
},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Duplicate local port 8080"},
},
{
name: "duplicate ports across contexts",
config: &Config{
Contexts: []Context{
{
Name: "cluster1",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/app1",
Port: 8080,
LocalPort: 8080,
contextName: "cluster1",
namespaceName: "default",
},
},
},
},
},
{
Name: "cluster2",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/app2",
Port: 8080,
LocalPort: 8080,
contextName: "cluster2",
namespaceName: "default",
},
},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Duplicate local port 8080"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.validateDuplicatePorts(tt.config)
if tt.expectErrors {
assert.NotEmpty(t, errs, "expected validation errors")
// Check that expected error messages are present
for _, expectedMsg := range tt.errorContains {
found := false
for _, err := range errs {
if strings.Contains(err.Message, expectedMsg) {
found = true
break
}
}
assert.True(t, found, "expected error message '%s' not found", expectedMsg)
}
} else {
assert.Empty(t, errs, "expected no validation errors")
}
})
}
}
func TestFormatValidationErrors(t *testing.T) {
tests := []struct {
name string
errors []ValidationError
expectEmpty bool
expectContains []string
}{
{
name: "no errors",
errors: []ValidationError{},
expectEmpty: true,
},
{
name: "single error",
errors: []ValidationError{
{
Field: "port",
Message: "Invalid port 0",
},
},
expectEmpty: false,
expectContains: []string{"Configuration Validation Errors", "1. Invalid port 0"},
},
{
name: "multiple errors",
errors: []ValidationError{
{
Field: "port",
Message: "Invalid port 0",
},
{
Field: "resource",
Message: "Resource cannot be empty",
},
},
expectEmpty: false,
expectContains: []string{"Configuration Validation Errors", "1. Invalid port 0", "2. Resource cannot be empty"},
},
{
name: "error with context",
errors: []ValidationError{
{
Field: "localPort",
Message: "Duplicate local port 8080",
Context: map[string]string{
"port": "8080",
"forwards": "dev/default/pod/app1:8080, dev/default/pod/app2:8080",
},
},
},
expectEmpty: false,
expectContains: []string{"Configuration Validation Errors", "Duplicate local port 8080", "port:", "8080", "forwards:"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output := FormatValidationErrors(tt.errors)
if tt.expectEmpty {
assert.Empty(t, output, "expected empty output")
} else {
assert.NotEmpty(t, output, "expected non-empty output")
// Check that expected strings are present
for _, expected := range tt.expectContains {
assert.Contains(t, output, expected, "output should contain '%s'", expected)
}
}
})
}
}
func TestValidationError_Error(t *testing.T) {
err := ValidationError{
Field: "port",
Message: "Invalid port 0",
}
assert.Equal(t, "Invalid port 0", err.Error(), "Error() should return the message")
}
func TestValidator_ValidateStructure(t *testing.T) {
validator := NewValidator()
tests := []struct {
name string
config *Config
expectErrors bool
errorContains []string
}{
{
name: "empty context name",
config: &Config{
Contexts: []Context{
{
Name: "",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{{Resource: "pod/app", Port: 8080, LocalPort: 8080}},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Context name cannot be empty"},
},
{
name: "empty namespace name",
config: &Config{
Contexts: []Context{
{
Name: "dev",
Namespaces: []Namespace{
{
Name: "",
Forwards: []Forward{{Resource: "pod/app", Port: 8080, LocalPort: 8080}},
},
},
},
},
},
expectErrors: true,
errorContains: []string{"Namespace name cannot be empty"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.validateStructure(tt.config)
if tt.expectErrors {
assert.NotEmpty(t, errs, "expected validation errors")
// Check that expected error messages are present
for _, expectedMsg := range tt.errorContains {
found := false
for _, err := range errs {
if strings.Contains(err.Message, expectedMsg) {
found = true
break
}
}
assert.True(t, found, "expected error message '%s' not found", expectedMsg)
}
} else {
assert.Empty(t, errs, "expected no validation errors")
}
})
}
}
+140
View File
@@ -0,0 +1,140 @@
package config
import (
"fmt"
"log"
"path/filepath"
"github.com/fsnotify/fsnotify"
)
// ReloadCallback is called when the configuration file changes.
// It receives the new configuration and should return an error if the reload fails.
type ReloadCallback func(*Config) error
// Watcher watches a configuration file for changes and triggers hot-reload.
type Watcher struct {
configPath string
callback ReloadCallback
watcher *fsnotify.Watcher
done chan struct{}
verbose bool
}
// NewWatcher creates a new file watcher for the given config file.
func NewWatcher(configPath string, callback ReloadCallback, verbose bool) (*Watcher, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("failed to create file watcher: %w", err)
}
absPath, err := filepath.Abs(configPath)
if err != nil {
watcher.Close()
return nil, fmt.Errorf("failed to resolve absolute path: %w", err)
}
// Watch the directory instead of the file to handle atomic writes
// (many editors delete and recreate files on save)
dir := filepath.Dir(absPath)
if err := watcher.Add(dir); err != nil {
watcher.Close()
return nil, fmt.Errorf("failed to watch directory %s: %w", dir, err)
}
return &Watcher{
configPath: absPath,
callback: callback,
watcher: watcher,
done: make(chan struct{}),
verbose: verbose,
}, nil
}
// Start begins watching the configuration file for changes.
func (w *Watcher) Start() {
go w.watch()
}
// Stop stops watching the configuration file.
func (w *Watcher) Stop() {
close(w.done)
w.watcher.Close()
}
// watch runs the file watching loop.
func (w *Watcher) watch() {
if w.verbose {
log.Printf("Watching configuration file: %s", w.configPath)
}
for {
select {
case event, ok := <-w.watcher.Events:
if !ok {
return
}
// Only process events for our config file
eventPath, err := filepath.Abs(event.Name)
if err != nil {
if w.verbose {
log.Printf("Failed to resolve event path: %v", err)
}
continue
}
if eventPath != w.configPath {
continue
}
// Handle write and create events (create happens on atomic writes)
if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create {
if w.verbose {
log.Printf("Configuration file changed, reloading...")
}
w.handleReload()
}
case err, ok := <-w.watcher.Errors:
if !ok {
return
}
log.Printf("File watcher error: %v", err)
case <-w.done:
return
}
}
}
// handleReload loads and validates the new configuration, then calls the callback.
func (w *Watcher) handleReload() {
// Load new configuration
newCfg, err := LoadConfig(w.configPath)
if err != nil {
log.Printf("Failed to load configuration: %v", err)
log.Printf("Keeping previous configuration active")
return
}
// Validate new configuration
validator := NewValidator()
if errs := validator.ValidateConfig(newCfg); len(errs) > 0 {
log.Printf("Configuration validation failed:")
log.Print(FormatValidationErrors(errs))
log.Printf("Keeping previous configuration active")
return
}
// Call reload callback
if err := w.callback(newCfg); err != nil {
log.Printf("Failed to apply new configuration: %v", err)
log.Printf("Keeping previous configuration active")
return
}
if w.verbose {
log.Printf("Configuration reloaded successfully")
}
}
+298
View File
@@ -0,0 +1,298 @@
package forward
import (
"fmt"
"log"
"sync"
"github.com/nvm/kportal/internal/config"
"github.com/nvm/kportal/internal/k8s"
)
// Manager orchestrates all port-forward workers.
// It handles starting, stopping, and hot-reloading forwards.
type Manager struct {
workers map[string]*ForwardWorker // key: forward.ID()
workersMu sync.RWMutex
clientPool *k8s.ClientPool
resolver *k8s.ResourceResolver
portForwarder *k8s.PortForwarder
portChecker *PortChecker
verbose bool
currentConfig *config.Config
}
// NewManager creates a new forward Manager.
func NewManager(verbose bool) *Manager {
clientPool, err := k8s.NewClientPool()
if err != nil {
log.Fatalf("Failed to create client pool: %v", err)
}
resolver := k8s.NewResourceResolver(clientPool)
portForwarder := k8s.NewPortForwarder(clientPool, resolver)
return &Manager{
workers: make(map[string]*ForwardWorker),
clientPool: clientPool,
resolver: resolver,
portForwarder: portForwarder,
portChecker: NewPortChecker(),
verbose: verbose,
}
}
// Start initializes and starts all port-forwards from the configuration.
func (m *Manager) Start(cfg *config.Config) error {
if cfg == nil {
return fmt.Errorf("configuration is nil")
}
m.currentConfig = cfg
// Get all forwards from config
forwards := cfg.GetAllForwards()
if len(forwards) == 0 {
return fmt.Errorf("no forwards configured")
}
// Check port availability before starting
ports := m.extractPorts(forwards)
conflicts := m.portChecker.CheckAvailability(ports, nil)
if len(conflicts) > 0 {
// Add resource information to conflicts
for i := range conflicts {
conflicts[i].Resource = m.getResourceForPort(forwards, conflicts[i].Port)
}
return fmt.Errorf("port conflicts detected:\n%s", FormatConflicts(conflicts))
}
// Start all workers
log.Printf("Starting %d port-forward(s)...", len(forwards))
for _, fwd := range forwards {
if err := m.startWorker(fwd); err != nil {
log.Printf("Failed to start worker for %s: %v", fwd.ID(), err)
// Continue with other workers
}
}
log.Printf("All port-forwards started")
return nil
}
// Stop gracefully stops all port-forward workers.
func (m *Manager) Stop() {
log.Printf("Stopping all port-forwards...")
m.workersMu.Lock()
workers := make([]*ForwardWorker, 0, len(m.workers))
for _, worker := range m.workers {
workers = append(workers, worker)
}
m.workersMu.Unlock()
// Stop all workers
var wg sync.WaitGroup
for _, worker := range workers {
wg.Add(1)
go func(w *ForwardWorker) {
defer wg.Done()
w.Stop()
}(worker)
}
wg.Wait()
// Clear workers map
m.workersMu.Lock()
m.workers = make(map[string]*ForwardWorker)
m.workersMu.Unlock()
log.Printf("All port-forwards stopped")
}
// Reload applies a new configuration with hot-reload logic.
// It diffs the new config against the current one and:
// - Stops removed forwards
// - Keeps unchanged forwards running
// - Starts new forwards
func (m *Manager) Reload(newCfg *config.Config) error {
if newCfg == nil {
return fmt.Errorf("new configuration is nil")
}
log.Printf("Reloading configuration...")
// Get all forwards from new config
newForwards := newCfg.GetAllForwards()
if len(newForwards) == 0 {
log.Printf("New configuration has no forwards, stopping all")
m.Stop()
m.currentConfig = newCfg
return nil
}
// Create maps for easier comparison
newForwardsMap := make(map[string]config.Forward)
for _, fwd := range newForwards {
newForwardsMap[fwd.ID()] = fwd
}
m.workersMu.RLock()
currentForwardsMap := make(map[string]config.Forward)
for id, worker := range m.workers {
currentForwardsMap[id] = worker.GetForward()
}
m.workersMu.RUnlock()
// Determine changes
var toAdd []config.Forward
var toRemove []string
var toKeep []string
// Find forwards to add and keep
for id, fwd := range newForwardsMap {
if _, exists := currentForwardsMap[id]; exists {
toKeep = append(toKeep, id)
} else {
toAdd = append(toAdd, fwd)
}
}
// Find forwards to remove
for id := range currentForwardsMap {
if _, exists := newForwardsMap[id]; !exists {
toRemove = append(toRemove, id)
}
}
// Check port availability for new forwards
if len(toAdd) > 0 {
// Get currently managed ports to skip in availability check
managedPorts := make(map[int]bool)
for _, id := range toKeep {
managedPorts[currentForwardsMap[id].LocalPort] = true
}
// Check new ports
newPorts := m.extractPorts(toAdd)
conflicts := m.portChecker.CheckAvailability(newPorts, managedPorts)
if len(conflicts) > 0 {
// Add resource information to conflicts
for i := range conflicts {
conflicts[i].Resource = m.getResourceForPort(toAdd, conflicts[i].Port)
}
log.Printf("Config change rejected due to port conflicts:\n%s", FormatConflicts(conflicts))
log.Printf("Keeping previous configuration active")
return fmt.Errorf("port conflicts detected")
}
}
// Apply changes
log.Printf("Configuration diff: %d to add, %d to remove, %d to keep",
len(toAdd), len(toRemove), len(toKeep))
// Stop removed forwards
for _, id := range toRemove {
if err := m.stopWorker(id); err != nil {
log.Printf("Failed to stop worker %s: %v", id, err)
} else {
log.Printf("Stopped: %s", id)
}
}
// Start new forwards
for _, fwd := range toAdd {
if err := m.startWorker(fwd); err != nil {
log.Printf("Failed to start worker for %s: %v", fwd.ID(), err)
} else {
log.Printf("Started: %s", fwd.ID())
}
}
// Update current config
m.currentConfig = newCfg
log.Printf("Configuration reloaded successfully")
return nil
}
// startWorker creates and starts a new forward worker.
func (m *Manager) startWorker(fwd config.Forward) error {
m.workersMu.Lock()
defer m.workersMu.Unlock()
// Check if worker already exists
if _, exists := m.workers[fwd.ID()]; exists {
return fmt.Errorf("worker already exists for %s", fwd.ID())
}
// Create and start worker
worker := NewForwardWorker(fwd, m.portForwarder, m.verbose)
worker.Start()
// Store worker
m.workers[fwd.ID()] = worker
return nil
}
// stopWorker stops and removes a forward worker.
func (m *Manager) stopWorker(id string) error {
m.workersMu.Lock()
worker, exists := m.workers[id]
if !exists {
m.workersMu.Unlock()
return fmt.Errorf("worker not found: %s", id)
}
delete(m.workers, id)
m.workersMu.Unlock()
// Stop the worker
worker.Stop()
return nil
}
// GetActiveForwards returns a list of all active forward IDs.
func (m *Manager) GetActiveForwards() []string {
m.workersMu.RLock()
defer m.workersMu.RUnlock()
ids := make([]string, 0, len(m.workers))
for id := range m.workers {
ids = append(ids, id)
}
return ids
}
// GetWorkerCount returns the number of active workers.
func (m *Manager) GetWorkerCount() int {
m.workersMu.RLock()
defer m.workersMu.RUnlock()
return len(m.workers)
}
// extractPorts extracts all local ports from a list of forwards.
func (m *Manager) extractPorts(forwards []config.Forward) []int {
ports := make([]int, len(forwards))
for i, fwd := range forwards {
ports[i] = fwd.LocalPort
}
return ports
}
// getResourceForPort finds the resource (forward ID) that uses a given port.
func (m *Manager) getResourceForPort(forwards []config.Forward, port int) string {
for _, fwd := range forwards {
if fwd.LocalPort == port {
return fwd.ID()
}
}
return "unknown"
}
+203
View File
@@ -0,0 +1,203 @@
package forward
import (
"fmt"
"net"
"os/exec"
"runtime"
"strings"
)
// PortConflict represents a local port that is already in use.
type PortConflict struct {
Port int // The conflicting port number
Resource string // The forward resource that needs this port
UsedBy string // Process information (PID, command) using the port
}
// PortChecker checks port availability on the local system.
type PortChecker struct{}
// NewPortChecker creates a new PortChecker instance.
func NewPortChecker() *PortChecker {
return &PortChecker{}
}
// CheckAvailability checks if the given ports are available for binding.
// It returns a list of conflicts for ports that are already in use.
// The skipPorts map contains ports currently managed by kportal that should be excluded from the check.
func (pc *PortChecker) CheckAvailability(ports []int, skipPorts map[int]bool) []PortConflict {
var conflicts []PortConflict
for _, port := range ports {
// Skip ports that are already managed by kportal
if skipPorts[port] {
continue
}
// Try to bind to the port
if !pc.isPortAvailable(port) {
// Port is in use, get process info
usedBy := pc.getProcessUsingPort(port)
conflicts = append(conflicts, PortConflict{
Port: port,
UsedBy: usedBy,
})
}
}
return conflicts
}
// isPortAvailable checks if a port is available by attempting to bind to it.
func (pc *PortChecker) isPortAvailable(port int) bool {
// Try to listen on the port
addr := fmt.Sprintf(":%d", port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return false
}
listener.Close()
return true
}
// getProcessUsingPort returns information about the process using the given port.
// Returns a string like "nginx (PID 1234)" or "unknown" if the process cannot be determined.
func (pc *PortChecker) getProcessUsingPort(port int) string {
switch runtime.GOOS {
case "darwin", "linux":
return pc.getProcessUsingPortUnix(port)
case "windows":
return pc.getProcessUsingPortWindows(port)
default:
return "unknown"
}
}
// getProcessUsingPortUnix uses lsof to find the process using a port on Unix systems.
func (pc *PortChecker) getProcessUsingPortUnix(port int) string {
// Use lsof to find the process
// lsof -i :PORT -sTCP:LISTEN -t returns PIDs
cmd := exec.Command("lsof", "-i", fmt.Sprintf(":%d", port), "-sTCP:LISTEN", "-t")
output, err := cmd.Output()
if err != nil {
return "unknown"
}
pidStr := strings.TrimSpace(string(output))
if pidStr == "" {
return "unknown"
}
// Get the first PID if multiple are returned
pids := strings.Split(pidStr, "\n")
pid := pids[0]
// Get process name using ps
cmd = exec.Command("ps", "-p", pid, "-o", "comm=")
output, err = cmd.Output()
if err != nil {
return fmt.Sprintf("PID %s", pid)
}
procName := strings.TrimSpace(string(output))
if procName == "" {
return fmt.Sprintf("PID %s", pid)
}
return fmt.Sprintf("%s (PID %s)", procName, pid)
}
// getProcessUsingPortWindows uses netstat to find the process using a port on Windows.
func (pc *PortChecker) getProcessUsingPortWindows(port int) string {
// Use netstat to find the process
// netstat -ano | findstr :PORT
cmd := exec.Command("netstat", "-ano")
output, err := cmd.Output()
if err != nil {
return "unknown"
}
lines := strings.Split(string(output), "\n")
portStr := fmt.Sprintf(":%d", port)
for _, line := range lines {
if !strings.Contains(line, portStr) {
continue
}
// Parse the line to extract PID
// Format: TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 1234
fields := strings.Fields(line)
if len(fields) < 5 {
continue
}
// Check if this is a LISTENING state
if !strings.Contains(strings.ToUpper(line), "LISTENING") {
continue
}
pid := fields[len(fields)-1]
// Get process name using tasklist
cmd = exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %s", pid), "/FO", "CSV", "/NH")
output, err = cmd.Output()
if err != nil {
return fmt.Sprintf("PID %s", pid)
}
// Parse CSV output: "process.exe","1234","Console","1","12,345 K"
csvLine := strings.TrimSpace(string(output))
if csvLine == "" {
return fmt.Sprintf("PID %s", pid)
}
parts := strings.Split(csvLine, ",")
if len(parts) > 0 {
procName := strings.Trim(parts[0], "\"")
return fmt.Sprintf("%s (PID %s)", procName, pid)
}
return fmt.Sprintf("PID %s", pid)
}
return "unknown"
}
// FormatConflicts formats port conflicts into a human-readable error message.
func FormatConflicts(conflicts []PortConflict) string {
if len(conflicts) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("\nPort Conflicts Detected:\n")
sb.WriteString(strings.Repeat("=", 50) + "\n\n")
for _, conflict := range conflicts {
sb.WriteString(fmt.Sprintf("Port %d\n", conflict.Port))
if conflict.Resource != "" {
sb.WriteString(fmt.Sprintf(" Needed for: %s\n", conflict.Resource))
}
sb.WriteString(fmt.Sprintf(" Currently used by: %s\n", conflict.UsedBy))
sb.WriteString("\n")
}
sb.WriteString("Action: Stop conflicting processes or change localPort in config.\n")
return sb.String()
}
// GetPortsFromForwards extracts all local ports from a list of forward configurations.
func GetPortsFromForwards(forwards []interface{}) []int {
ports := make([]int, 0, len(forwards))
for _, fwd := range forwards {
// This function expects a generic interface to work with different forward types
// The actual implementation should use the Forward struct from config package
if f, ok := fwd.(interface{ GetLocalPort() int }); ok {
ports = append(ports, f.GetLocalPort())
}
}
return ports
}
+229
View File
@@ -0,0 +1,229 @@
package forward
import (
"net"
"testing"
"github.com/stretchr/testify/assert"
)
func TestPortChecker_IsAvailable(t *testing.T) {
pc := NewPortChecker()
// Test that isPortAvailable returns a bool
// We use a high port that's likely to be available
result := pc.isPortAvailable(54321)
assert.IsType(t, false, result, "isPortAvailable should return bool")
}
func TestPortChecker_CheckAvailability_EmptyPorts(t *testing.T) {
pc := NewPortChecker()
// Test with empty ports slice
conflicts := pc.CheckAvailability([]int{}, nil)
assert.Empty(t, conflicts, "should return empty conflicts for empty ports")
// Test with nil exclude map
conflicts = pc.CheckAvailability([]int{}, nil)
assert.Empty(t, conflicts, "should return empty conflicts for nil exclude map")
}
func TestPortChecker_CheckAvailability_ExcludeMap(t *testing.T) {
pc := NewPortChecker()
// Create a listener to occupy a port
listener, err := net.Listen("tcp", ":0")
assert.NoError(t, err, "should create listener")
defer listener.Close()
// Get the port that's now occupied
addr := listener.Addr().(*net.TCPAddr)
occupiedPort := addr.Port
// Test that the occupied port shows as conflicted
conflicts := pc.CheckAvailability([]int{occupiedPort}, nil)
assert.Len(t, conflicts, 1, "should detect conflict for occupied port")
assert.Equal(t, occupiedPort, conflicts[0].Port)
// Test that skipPorts map excludes the port from conflict detection
skipPorts := map[int]bool{
occupiedPort: true,
}
conflicts = pc.CheckAvailability([]int{occupiedPort}, skipPorts)
assert.Empty(t, conflicts, "should skip ports in exclude map")
}
func TestPortChecker_CheckAvailability_MultipleSkipPorts(t *testing.T) {
pc := NewPortChecker()
// Create multiple listeners
listener1, err := net.Listen("tcp", ":0")
assert.NoError(t, err)
defer listener1.Close()
listener2, err := net.Listen("tcp", ":0")
assert.NoError(t, err)
defer listener2.Close()
port1 := listener1.Addr().(*net.TCPAddr).Port
port2 := listener2.Addr().(*net.TCPAddr).Port
// Test with both ports occupied
conflicts := pc.CheckAvailability([]int{port1, port2}, nil)
assert.Len(t, conflicts, 2, "should detect both conflicts")
// Test excluding one port
skipPorts := map[int]bool{port1: true}
conflicts = pc.CheckAvailability([]int{port1, port2}, skipPorts)
assert.Len(t, conflicts, 1, "should detect only non-excluded port")
assert.Equal(t, port2, conflicts[0].Port)
// Test excluding both ports
skipPorts = map[int]bool{port1: true, port2: true}
conflicts = pc.CheckAvailability([]int{port1, port2}, skipPorts)
assert.Empty(t, conflicts, "should skip all excluded ports")
}
func TestPortChecker_GetProcessInfo(t *testing.T) {
pc := NewPortChecker()
// Test that getProcessUsingPort returns a string
// We don't test actual process detection to avoid flakiness
result := pc.getProcessUsingPort(12345)
assert.IsType(t, "", result, "getProcessUsingPort should return string")
assert.NotEmpty(t, result, "should return some string (even if 'unknown')")
}
func TestFormatConflicts_Empty(t *testing.T) {
// Test with empty conflicts
output := FormatConflicts([]PortConflict{})
assert.Empty(t, output, "should return empty string for no conflicts")
}
func TestFormatConflicts_SingleConflict(t *testing.T) {
conflicts := []PortConflict{
{
Port: 8080,
Resource: "dev/default/pod/my-app:8080",
UsedBy: "nginx (PID 1234)",
},
}
output := FormatConflicts(conflicts)
assert.NotEmpty(t, output, "should return non-empty output")
assert.Contains(t, output, "Port Conflicts Detected", "should contain header")
assert.Contains(t, output, "Port 8080", "should contain port number")
assert.Contains(t, output, "dev/default/pod/my-app:8080", "should contain resource")
assert.Contains(t, output, "nginx (PID 1234)", "should contain process info")
}
func TestFormatConflicts_MultipleConflicts(t *testing.T) {
conflicts := []PortConflict{
{
Port: 8080,
Resource: "dev/default/pod/app1:8080",
UsedBy: "nginx (PID 1234)",
},
{
Port: 5432,
Resource: "prod/database/service/postgres:5432",
UsedBy: "postgres (PID 5678)",
},
}
output := FormatConflicts(conflicts)
assert.NotEmpty(t, output, "should return non-empty output")
assert.Contains(t, output, "Port Conflicts Detected", "should contain header")
assert.Contains(t, output, "Port 8080", "should contain first port")
assert.Contains(t, output, "Port 5432", "should contain second port")
assert.Contains(t, output, "nginx (PID 1234)", "should contain first process")
assert.Contains(t, output, "postgres (PID 5678)", "should contain second process")
assert.Contains(t, output, "Action:", "should contain action message")
}
func TestFormatConflicts_WithoutResource(t *testing.T) {
conflicts := []PortConflict{
{
Port: 8080,
UsedBy: "nginx (PID 1234)",
},
}
output := FormatConflicts(conflicts)
assert.NotEmpty(t, output, "should return non-empty output")
assert.Contains(t, output, "Port 8080", "should contain port")
assert.Contains(t, output, "nginx (PID 1234)", "should contain process info")
// Should not crash or include empty "Needed for:" line
assert.NotContains(t, output, "Needed for: \n", "should not have empty resource line")
}
func TestPortConflict_Structure(t *testing.T) {
// Test that PortConflict structure works correctly
conflict := PortConflict{
Port: 8080,
Resource: "dev/default/pod/app:8080",
UsedBy: "nginx (PID 1234)",
}
assert.Equal(t, 8080, conflict.Port)
assert.Equal(t, "dev/default/pod/app:8080", conflict.Resource)
assert.Equal(t, "nginx (PID 1234)", conflict.UsedBy)
}
func TestNewPortChecker(t *testing.T) {
pc := NewPortChecker()
assert.NotNil(t, pc, "NewPortChecker should return non-nil instance")
}
func TestPortChecker_PortAvailability_Integration(t *testing.T) {
pc := NewPortChecker()
// Create a listener to occupy a port
listener, err := net.Listen("tcp", ":0")
assert.NoError(t, err, "should create listener")
defer listener.Close()
// Get the occupied port
occupiedPort := listener.Addr().(*net.TCPAddr).Port
// Test that the port is correctly detected as unavailable
available := pc.isPortAvailable(occupiedPort)
assert.False(t, available, "occupied port should not be available")
// Close the listener
listener.Close()
// The port should now be available (though there might be a brief delay)
// We don't assert this to avoid flakiness in CI environments
}
func TestPortChecker_CheckAvailability_AvailablePorts(t *testing.T) {
pc := NewPortChecker()
// Use high port numbers that are very unlikely to be in use
// This test might be slightly flaky in unusual environments, but should be stable
unlikelyPorts := []int{54321, 54322, 54323}
conflicts := pc.CheckAvailability(unlikelyPorts, nil)
// Most likely all ports will be available
// The function returns nil or empty slice when there are no conflicts
// We just verify the function executes without panicking
_ = conflicts
}
func TestFormatConflicts_Formatting(t *testing.T) {
conflicts := []PortConflict{
{
Port: 8080,
Resource: "dev/default/pod/my-app:8080",
UsedBy: "nginx (PID 1234)",
},
}
output := FormatConflicts(conflicts)
// Check formatting details
assert.Contains(t, output, "==================================================", "should contain separator line")
assert.Contains(t, output, "\n", "should contain newlines")
}
+244
View File
@@ -0,0 +1,244 @@
package forward
import (
"context"
"fmt"
"io"
"log"
"time"
"github.com/nvm/kportal/internal/config"
"github.com/nvm/kportal/internal/k8s"
"github.com/nvm/kportal/internal/retry"
)
// ForwardWorker manages a single port-forward connection with automatic retry.
type ForwardWorker struct {
forward config.Forward
portForwarder *k8s.PortForwarder
ctx context.Context
cancel context.CancelFunc
stopChan chan struct{}
doneChan chan struct{}
verbose bool
lastPod string // Track the last pod we connected to
}
// NewForwardWorker creates a new ForwardWorker for a single forward configuration.
func NewForwardWorker(fwd config.Forward, portForwarder *k8s.PortForwarder, verbose bool) *ForwardWorker {
ctx, cancel := context.WithCancel(context.Background())
return &ForwardWorker{
forward: fwd,
portForwarder: portForwarder,
ctx: ctx,
cancel: cancel,
stopChan: make(chan struct{}),
doneChan: make(chan struct{}),
verbose: verbose,
}
}
// Start begins the port-forward worker in a goroutine.
// The worker will continuously retry on failures with exponential backoff.
func (w *ForwardWorker) Start() {
go w.run()
}
// Stop gracefully stops the port-forward worker.
func (w *ForwardWorker) Stop() {
w.cancel()
close(w.stopChan)
<-w.doneChan // Wait for worker to finish
}
// run is the main worker loop that handles retries.
func (w *ForwardWorker) run() {
defer close(w.doneChan)
backoff := retry.NewBackoff()
for {
// Check if we should stop
select {
case <-w.ctx.Done():
if w.verbose {
log.Printf("[%s] Worker stopped", w.forward.ID())
}
return
default:
}
// Resolve the resource to get current pod name
podName, err := w.portForwarder.GetPodForResource(
w.ctx,
w.forward.GetContext(),
w.forward.GetNamespace(),
w.forward.Resource,
w.forward.Selector,
)
if err != nil {
log.Printf("[%s] Failed to resolve resource: %v", w.forward.ID(), err)
w.sleepWithBackoff(backoff)
continue
}
// Check if pod changed (restart detected)
if w.lastPod != "" && w.lastPod != podName {
log.Printf("[%s] Switched to new pod: %s → %s", w.forward.ID(), w.lastPod, podName)
} else if w.lastPod == "" {
log.Printf("[%s] Forwarding %s → localhost:%d",
w.forward.ID(), w.forward.String(), w.forward.LocalPort)
}
w.lastPod = podName
// Establish port-forward connection
err = w.establishForward(podName)
if err != nil {
// Connection failed or was interrupted
if w.ctx.Err() != nil {
// Context was cancelled, exit gracefully
return
}
// Log the error
log.Printf("[%s] Port-forward connection failed: %v", w.forward.ID(), err)
// Clear last pod so we re-resolve on next attempt
w.lastPod = ""
// Wait with backoff before retrying
w.sleepWithBackoff(backoff)
continue
}
// Connection closed normally (shouldn't happen unless stopped)
if w.ctx.Err() != nil {
return
}
// Connection closed unexpectedly, retry
log.Printf("[%s] Connection closed unexpectedly, retrying...", w.forward.ID())
w.lastPod = ""
w.sleepWithBackoff(backoff)
}
}
// establishForward establishes a port-forward connection.
// This blocks until the connection is closed or an error occurs.
func (w *ForwardWorker) establishForward(podName string) error {
// Create channels for this forward
stopChan := make(chan struct{}, 1)
readyChan := make(chan struct{}, 1)
// Create a context for this forward attempt
forwardCtx, forwardCancel := context.WithCancel(w.ctx)
defer forwardCancel()
// Start a goroutine to monitor for stop signal
go func() {
select {
case <-w.stopChan:
close(stopChan)
case <-forwardCtx.Done():
close(stopChan)
}
}()
// Set up output writers
var out, errOut io.Writer
if w.verbose {
out = &logWriter{prefix: fmt.Sprintf("[%s] ", w.forward.ID())}
errOut = &logWriter{prefix: fmt.Sprintf("[%s] ERROR: ", w.forward.ID())}
} else {
out = io.Discard
errOut = io.Discard
}
// Create forward request
req := &k8s.ForwardRequest{
ContextName: w.forward.GetContext(),
Namespace: w.forward.GetNamespace(),
Resource: w.forward.Resource,
Selector: w.forward.Selector,
LocalPort: w.forward.LocalPort,
RemotePort: w.forward.Port,
StopChan: stopChan,
ReadyChan: readyChan,
Out: out,
ErrOut: errOut,
}
// Start port forwarding in a goroutine
errChan := make(chan error, 1)
go func() {
errChan <- w.portForwarder.Forward(forwardCtx, req)
}()
// Wait for ready or error
select {
case <-readyChan:
if w.verbose {
log.Printf("[%s] Port-forward connection established", w.forward.ID())
}
case err := <-errChan:
return fmt.Errorf("failed to establish forward: %w", err)
case <-w.ctx.Done():
return nil
case <-time.After(30 * time.Second):
return fmt.Errorf("timeout waiting for port-forward to become ready")
}
// Wait for connection to close or error
select {
case err := <-errChan:
return err
case <-w.ctx.Done():
return nil
}
}
// sleepWithBackoff waits for the next backoff duration.
// Returns early if the worker is stopped.
func (w *ForwardWorker) sleepWithBackoff(backoff *retry.Backoff) {
delay := backoff.Next()
if w.verbose {
log.Printf("[%s] Retrying in %v (attempt %d)", w.forward.ID(), delay, backoff.Attempt())
}
select {
case <-time.After(delay):
// Continue with retry
case <-w.ctx.Done():
// Worker stopped
}
}
// GetForward returns the forward configuration for this worker.
func (w *ForwardWorker) GetForward() config.Forward {
return w.forward
}
// IsRunning returns true if the worker is running.
func (w *ForwardWorker) IsRunning() bool {
select {
case <-w.doneChan:
return false
default:
return true
}
}
// logWriter implements io.Writer to write log messages with a prefix.
type logWriter struct {
prefix string
}
func (lw *logWriter) Write(p []byte) (n int, err error) {
log.Printf("%s%s", lw.prefix, string(p))
return len(p), nil
}
+207
View File
@@ -0,0 +1,207 @@
package k8s
import (
"fmt"
"sync"
corev1 "k8s.io/api/core/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
)
// ClientPool manages Kubernetes clients per context with thread-safe access.
type ClientPool struct {
mu sync.RWMutex
clients map[string]*kubernetes.Clientset
configs map[string]*rest.Config
loader clientcmd.ClientConfig
}
// NewClientPool creates a new ClientPool instance.
func NewClientPool() (*ClientPool, error) {
// Load kubeconfig using default loading rules
loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
configOverrides := &clientcmd.ConfigOverrides{}
loader := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides)
return &ClientPool{
clients: make(map[string]*kubernetes.Clientset),
configs: make(map[string]*rest.Config),
loader: loader,
}, nil
}
// GetClient returns a Kubernetes client for the given context.
// Clients are cached and reused across multiple calls.
// This method is thread-safe.
func (p *ClientPool) GetClient(contextName string) (*kubernetes.Clientset, error) {
// Try to get cached client (read lock)
p.mu.RLock()
client, exists := p.clients[contextName]
p.mu.RUnlock()
if exists {
return client, nil
}
// Client doesn't exist, create it (write lock)
p.mu.Lock()
defer p.mu.Unlock()
// Double-check in case another goroutine created it while we waited
if client, exists := p.clients[contextName]; exists {
return client, nil
}
// Create new client
config, err := p.getRestConfig(contextName)
if err != nil {
return nil, fmt.Errorf("failed to get rest config for context %s: %w", contextName, err)
}
client, err = kubernetes.NewForConfig(config)
if err != nil {
return nil, fmt.Errorf("failed to create client for context %s: %w", contextName, err)
}
// Cache the client and config
p.clients[contextName] = client
p.configs[contextName] = config
return client, nil
}
// GetRestConfig returns the REST config for the given context.
// Configs are cached and reused.
// This method is thread-safe.
func (p *ClientPool) GetRestConfig(contextName string) (*rest.Config, error) {
// Try to get cached config (read lock)
p.mu.RLock()
config, exists := p.configs[contextName]
p.mu.RUnlock()
if exists {
return config, nil
}
// Config doesn't exist, create it (write lock)
p.mu.Lock()
defer p.mu.Unlock()
// Double-check in case another goroutine created it while we waited
if config, exists := p.configs[contextName]; exists {
return config, nil
}
// Create new config
config, err := p.getRestConfig(contextName)
if err != nil {
return nil, err
}
// Cache the config
p.configs[contextName] = config
return config, nil
}
// getRestConfig creates a REST config for the given context.
// This is an internal method that should only be called with a lock held.
func (p *ClientPool) getRestConfig(contextName string) (*rest.Config, error) {
// Load the raw kubeconfig
rawConfig, err := p.loader.RawConfig()
if err != nil {
return nil, fmt.Errorf("failed to load kubeconfig: %w", err)
}
// Check if the context exists
if _, exists := rawConfig.Contexts[contextName]; !exists {
return nil, fmt.Errorf("context %s not found in kubeconfig", contextName)
}
// Create config overrides for the specific context
overrides := &clientcmd.ConfigOverrides{
CurrentContext: contextName,
}
// Build the config
config, err := clientcmd.NewNonInteractiveClientConfig(
rawConfig,
contextName,
overrides,
clientcmd.NewDefaultClientConfigLoadingRules(),
).ClientConfig()
if err != nil {
return nil, fmt.Errorf("failed to build client config for context %s: %w", contextName, err)
}
return config, nil
}
// GetCurrentContext returns the name of the current context from kubeconfig.
func (p *ClientPool) GetCurrentContext() (string, error) {
rawConfig, err := p.loader.RawConfig()
if err != nil {
return "", fmt.Errorf("failed to load kubeconfig: %w", err)
}
return rawConfig.CurrentContext, nil
}
// ListContexts returns a list of all available contexts from kubeconfig.
func (p *ClientPool) ListContexts() ([]string, error) {
rawConfig, err := p.loader.RawConfig()
if err != nil {
return nil, fmt.Errorf("failed to load kubeconfig: %w", err)
}
contexts := make([]string, 0, len(rawConfig.Contexts))
for name := range rawConfig.Contexts {
contexts = append(contexts, name)
}
return contexts, nil
}
// ClearCache removes all cached clients and configs.
// This is useful for testing or when kubeconfig has been updated.
func (p *ClientPool) ClearCache() {
p.mu.Lock()
defer p.mu.Unlock()
p.clients = make(map[string]*kubernetes.Clientset)
p.configs = make(map[string]*rest.Config)
}
// RemoveContext removes a specific context from the cache.
// This is useful when a context is removed or updated.
func (p *ClientPool) RemoveContext(contextName string) {
p.mu.Lock()
defer p.mu.Unlock()
delete(p.clients, contextName)
delete(p.configs, contextName)
}
// GetNamespace returns the default namespace for the given context.
func (p *ClientPool) GetNamespace(contextName string) (string, error) {
rawConfig, err := p.loader.RawConfig()
if err != nil {
return "", fmt.Errorf("failed to load kubeconfig: %w", err)
}
context, exists := rawConfig.Contexts[contextName]
if !exists {
return "", fmt.Errorf("context %s not found", contextName)
}
// Return the namespace from the context, or "default" if not specified
if context.Namespace == "" {
return corev1.NamespaceDefault, nil
}
return context.Namespace, nil
}
+224
View File
@@ -0,0 +1,224 @@
package k8s
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewClientPool(t *testing.T) {
pool, err := NewClientPool()
assert.NoError(t, err, "NewClientPool should not return error")
assert.NotNil(t, pool, "pool should not be nil")
assert.NotNil(t, pool.clients, "clients map should be initialized")
assert.NotNil(t, pool.configs, "configs map should be initialized")
assert.Empty(t, pool.clients, "clients map should be empty initially")
assert.Empty(t, pool.configs, "configs map should be empty initially")
}
func TestClientPool_ClearCache(t *testing.T) {
pool, err := NewClientPool()
assert.NoError(t, err)
// Initially empty
assert.Empty(t, pool.clients)
assert.Empty(t, pool.configs)
// Call ClearCache on empty pool (should not panic)
pool.ClearCache()
// Should still be empty
assert.Empty(t, pool.clients)
assert.Empty(t, pool.configs)
}
func TestClientPool_RemoveContext(t *testing.T) {
pool, err := NewClientPool()
assert.NoError(t, err)
// Remove from empty pool (should not panic)
pool.RemoveContext("non-existent-context")
// Should still be empty
assert.Empty(t, pool.clients)
assert.Empty(t, pool.configs)
}
func TestClientPool_Structure(t *testing.T) {
pool, err := NewClientPool()
assert.NoError(t, err)
// Verify structure - just check maps are initialized
assert.NotNil(t, pool.clients, "clients map should exist")
assert.NotNil(t, pool.configs, "configs map should exist")
assert.NotNil(t, pool.loader, "loader should exist")
}
func TestClientPool_GetCurrentContext(t *testing.T) {
pool, err := NewClientPool()
assert.NoError(t, err)
// Try to get current context
// This may fail if kubeconfig is not available, which is fine for unit tests
context, err := pool.GetCurrentContext()
if err == nil {
// If successful, context should be a string
assert.IsType(t, "", context)
} else {
// If failed, error should mention kubeconfig
assert.Contains(t, err.Error(), "kubeconfig")
}
}
func TestClientPool_ListContexts(t *testing.T) {
pool, err := NewClientPool()
assert.NoError(t, err)
// Try to list contexts
// This may fail if kubeconfig is not available, which is fine for unit tests
contexts, err := pool.ListContexts()
if err == nil {
// If successful, contexts should be a slice
assert.NotNil(t, contexts)
assert.IsType(t, []string{}, contexts)
} else {
// If failed, error should mention kubeconfig
assert.Contains(t, err.Error(), "kubeconfig")
}
}
func TestClientPool_GetNamespace(t *testing.T) {
pool, err := NewClientPool()
assert.NoError(t, err)
// Try to get namespace for a non-existent context
namespace, err := pool.GetNamespace("non-existent-context")
// This should fail with context not found error
if err != nil {
// Error is expected, check it mentions the context or kubeconfig
errMsg := err.Error()
containsContext := assert.Contains(t, errMsg, "context", "error should mention context") ||
assert.Contains(t, errMsg, "kubeconfig", "error should mention kubeconfig")
assert.True(t, containsContext)
} else {
// If no error (unlikely), namespace should be a string
assert.IsType(t, "", namespace)
}
}
func TestClientPool_GetClient_NonExistentContext(t *testing.T) {
pool, err := NewClientPool()
assert.NoError(t, err)
// Try to get a client for a non-existent context
client, err := pool.GetClient("non-existent-context")
// This should fail
assert.Error(t, err, "should return error for non-existent context")
assert.Nil(t, client, "client should be nil on error")
}
func TestClientPool_GetRestConfig_NonExistentContext(t *testing.T) {
pool, err := NewClientPool()
assert.NoError(t, err)
// Try to get a rest config for a non-existent context
config, err := pool.GetRestConfig("non-existent-context")
// This should fail
assert.Error(t, err, "should return error for non-existent context")
assert.Nil(t, config, "config should be nil on error")
}
func TestClientPool_ThreadSafety(t *testing.T) {
pool, err := NewClientPool()
assert.NoError(t, err)
// Test that concurrent operations don't panic
// We don't check for errors as the context may not exist
done := make(chan bool)
for i := 0; i < 10; i++ {
go func() {
pool.ClearCache()
pool.RemoveContext("test-context")
pool.GetCurrentContext()
pool.ListContexts()
done <- true
}()
}
// Wait for all goroutines to complete
for i := 0; i < 10; i++ {
<-done
}
// If we get here without panic, thread safety is working
assert.True(t, true, "concurrent operations should not panic")
}
func TestClientPool_CacheBehavior(t *testing.T) {
pool, err := NewClientPool()
assert.NoError(t, err)
// Initially empty
assert.Empty(t, pool.clients)
assert.Empty(t, pool.configs)
// Add a context to the internal cache manually for testing
// Note: This is a simplified test that doesn't require actual kubeconfig
pool.mu.Lock()
testContext := "test-context"
pool.clients[testContext] = nil // Just mark as cached
pool.configs[testContext] = nil // Just mark as cached
pool.mu.Unlock()
// Verify it's cached
pool.mu.RLock()
_, clientExists := pool.clients[testContext]
_, configExists := pool.configs[testContext]
pool.mu.RUnlock()
assert.True(t, clientExists, "client should be in cache")
assert.True(t, configExists, "config should be in cache")
// Remove context
pool.RemoveContext(testContext)
// Verify it's removed
pool.mu.RLock()
_, clientExists = pool.clients[testContext]
_, configExists = pool.configs[testContext]
pool.mu.RUnlock()
assert.False(t, clientExists, "client should be removed from cache")
assert.False(t, configExists, "config should be removed from cache")
// Add back and clear cache
pool.mu.Lock()
pool.clients[testContext] = nil
pool.configs[testContext] = nil
pool.mu.Unlock()
pool.ClearCache()
// Verify cache is cleared
assert.Empty(t, pool.clients, "clients should be cleared")
assert.Empty(t, pool.configs, "configs should be cleared")
}
func TestClientPool_EmptyPoolOperations(t *testing.T) {
pool, err := NewClientPool()
assert.NoError(t, err)
// Test various operations on empty pool (should not panic)
pool.ClearCache()
pool.RemoveContext("any-context")
// All these operations should complete without panic
assert.NotNil(t, pool, "pool should still be valid")
}
+249
View File
@@ -0,0 +1,249 @@
package k8s
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/portforward"
"k8s.io/client-go/transport/spdy"
)
// PortForwarder handles Kubernetes port-forwarding operations.
type PortForwarder struct {
clientPool *ClientPool
resolver *ResourceResolver
}
// NewPortForwarder creates a new PortForwarder instance.
func NewPortForwarder(clientPool *ClientPool, resolver *ResourceResolver) *PortForwarder {
return &PortForwarder{
clientPool: clientPool,
resolver: resolver,
}
}
// ForwardRequest contains the parameters for a port-forward request.
type ForwardRequest struct {
ContextName string // Kubernetes context name
Namespace string // Namespace
Resource string // Resource (pod/name or service/name)
Selector string // Label selector (for pod resolution)
LocalPort int // Local port
RemotePort int // Remote port
StopChan chan struct{}
ReadyChan chan struct{}
Out io.Writer // Output writer for logs
ErrOut io.Writer // Error output writer
}
// Forward establishes a port-forward connection to a Kubernetes resource.
// It supports both pod and service forwarding.
// The connection runs until StopChan is closed or an error occurs.
func (pf *PortForwarder) Forward(ctx context.Context, req *ForwardRequest) error {
// Resolve the resource to an actual pod name
resolvedResource, err := pf.resolver.Resolve(ctx, req.ContextName, req.Namespace, req.Resource, req.Selector)
if err != nil {
return fmt.Errorf("failed to resolve resource: %w", err)
}
// Parse the resolved resource
parts := strings.SplitN(resolvedResource, "/", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid resolved resource format: %s", resolvedResource)
}
resourceType := parts[0]
resourceName := parts[1]
// Handle different resource types
switch resourceType {
case "pod":
return pf.forwardToPod(ctx, req, resourceName)
case "service":
return pf.forwardToService(ctx, req, resourceName)
default:
return fmt.Errorf("unsupported resource type: %s", resourceType)
}
}
// forwardToPod establishes a port-forward to a specific pod.
func (pf *PortForwarder) forwardToPod(ctx context.Context, req *ForwardRequest, podName string) error {
// Get Kubernetes client and config
client, err := pf.clientPool.GetClient(req.ContextName)
if err != nil {
return fmt.Errorf("failed to get client: %w", err)
}
config, err := pf.clientPool.GetRestConfig(req.ContextName)
if err != nil {
return fmt.Errorf("failed to get rest config: %w", err)
}
// Verify pod exists and is running
pod, err := client.CoreV1().Pods(req.Namespace).Get(ctx, podName, metav1.GetOptions{})
if err != nil {
return fmt.Errorf("failed to get pod: %w", err)
}
if pod.Status.Phase != corev1.PodRunning {
return fmt.Errorf("pod is not running (current phase: %s)", pod.Status.Phase)
}
// Build the port-forward URL
reqURL := client.CoreV1().RESTClient().Post().
Resource("pods").
Namespace(req.Namespace).
Name(podName).
SubResource("portforward").
URL()
// Create the port-forward
return pf.executePortForward(config, reqURL, req)
}
// forwardToService establishes a port-forward to a service.
// This resolves the service to its backing pods and forwards to one of them.
func (pf *PortForwarder) forwardToService(ctx context.Context, req *ForwardRequest, serviceName string) error {
// Get Kubernetes client
client, err := pf.clientPool.GetClient(req.ContextName)
if err != nil {
return fmt.Errorf("failed to get client: %w", err)
}
// Get the service
service, err := client.CoreV1().Services(req.Namespace).Get(ctx, serviceName, metav1.GetOptions{})
if err != nil {
return fmt.Errorf("failed to get service: %w", err)
}
// Get pods backing the service using label selector
selector := metav1.FormatLabelSelector(&metav1.LabelSelector{MatchLabels: service.Spec.Selector})
pods, err := client.CoreV1().Pods(req.Namespace).List(ctx, metav1.ListOptions{
LabelSelector: selector,
})
if err != nil {
return fmt.Errorf("failed to list pods for service: %w", err)
}
// Find first running pod
var targetPod *corev1.Pod
for i := range pods.Items {
pod := &pods.Items[i]
if pod.Status.Phase == corev1.PodRunning {
targetPod = pod
break
}
}
if targetPod == nil {
return fmt.Errorf("no running pods found for service %s", serviceName)
}
// Forward to the pod
config, err := pf.clientPool.GetRestConfig(req.ContextName)
if err != nil {
return fmt.Errorf("failed to get rest config: %w", err)
}
reqURL := client.CoreV1().RESTClient().Post().
Resource("pods").
Namespace(req.Namespace).
Name(targetPod.Name).
SubResource("portforward").
URL()
return pf.executePortForward(config, reqURL, req)
}
// executePortForward performs the actual port-forward operation.
func (pf *PortForwarder) executePortForward(config *rest.Config, url *url.URL, req *ForwardRequest) error {
// Create SPDY roundtripper
transport, upgrader, err := spdy.RoundTripperFor(config)
if err != nil {
return fmt.Errorf("failed to create round tripper: %w", err)
}
// Create dialer
dialer := spdy.NewDialer(upgrader, &http.Client{Transport: transport}, http.MethodPost, url)
// Set up port forwarding
ports := []string{fmt.Sprintf("%d:%d", req.LocalPort, req.RemotePort)}
// Create output writers
out := req.Out
errOut := req.ErrOut
if out == nil {
out = io.Discard
}
if errOut == nil {
errOut = io.Discard
}
// Create port forwarder
fw, err := portforward.New(dialer, ports, req.StopChan, req.ReadyChan, out, errOut)
if err != nil {
return fmt.Errorf("failed to create port forwarder: %w", err)
}
// Start forwarding (blocks until stopped or error)
if err := fw.ForwardPorts(); err != nil {
return fmt.Errorf("port forward failed: %w", err)
}
return nil
}
// GetPodForResource returns the pod name that would be used for forwarding.
// This is useful for logging and debugging.
func (pf *PortForwarder) GetPodForResource(ctx context.Context, contextName, namespace, resource, selector string) (string, error) {
resolvedResource, err := pf.resolver.Resolve(ctx, contextName, namespace, resource, selector)
if err != nil {
return "", err
}
parts := strings.SplitN(resolvedResource, "/", 2)
if len(parts) != 2 {
return "", fmt.Errorf("invalid resolved resource format: %s", resolvedResource)
}
resourceType := parts[0]
resourceName := parts[1]
if resourceType == "service" {
// For services, need to resolve to backing pod
client, err := pf.clientPool.GetClient(contextName)
if err != nil {
return "", fmt.Errorf("failed to get client: %w", err)
}
service, err := client.CoreV1().Services(namespace).Get(ctx, resourceName, metav1.GetOptions{})
if err != nil {
return "", fmt.Errorf("failed to get service: %w", err)
}
selector := metav1.FormatLabelSelector(&metav1.LabelSelector{MatchLabels: service.Spec.Selector})
pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
LabelSelector: selector,
})
if err != nil {
return "", fmt.Errorf("failed to list pods: %w", err)
}
for i := range pods.Items {
if pods.Items[i].Status.Phase == corev1.PodRunning {
return pods.Items[i].Name, nil
}
}
return "", fmt.Errorf("no running pods found for service")
}
return resourceName, nil
}
+256
View File
@@ -0,0 +1,256 @@
package k8s
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
const (
// Default cache TTL for resolved resources
defaultCacheTTL = 30 * time.Second
)
// ResolvedResource represents a resolved Kubernetes resource.
type ResolvedResource struct {
Name string // The resolved pod or service name
Namespace string // The namespace
Timestamp time.Time // When this was resolved
}
// cacheEntry stores a cached resolution result with expiry.
type cacheEntry struct {
resource ResolvedResource
expiresAt time.Time
}
// ResourceResolver resolves Kubernetes resources with caching.
// It handles prefix matching for pods and label selector resolution.
type ResourceResolver struct {
clientPool *ClientPool
cache map[string]cacheEntry // key: contextName/namespace/resource -> resolved name
cacheMu sync.RWMutex
cacheTTL time.Duration
}
// NewResourceResolver creates a new ResourceResolver instance.
func NewResourceResolver(clientPool *ClientPool) *ResourceResolver {
return &ResourceResolver{
clientPool: clientPool,
cache: make(map[string]cacheEntry),
cacheTTL: defaultCacheTTL,
}
}
// SetCacheTTL sets the cache TTL for resolved resources.
func (r *ResourceResolver) SetCacheTTL(ttl time.Duration) {
r.cacheTTL = ttl
}
// Resolve resolves a resource name to an actual pod or service name.
// It supports:
// - pod/prefix: Prefix matching (e.g., "pod/my-app" matches "my-app-xyz789")
// - pod + selector: Label selector matching (e.g., "pod" with selector "app=nginx")
// - service/name: Direct service name (no resolution needed)
func (r *ResourceResolver) Resolve(ctx context.Context, contextName, namespace, resource, selector string) (string, error) {
// Parse resource type and name
parts := strings.SplitN(resource, "/", 2)
resourceType := parts[0]
// Services don't need resolution
if resourceType == "service" {
if len(parts) < 2 {
return "", fmt.Errorf("invalid service resource format: %s", resource)
}
return resource, nil
}
// Handle pod resolution
if resourceType == "pod" {
if len(parts) == 2 {
// pod/prefix format - prefix matching
prefix := parts[1]
return r.resolvePodPrefix(ctx, contextName, namespace, prefix)
}
// pod with selector - label selector matching
if selector != "" {
return r.resolvePodSelector(ctx, contextName, namespace, selector)
}
return "", fmt.Errorf("pod resource requires either a name prefix (pod/name) or a selector")
}
return "", fmt.Errorf("unsupported resource type: %s", resourceType)
}
// resolvePodPrefix resolves a pod name using prefix matching.
// It returns the newest running pod that matches the prefix.
func (r *ResourceResolver) resolvePodPrefix(ctx context.Context, contextName, namespace, prefix string) (string, error) {
// Check cache first
cacheKey := fmt.Sprintf("%s/%s/pod/%s", contextName, namespace, prefix)
if cached := r.getFromCache(cacheKey); cached != "" {
return fmt.Sprintf("pod/%s", cached), nil
}
// Get Kubernetes client
client, err := r.clientPool.GetClient(contextName)
if err != nil {
return "", fmt.Errorf("failed to get client: %w", err)
}
// List all pods in the namespace
pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{})
if err != nil {
return "", fmt.Errorf("failed to list pods: %w", err)
}
// Find pods matching the prefix
var matchingPods []*corev1.Pod
for i := range pods.Items {
pod := &pods.Items[i]
if strings.HasPrefix(pod.Name, prefix) && pod.Status.Phase == corev1.PodRunning {
matchingPods = append(matchingPods, pod)
}
}
if len(matchingPods) == 0 {
return "", fmt.Errorf("no running pods found matching prefix '%s' in namespace %s", prefix, namespace)
}
// Sort by creation timestamp (newest first)
sort.Slice(matchingPods, func(i, j int) bool {
return matchingPods[i].CreationTimestamp.After(matchingPods[j].CreationTimestamp.Time)
})
// Return the newest pod
resolvedName := matchingPods[0].Name
r.putInCache(cacheKey, resolvedName)
return fmt.Sprintf("pod/%s", resolvedName), nil
}
// resolvePodSelector resolves a pod name using label selectors.
// It returns the first running pod matching the selector.
func (r *ResourceResolver) resolvePodSelector(ctx context.Context, contextName, namespace, selector string) (string, error) {
// Check cache first
cacheKey := fmt.Sprintf("%s/%s/pod?selector=%s", contextName, namespace, selector)
if cached := r.getFromCache(cacheKey); cached != "" {
return fmt.Sprintf("pod/%s", cached), nil
}
// Get Kubernetes client
client, err := r.clientPool.GetClient(contextName)
if err != nil {
return "", fmt.Errorf("failed to get client: %w", err)
}
// List pods matching the selector
pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
LabelSelector: selector,
})
if err != nil {
return "", fmt.Errorf("failed to list pods with selector '%s': %w", selector, err)
}
// Find first running pod
for i := range pods.Items {
pod := &pods.Items[i]
if pod.Status.Phase == corev1.PodRunning {
resolvedName := pod.Name
r.putInCache(cacheKey, resolvedName)
return fmt.Sprintf("pod/%s", resolvedName), nil
}
}
return "", fmt.Errorf("no running pods found matching selector '%s' in namespace %s", selector, namespace)
}
// getFromCache retrieves a cached resolution result if it exists and hasn't expired.
func (r *ResourceResolver) getFromCache(key string) string {
r.cacheMu.RLock()
defer r.cacheMu.RUnlock()
entry, exists := r.cache[key]
if !exists {
return ""
}
// Check if expired
if time.Now().After(entry.expiresAt) {
return ""
}
return entry.resource.Name
}
// putInCache stores a resolution result in the cache with TTL.
func (r *ResourceResolver) putInCache(key, value string) {
r.cacheMu.Lock()
defer r.cacheMu.Unlock()
r.cache[key] = cacheEntry{
resource: ResolvedResource{
Name: value,
Timestamp: time.Now(),
},
expiresAt: time.Now().Add(r.cacheTTL),
}
}
// ClearCache clears all cached resolution results.
func (r *ResourceResolver) ClearCache() {
r.cacheMu.Lock()
defer r.cacheMu.Unlock()
r.cache = make(map[string]cacheEntry)
}
// InvalidateCache invalidates cache entries for a specific resource.
func (r *ResourceResolver) InvalidateCache(contextName, namespace, resource string) {
r.cacheMu.Lock()
defer r.cacheMu.Unlock()
// Remove exact match
delete(r.cache, fmt.Sprintf("%s/%s/%s", contextName, namespace, resource))
// Remove prefix matches (for selector-based resources)
prefix := fmt.Sprintf("%s/%s/", contextName, namespace)
for key := range r.cache {
if strings.HasPrefix(key, prefix) {
delete(r.cache, key)
}
}
}
// GetPodList returns a list of pods matching the given criteria.
// This is useful for debugging and testing.
func (r *ResourceResolver) GetPodList(ctx context.Context, contextName, namespace, selector string) ([]*corev1.Pod, error) {
client, err := r.clientPool.GetClient(contextName)
if err != nil {
return nil, fmt.Errorf("failed to get client: %w", err)
}
listOptions := metav1.ListOptions{}
if selector != "" {
listOptions.LabelSelector = selector
}
pods, err := client.CoreV1().Pods(namespace).List(ctx, listOptions)
if err != nil {
return nil, fmt.Errorf("failed to list pods: %w", err)
}
result := make([]*corev1.Pod, len(pods.Items))
for i := range pods.Items {
result[i] = &pods.Items[i]
}
return result, nil
}
+74
View File
@@ -0,0 +1,74 @@
package retry
import (
"math"
"math/rand"
"time"
)
const (
// Backoff intervals: 1s → 2s → 4s → 8s → 10s (max)
initialDelay = 1 * time.Second
maxDelay = 10 * time.Second
jitterPct = 0.1 // 10% jitter
)
// Backoff implements exponential backoff with jitter for retry logic.
// The backoff sequence is: 1s → 2s → 4s → 8s → 10s (max, then stays at 10s).
type Backoff struct {
attempt int
rng *rand.Rand
}
// NewBackoff creates a new Backoff instance with a seeded random number generator.
func NewBackoff() *Backoff {
return &Backoff{
attempt: 0,
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
// Next returns the next backoff duration and increments the attempt counter.
// The duration follows exponential backoff: 1s → 2s → 4s → 8s → 10s (max).
// A 10% jitter is added to prevent thundering herd effects.
func (b *Backoff) Next() time.Duration {
// Calculate base delay: 2^attempt seconds
exp := math.Pow(2, float64(b.attempt))
delay := time.Duration(exp) * time.Second
// Cap at max delay
if delay > maxDelay {
delay = maxDelay
}
// Add jitter (±10%)
jitter := b.calculateJitter(delay)
delay = delay + jitter
b.attempt++
return delay
}
// Reset resets the backoff to the initial state.
func (b *Backoff) Reset() {
b.attempt = 0
}
// Attempt returns the current attempt number.
func (b *Backoff) Attempt() int {
return b.attempt
}
// calculateJitter adds random jitter to prevent synchronized retries.
// Returns a value between -jitterPct*delay and +jitterPct*delay.
func (b *Backoff) calculateJitter(delay time.Duration) time.Duration {
maxJitter := float64(delay) * jitterPct
// Generate random value in range [-maxJitter, +maxJitter]
jitter := (b.rng.Float64()*2 - 1) * maxJitter
return time.Duration(jitter)
}
// Sleep waits for the next backoff duration.
func (b *Backoff) Sleep() {
time.Sleep(b.Next())
}
+167
View File
@@ -0,0 +1,167 @@
package retry
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestBackoff_Next(t *testing.T) {
tests := []struct {
name string
attempt int
minDelay time.Duration
maxDelay time.Duration
}{
{
name: "first attempt returns ~1s",
attempt: 0,
minDelay: 900 * time.Millisecond, // 1s - 10% jitter
maxDelay: 1100 * time.Millisecond, // 1s + 10% jitter
},
{
name: "second attempt returns ~2s",
attempt: 1,
minDelay: 1800 * time.Millisecond, // 2s - 10% jitter
maxDelay: 2200 * time.Millisecond, // 2s + 10% jitter
},
{
name: "third attempt returns ~4s",
attempt: 2,
minDelay: 3600 * time.Millisecond, // 4s - 10% jitter
maxDelay: 4400 * time.Millisecond, // 4s + 10% jitter
},
{
name: "fourth attempt returns ~8s",
attempt: 3,
minDelay: 7200 * time.Millisecond, // 8s - 10% jitter
maxDelay: 8800 * time.Millisecond, // 8s + 10% jitter
},
{
name: "fifth attempt returns ~10s (max)",
attempt: 4,
minDelay: 9000 * time.Millisecond, // 10s - 10% jitter
maxDelay: 11000 * time.Millisecond, // 10s + 10% jitter
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := NewBackoff()
// Advance to the desired attempt
for i := 0; i < tt.attempt; i++ {
b.Next()
}
delay := b.Next()
assert.GreaterOrEqual(t, delay, tt.minDelay, "delay should be >= min with jitter")
assert.LessOrEqual(t, delay, tt.maxDelay, "delay should be <= max with jitter")
})
}
}
func TestBackoff_Next_StaysAtMax(t *testing.T) {
b := NewBackoff()
// Go through several attempts past the max
for i := 0; i < 10; i++ {
delay := b.Next()
// After the 5th attempt (index 4), should always be at max (10s ± jitter)
if i >= 4 {
assert.GreaterOrEqual(t, delay, 9*time.Second, "should stay at max delay")
assert.LessOrEqual(t, delay, 11*time.Second, "should stay at max delay with jitter")
}
}
}
func TestBackoff_Reset(t *testing.T) {
b := NewBackoff()
// Advance through several attempts
for i := 0; i < 5; i++ {
b.Next()
}
// Should be at attempt 5
assert.Equal(t, 5, b.Attempt(), "should be at attempt 5")
// Reset
b.Reset()
// Should be back to attempt 0
assert.Equal(t, 0, b.Attempt(), "should be reset to attempt 0")
// Next call should return ~1s (first attempt)
delay := b.Next()
assert.GreaterOrEqual(t, delay, 900*time.Millisecond, "after reset should return ~1s")
assert.LessOrEqual(t, delay, 1100*time.Millisecond, "after reset should return ~1s with jitter")
}
func TestBackoff_Jitter_IsWithinExpectedRange(t *testing.T) {
b := NewBackoff()
// Test multiple times to ensure jitter varies
delays := make([]time.Duration, 20)
for i := 0; i < 20; i++ {
b.Reset()
delays[i] = b.Next()
}
// All delays should be within the jitter range for 1s
for _, delay := range delays {
assert.GreaterOrEqual(t, delay, 900*time.Millisecond, "jitter should not go below 10%")
assert.LessOrEqual(t, delay, 1100*time.Millisecond, "jitter should not go above 10%")
}
// Check that not all delays are identical (jitter is working)
allSame := true
first := delays[0]
for _, d := range delays[1:] {
if d != first {
allSame = false
break
}
}
assert.False(t, allSame, "jitter should produce varying delays")
}
func TestBackoff_Attempt(t *testing.T) {
b := NewBackoff()
assert.Equal(t, 0, b.Attempt(), "initial attempt should be 0")
b.Next()
assert.Equal(t, 1, b.Attempt(), "attempt should increment after Next()")
b.Next()
assert.Equal(t, 2, b.Attempt(), "attempt should increment after Next()")
b.Reset()
assert.Equal(t, 0, b.Attempt(), "attempt should reset to 0")
}
func TestBackoff_ExponentialProgression(t *testing.T) {
b := NewBackoff()
// Track the progression
var delays []time.Duration
for i := 0; i < 5; i++ {
delays = append(delays, b.Next())
}
// Verify exponential growth (each should be roughly 2x the previous)
// We allow for jitter by checking a range
for i := 1; i < len(delays)-1; i++ {
// Each delay should be roughly double the previous (accounting for jitter)
// With 10% jitter on each value, worst case: (2.0 * 1.1) / 0.9 = 2.44
// We use 1.7x to 2.5x as a reasonable range with 10% jitter on each
ratio := float64(delays[i]) / float64(delays[i-1])
assert.GreaterOrEqual(t, ratio, 1.7, "exponential growth should be ~2x")
assert.LessOrEqual(t, ratio, 2.5, "exponential growth should be ~2x")
}
}
-13
View File
@@ -11,22 +11,9 @@ blacklist:
- "WIP"
wording:
patch:
- "fix"
- "bugfix"
- "hotfix"
- "patch"
- "docs"
- "test"
- "tests"
- "refactor"
minor:
- "feat"
- "feature"
- "add"
- "enhance"
- "update"
- "improve"
major:
- "breaking"
- "major"