diff --git a/.kportal.yaml b/.kportal.yaml new file mode 100644 index 0000000..9866cf4 --- /dev/null +++ b/.kportal.yaml @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f9b0fb1 --- /dev/null +++ b/Makefile @@ -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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..cacd257 --- /dev/null +++ b/README.md @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..07e426e --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3713757 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8d8eb6a --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..0d478be --- /dev/null +++ b/internal/config/config_test.go @@ -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()) +} diff --git a/internal/config/validator.go b/internal/config/validator.go new file mode 100644 index 0000000..a510300 --- /dev/null +++ b/internal/config/validator.go @@ -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() +} diff --git a/internal/config/validator_test.go b/internal/config/validator_test.go new file mode 100644 index 0000000..7569f56 --- /dev/null +++ b/internal/config/validator_test.go @@ -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") + } + }) + } +} diff --git a/internal/config/watcher.go b/internal/config/watcher.go new file mode 100644 index 0000000..9d50dcc --- /dev/null +++ b/internal/config/watcher.go @@ -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") + } +} diff --git a/internal/forward/manager.go b/internal/forward/manager.go new file mode 100644 index 0000000..28dfb58 --- /dev/null +++ b/internal/forward/manager.go @@ -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" +} diff --git a/internal/forward/portcheck.go b/internal/forward/portcheck.go new file mode 100644 index 0000000..031ed58 --- /dev/null +++ b/internal/forward/portcheck.go @@ -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 +} diff --git a/internal/forward/portcheck_test.go b/internal/forward/portcheck_test.go new file mode 100644 index 0000000..5600bd3 --- /dev/null +++ b/internal/forward/portcheck_test.go @@ -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") +} diff --git a/internal/forward/worker.go b/internal/forward/worker.go new file mode 100644 index 0000000..99c6cdf --- /dev/null +++ b/internal/forward/worker.go @@ -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 +} diff --git a/internal/k8s/client.go b/internal/k8s/client.go new file mode 100644 index 0000000..57e483b --- /dev/null +++ b/internal/k8s/client.go @@ -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 +} diff --git a/internal/k8s/client_test.go b/internal/k8s/client_test.go new file mode 100644 index 0000000..9c8b7d5 --- /dev/null +++ b/internal/k8s/client_test.go @@ -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") +} diff --git a/internal/k8s/portforward.go b/internal/k8s/portforward.go new file mode 100644 index 0000000..5f8e510 --- /dev/null +++ b/internal/k8s/portforward.go @@ -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 +} diff --git a/internal/k8s/resolver.go b/internal/k8s/resolver.go new file mode 100644 index 0000000..7fb09f6 --- /dev/null +++ b/internal/k8s/resolver.go @@ -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 +} diff --git a/internal/retry/backoff.go b/internal/retry/backoff.go new file mode 100644 index 0000000..23fc7a7 --- /dev/null +++ b/internal/retry/backoff.go @@ -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()) +} diff --git a/internal/retry/backoff_test.go b/internal/retry/backoff_test.go new file mode 100644 index 0000000..b93798c --- /dev/null +++ b/internal/retry/backoff_test.go @@ -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") + } +} diff --git a/semver.yaml b/semver.yaml index c1dcfae..c5389db 100644 --- a/semver.yaml +++ b/semver.yaml @@ -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"