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