mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-05 23:03:40 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 035b1cdd01 | |||
| 32e88efd9a | |||
| 6d8677026f | |||
| b7a32e4aab | |||
| 1167847fd4 | |||
| 3a7cc6f502 | |||
| 49acba5679 | |||
| 39fe4286b4 | |||
| 2fdc5912e7 | |||
| 7df161aee0 |
@@ -0,0 +1,2 @@
|
||||
github: [lukaszraczylo]
|
||||
custom: [monzo.me/lukaszraczylo]
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Autoupdate go.mod and go.sum
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
|
||||
env:
|
||||
GO_VERSION: ">=1.21"
|
||||
|
||||
jobs:
|
||||
# This job is responsible for preparation of the build
|
||||
# environment variables.
|
||||
prepare:
|
||||
name: Preparing build context
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
id: cache
|
||||
with:
|
||||
go-version: ${{env.GO_VERSION}}
|
||||
cache-dependency-path: "**/*.sum"
|
||||
|
||||
- name: Go get dependencies
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
go get ./...
|
||||
|
||||
# This job is responsible for running tests and linting the codebase
|
||||
test:
|
||||
name: "Unit testing"
|
||||
runs-on: ubuntu-latest
|
||||
container: golang:1
|
||||
needs: [prepare]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Ensure full history is checked out
|
||||
token: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{env.GO_VERSION}}
|
||||
cache-dependency-path: "**/*.sum"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install ca-certificates make -y
|
||||
update-ca-certificates
|
||||
go mod tidy
|
||||
go get -u -v ./...
|
||||
go mod tidy -v
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
CI_RUN=${CI} make test
|
||||
git config --global --add safe.directory /__w/kportal/kportal
|
||||
|
||||
- name: Commit changes
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: "Update go.mod and go.sum"
|
||||
commit_options: "--no-verify --signoff"
|
||||
file_pattern: "go.mod go.sum"
|
||||
+1
-1
@@ -19,7 +19,7 @@ builds:
|
||||
- arm64
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X main.version={{.Version}}
|
||||
- -X main.appVersion={{.Version}}
|
||||
|
||||
archives:
|
||||
- id: kportal
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
# Example kportal configuration
|
||||
# Copy this file to your project and customize as needed
|
||||
|
||||
# Optional: Health check configuration
|
||||
# These settings control how kportal monitors connection health and detects stale connections
|
||||
healthCheck:
|
||||
interval: "3s" # How often to check connection health (default: 3s)
|
||||
timeout: "2s" # Timeout for health check operations (default: 2s)
|
||||
method: "data-transfer" # Health check method: "tcp-dial" or "data-transfer" (default: data-transfer)
|
||||
# - tcp-dial: Simple TCP connection test (fast, less reliable)
|
||||
# - data-transfer: Attempts to read data (slower, more reliable)
|
||||
maxConnectionAge: "25m" # Maximum connection age before proactive reconnect (default: 25m)
|
||||
# Helps avoid Kubernetes API server timeouts (typically 30m)
|
||||
maxIdleTime: "10m" # Maximum idle time before marking as stale (default: 10m)
|
||||
# Connections with no data transfer are marked stale
|
||||
|
||||
# Optional: Reliability configuration
|
||||
# These settings improve connection stability for long-running transfers
|
||||
reliability:
|
||||
tcpKeepalive: "30s" # TCP keepalive interval for OS-level connection monitoring (default: 30s)
|
||||
dialTimeout: "30s" # Connection dial timeout (default: 30s)
|
||||
retryOnStale: true # Automatically reconnect when stale connections detected (default: true)
|
||||
watchdogPeriod: "30s" # Goroutine watchdog check interval to detect hung workers (default: 30s)
|
||||
|
||||
contexts:
|
||||
# Production context
|
||||
- name: production
|
||||
|
||||
@@ -37,7 +37,7 @@ GOFMT=$(GOCMD) fmt
|
||||
|
||||
# Build flags
|
||||
BUILD_FLAGS=-buildvcs=false
|
||||
LDFLAGS=-ldflags="-s -w -X main.version=$(VERSION)"
|
||||
LDFLAGS=-ldflags="-s -w -X main.appVersion=$(VERSION)"
|
||||
|
||||
all: fmt vet staticcheck test build
|
||||
|
||||
|
||||
@@ -9,33 +9,25 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Modern Kubernetes port-forward manager with interactive terminal UI</strong>
|
||||
<strong>Kubernetes port-forward manager with interactive terminal UI</strong>
|
||||
</p>
|
||||
|
||||
kportal simplifies managing multiple Kubernetes port-forwards with an elegant, interactive terminal interface. Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea), it provides real-time status updates, automatic reconnection, and hot-reload configuration support.
|
||||
kportal manages multiple Kubernetes port-forwards with an interactive terminal interface. It provides real-time status updates, automatic reconnection, hot-reload configuration, and mDNS hostname publishing.
|
||||
|
||||

|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🎯 **Interactive TUI** - Beautiful terminal interface with keyboard navigation (↑↓/jk, Space to toggle, q to quit)
|
||||
- ➕ **Live Add** - Add new port-forwards on-the-fly without editing config files or restarting
|
||||
- ✏️ **Live Edit** - Modify existing port-forwards (ports, resources, aliases) in real-time
|
||||
- 🗑️ **Live Delete** - Remove port-forwards instantly from the running session
|
||||
- 🔄 **Auto-Reconnect** - Automatic retry with exponential backoff on connection failures (max 10s)
|
||||
- ⚡ **Hot-Reload** - Update configuration without restarting - changes applied automatically
|
||||
- 🏥 **Health Checks** - Real-time port forward status monitoring with 5-second intervals
|
||||
- 🎨 **Multi-Context** - Support for multiple Kubernetes contexts and namespaces
|
||||
- 📦 **Batch Management** - Manage all port-forwards from a single configuration file
|
||||
- 🔌 **Toggle Forwards** - Enable/disable individual port-forwards on the fly with Space key
|
||||
- 🚀 **Grace Period** - Smart 10-second grace period to avoid false "Error" status on startup
|
||||
- 📊 **Status Display** - Clear visual indicators: Active (●), Starting (○), Reconnecting (◐), Error (✗)
|
||||
- 🔍 **Error Reporting** - Detailed error messages displayed below the table
|
||||
- 🔄 **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
|
||||
- 🚫 **Port Conflict Detection** - Validates port availability before starting with detailed PID info
|
||||
- 🎭 **Alias Support** - Cleaner, more readable display names for your forwards
|
||||
- **Interactive TUI** - Terminal interface with keyboard navigation
|
||||
- **Live management** - Add, edit, and delete port-forwards without restarting
|
||||
- **Auto-reconnect** - Exponential backoff retry on connection failures
|
||||
- **Hot-reload** - Configuration changes applied automatically
|
||||
- **Health monitoring** - Multiple check methods with stale connection detection
|
||||
- **Multi-context** - Support for multiple Kubernetes contexts and namespaces
|
||||
- **Pod restart handling** - Automatic reconnection when pods restart
|
||||
- **Label selectors** - Dynamic pod targeting using label selectors
|
||||
- **Port conflict detection** - Validates port availability with PID information
|
||||
- **mDNS hostnames** - Access forwards via `.local` hostnames
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
@@ -45,7 +37,7 @@ kportal simplifies managing multiple Kubernetes port-forwards with an elegant, i
|
||||
brew install lukaszraczylo/brew-taps/kportal
|
||||
```
|
||||
|
||||
### Quick Install Script
|
||||
### Quick Install
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.sh | bash
|
||||
@@ -53,24 +45,19 @@ curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.
|
||||
|
||||
### Manual Download
|
||||
|
||||
Download the latest binary for your platform from the [releases page](https://github.com/lukaszraczylo/kportal/releases):
|
||||
|
||||
- **macOS**: `kportal-{version}-darwin-{amd64|arm64}.tar.gz`
|
||||
- **Linux**: `kportal-{version}-linux-{amd64|arm64}.tar.gz`
|
||||
- **Windows**: `kportal-{version}-windows-{amd64|arm64}.zip`
|
||||
Download binaries from the [releases page](https://github.com/lukaszraczylo/kportal/releases).
|
||||
|
||||
### Build from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/lukaszraczylo/kportal.git
|
||||
cd kportal
|
||||
make build
|
||||
make install
|
||||
make build && make install
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
1. **Create a configuration file** (`.kportal.yaml`):
|
||||
Create `.kportal.yaml`:
|
||||
|
||||
```yaml
|
||||
contexts:
|
||||
@@ -84,32 +71,32 @@ contexts:
|
||||
localPort: 5432
|
||||
alias: prod-db
|
||||
|
||||
- name: frontend
|
||||
forwards:
|
||||
- resource: service/redis
|
||||
protocol: tcp
|
||||
port: 6379
|
||||
localPort: 6380
|
||||
alias: prod-redis
|
||||
localPort: 6379
|
||||
```
|
||||
|
||||
2. **Run kportal**:
|
||||
Run:
|
||||
|
||||
```bash
|
||||
kportal
|
||||
```
|
||||
|
||||
3. **Navigate the interface**:
|
||||
- `↑↓` or `j/k` - Navigate through forwards
|
||||
- `Space` or `Enter` - Toggle forward on/off
|
||||
- `a` - Add new port-forward interactively
|
||||
- `e` - Edit selected port-forward
|
||||
- `d` - Delete selected port-forward
|
||||
- `q` - Quit application
|
||||
### Keyboard Controls
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `↑↓` / `j/k` | Navigate |
|
||||
| `Space` / `Enter` | Toggle forward |
|
||||
| `a` | Add forward |
|
||||
| `e` | Edit forward |
|
||||
| `d` | Delete forward |
|
||||
| `q` | Quit |
|
||||
|
||||
## 📖 Configuration
|
||||
|
||||
### Simple Configuration
|
||||
### Basic Structure
|
||||
|
||||
```yaml
|
||||
contexts:
|
||||
@@ -117,551 +104,207 @@ contexts:
|
||||
namespaces:
|
||||
- name: <namespace-name>
|
||||
forwards:
|
||||
- resource: <resource-type>/<resource-name>
|
||||
- resource: <type>/<name>
|
||||
protocol: tcp
|
||||
port: <remote-port>
|
||||
localPort: <local-port>
|
||||
alias: <friendly-name> # Optional
|
||||
alias: <display-name> # optional
|
||||
selector: <label-selector> # optional
|
||||
```
|
||||
|
||||
### Advanced Configuration
|
||||
### Forward Options
|
||||
|
||||
```yaml
|
||||
contexts:
|
||||
# Production cluster
|
||||
- name: prod-us-west
|
||||
namespaces:
|
||||
- name: databases
|
||||
forwards:
|
||||
# Direct pod connection with prefix matching
|
||||
- resource: pod/postgres-primary
|
||||
protocol: tcp
|
||||
port: 5432
|
||||
localPort: 5432
|
||||
alias: prod-postgres
|
||||
|
||||
# Service connection
|
||||
- resource: service/redis-master
|
||||
protocol: tcp
|
||||
port: 6379
|
||||
localPort: 6379
|
||||
alias: prod-redis
|
||||
|
||||
# Pod with label selector
|
||||
- resource: pod
|
||||
selector: app=mongodb
|
||||
protocol: tcp
|
||||
port: 27017
|
||||
localPort: 27017
|
||||
alias: mongo
|
||||
|
||||
- name: applications
|
||||
forwards:
|
||||
- resource: deployment/api-server
|
||||
protocol: tcp
|
||||
port: 8080
|
||||
localPort: 8080
|
||||
alias: api
|
||||
|
||||
# Development cluster
|
||||
- name: dev-local
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: service/grafana
|
||||
protocol: tcp
|
||||
port: 3000
|
||||
localPort: 3000
|
||||
alias: grafana-dashboard
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `resource` | string | Yes | Kubernetes resource with type prefix (e.g., `service/name`, `pod/name`) |
|
||||
| `protocol` | string | Yes | Connection protocol (typically `tcp`) |
|
||||
| `port` | int | Yes | Remote port on the Kubernetes resource |
|
||||
| `localPort` | int | Yes | Local port to forward to |
|
||||
| `alias` | string | No | Friendly name for display (defaults to resource name) |
|
||||
| `selector` | string | No | Label selector for dynamic pod selection (e.g., `app=nginx,env=prod`) |
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `resource` | Yes | Resource type and name (e.g., `service/postgres`, `pod/my-app`) |
|
||||
| `protocol` | Yes | Protocol (`tcp`) |
|
||||
| `port` | Yes | Remote port |
|
||||
| `localPort` | Yes | Local port |
|
||||
| `alias` | No | Display name and mDNS hostname |
|
||||
| `selector` | No | Label selector for pod resolution |
|
||||
|
||||
### Resource Formats
|
||||
|
||||
- **Pod by name**: `pod/pod-name` or just `pod-name`
|
||||
- **Pod by prefix**: `pod/my-app` (matches `my-app-xyz789`, `my-app-abc123`, etc.)
|
||||
- **Pod by selector**: Set `resource: pod` and use `selector: app=nginx`
|
||||
- **Service**: `service/service-name` or `svc/service-name`
|
||||
- **Deployment**: `deployment/deployment-name` or `deploy/deployment-name`
|
||||
| Format | Description |
|
||||
|--------|-------------|
|
||||
| `service/name` | Service forwarding |
|
||||
| `pod/name` | Direct pod by name |
|
||||
| `pod/prefix` | Pod by prefix (matches `prefix-*`) |
|
||||
| `pod` + `selector` | Pod by label selector |
|
||||
| `deployment/name` | Deployment |
|
||||
|
||||
## 🎮 Usage
|
||||
### Health Check Configuration
|
||||
|
||||
### Interactive Mode (Default)
|
||||
```yaml
|
||||
healthCheck:
|
||||
interval: "3s" # Check frequency
|
||||
timeout: "2s" # Check timeout
|
||||
method: "data-transfer" # tcp-dial or data-transfer
|
||||
maxConnectionAge: "25m" # Reconnect before k8s timeout
|
||||
maxIdleTime: "10m" # Detect idle connections
|
||||
|
||||
reliability:
|
||||
tcpKeepalive: "30s"
|
||||
dialTimeout: "30s"
|
||||
retryOnStale: true
|
||||
```
|
||||
|
||||
Health check methods:
|
||||
- `tcp-dial` - Fast TCP connection test
|
||||
- `data-transfer` - Verifies tunnel functionality by attempting data read
|
||||
|
||||
Connection age reconnection only triggers when the connection is also idle, preventing interruption of active transfers like database dumps.
|
||||
|
||||
### mDNS Hostnames
|
||||
|
||||
Enable mDNS to access forwards via `.local` hostnames:
|
||||
|
||||
```yaml
|
||||
mdns:
|
||||
enabled: true
|
||||
|
||||
contexts:
|
||||
- name: production
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: service/postgres
|
||||
port: 5432
|
||||
localPort: 5432
|
||||
alias: prod-db # Accessible via prod-db.local:5432
|
||||
```
|
||||
|
||||
- Explicit `alias` becomes `<alias>.local`
|
||||
- Without alias, hostname is generated from resource name (`service/redis` → `redis.local`)
|
||||
- Works on macOS (Bonjour) and Linux (avahi-daemon)
|
||||
|
||||
Verify registration:
|
||||
```bash
|
||||
dns-sd -B _kportal._tcp local # macOS
|
||||
avahi-browse -t _kportal._tcp # Linux
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Interactive Mode
|
||||
|
||||
```bash
|
||||
kportal
|
||||
```
|
||||
|
||||
Starts the interactive TUI where you can:
|
||||
- View all configured port-forwards in a table
|
||||
- See real-time status updates (Active, Starting, Reconnecting, Error)
|
||||
- Toggle forwards on/off with Space key
|
||||
- View detailed error messages at the bottom of the screen
|
||||
|
||||
### Verbose Mode
|
||||
|
||||
```bash
|
||||
kportal -v
|
||||
```
|
||||
|
||||
Runs in verbose mode with:
|
||||
- Detailed logging to stdout
|
||||
- Periodic status table updates every 2 seconds
|
||||
- Full error traces
|
||||
- No interactive controls (for automation/debugging)
|
||||
|
||||
### Validate Configuration
|
||||
|
||||
```bash
|
||||
kportal --check
|
||||
```
|
||||
|
||||
Validates your configuration file without starting any forwards:
|
||||
- Checks YAML syntax
|
||||
- Validates all required fields
|
||||
- Detects duplicate local ports
|
||||
- Shows validation errors with line numbers
|
||||
|
||||
### Custom Configuration File
|
||||
### Custom Config File
|
||||
|
||||
```bash
|
||||
kportal -c /path/to/config.yaml
|
||||
```
|
||||
|
||||
### Version Information
|
||||
## Status Indicators
|
||||
|
||||
| Indicator | Description |
|
||||
|-----------|-------------|
|
||||
| `● Active` | Connection healthy |
|
||||
| `○ Starting` | Initial connection (10s grace period) |
|
||||
| `◐ Reconnecting` | Reconnecting after failure |
|
||||
| `✗ Error` | Connection failed |
|
||||
| `○ Disabled` | Manually disabled |
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Hot-Reload
|
||||
|
||||
Configuration changes are applied automatically. Manual reload:
|
||||
|
||||
```bash
|
||||
kportal --version
|
||||
# Output: kportal version 0.1.5
|
||||
kill -HUP $(pgrep kportal)
|
||||
```
|
||||
|
||||
## 🔄 kftray Migration
|
||||
### Port Conflict Detection
|
||||
|
||||
Migrate from kftray JSON configuration:
|
||||
kportal validates port availability at startup and during hot-reload, showing which process is using conflicting ports.
|
||||
|
||||
### Retry Strategy
|
||||
|
||||
Exponential backoff: 1s → 2s → 4s → 8s → 10s (max). Retries continue indefinitely until connection succeeds.
|
||||
|
||||
## Migration from kftray
|
||||
|
||||
```bash
|
||||
kportal --convert configs.json --convert-output .kportal.yaml
|
||||
```
|
||||
|
||||
**Example conversion:**
|
||||
## Signal Handling
|
||||
|
||||
kftray JSON:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"service": "postgres",
|
||||
"namespace": "default",
|
||||
"local_port": 5432,
|
||||
"remote_port": 5432,
|
||||
"context": "production",
|
||||
"workload_type": "service",
|
||||
"protocol": "tcp",
|
||||
"alias": "prod-db"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Converts to kportal YAML:
|
||||
```yaml
|
||||
contexts:
|
||||
- name: production
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: service/postgres
|
||||
protocol: tcp
|
||||
port: 5432
|
||||
localPort: 5432
|
||||
alias: prod-db
|
||||
```
|
||||
|
||||
## 🎨 Status Indicators
|
||||
|
||||
| Indicator | Status | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `● Active` | 🟢 Green | Port-forward is active and healthy |
|
||||
| `○ Starting` | 🟡 Yellow | Initial connection in progress (10s grace period) |
|
||||
| `◐ Reconnecting` | 🟡 Yellow | Attempting to reconnect after failure |
|
||||
| `✗ Error` | 🔴 Red | Connection failed - see error details below table |
|
||||
| `○ Disabled` | ⚪ Gray | Port-forward manually disabled via Space key |
|
||||
|
||||
## 🛠️ Advanced Features
|
||||
|
||||
### Hot-Reload
|
||||
|
||||
kportal automatically watches for configuration file changes and reloads:
|
||||
|
||||
```bash
|
||||
# Edit your config while kportal is running
|
||||
vim .kportal.yaml
|
||||
|
||||
# Changes are applied automatically within seconds:
|
||||
# - New forwards are started
|
||||
# - Removed forwards are stopped
|
||||
# - Existing forwards continue running unchanged
|
||||
```
|
||||
|
||||
Supports manual reload via `SIGHUP`:
|
||||
```bash
|
||||
kill -HUP $(pgrep kportal)
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
|
||||
Built-in health monitoring system:
|
||||
- **Check interval**: Every 5 seconds
|
||||
- **Timeout**: 2 seconds per check
|
||||
- **Grace period**: 10 seconds for new connections
|
||||
- **Automatic updates**: Real-time status changes in UI
|
||||
- **Error tracking**: Detailed error messages for failed connections
|
||||
|
||||
### Error Display
|
||||
|
||||
When connections fail, errors are displayed below the table:
|
||||
|
||||
```
|
||||
Errors:
|
||||
• prod-postgres: dial tcp 127.0.0.1:5432: connect: connection refused
|
||||
• prod-redis: i/o timeout after 2.0s
|
||||
```
|
||||
|
||||
Errors automatically clear when:
|
||||
- Connection becomes healthy
|
||||
- Forward is disabled
|
||||
- Forward is removed
|
||||
|
||||
### Port Conflict Detection
|
||||
|
||||
kportal checks for port conflicts at multiple stages:
|
||||
|
||||
**At startup:**
|
||||
```
|
||||
Port conflicts detected:
|
||||
Port 8080:
|
||||
• Requested by: api-server (context: prod, namespace: default)
|
||||
• Currently used by: PID 1234 (chrome)
|
||||
```
|
||||
|
||||
**During hot-reload:**
|
||||
- Only validates new ports being added
|
||||
- Skips currently managed ports
|
||||
- Rejects configuration if conflicts found
|
||||
|
||||
### Pod Restart Handling
|
||||
|
||||
When a pod restarts:
|
||||
1. Port-forward connection breaks
|
||||
2. kportal immediately re-resolves the resource:
|
||||
- For prefix matches: Finds newest pod with matching prefix
|
||||
- For selectors: Re-queries pods with matching labels
|
||||
3. Reconnects to new pod
|
||||
4. Logs the switch: `Switched to new pod: old-pod-abc → new-pod-xyz`
|
||||
|
||||
### Retry Strategy
|
||||
|
||||
Exponential backoff with maximum interval:
|
||||
- **Intervals**: 1s → 2s → 4s → 8s → 10s (max)
|
||||
- **Infinite retries**: Continues until connection succeeds
|
||||
- **Independent**: Each forward has its own retry logic
|
||||
- **Grace period**: First 10 seconds show "Starting" instead of "Error"
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.23 or higher
|
||||
- Access to a Kubernetes cluster
|
||||
- kubectl configured with contexts
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
# Build binary
|
||||
make build
|
||||
|
||||
# Run tests
|
||||
make test
|
||||
|
||||
# Run all checks (fmt, vet, staticcheck, test)
|
||||
make all
|
||||
|
||||
# Check current version
|
||||
make version
|
||||
|
||||
# Install locally
|
||||
make install
|
||||
|
||||
# Install system-wide
|
||||
sudo make install-system
|
||||
|
||||
# Clean build artifacts
|
||||
make clean
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
kportal/
|
||||
├── cmd/kportal/ # Main application entry point
|
||||
├── internal/
|
||||
│ ├── config/ # Configuration parsing and validation
|
||||
│ ├── forward/ # Port-forward manager and workers
|
||||
│ │ ├── manager.go # Orchestrates all forwards
|
||||
│ │ ├── worker.go # Individual forward worker
|
||||
│ │ └── port_checker.go # Port conflict detection
|
||||
│ ├── healthcheck/ # Health monitoring system
|
||||
│ │ └── checker.go # Port health checking
|
||||
│ ├── k8s/ # Kubernetes client wrapper
|
||||
│ │ ├── client.go # K8s client management
|
||||
│ │ ├── port_forward.go # Port-forward implementation
|
||||
│ │ └── resolver.go # Resource resolution
|
||||
│ ├── retry/ # Retry logic with backoff
|
||||
│ │ └── backoff.go # Exponential backoff
|
||||
│ ├── ui/ # Terminal UI implementations
|
||||
│ │ ├── bubbletea_ui.go # Interactive TUI (Bubble Tea)
|
||||
│ │ └── table_ui.go # Simple table for verbose mode
|
||||
│ └── converter/ # kftray JSON converter
|
||||
├── Formula/ # Homebrew formula
|
||||
├── .github/workflows/ # CI/CD pipelines
|
||||
│ └── release.yml # Release automation
|
||||
├── install.sh # Installation script
|
||||
├── semver.yaml # Semantic version config
|
||||
├── Makefile # Build automation
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## 📝 Examples
|
||||
|
||||
### Database Access
|
||||
|
||||
```yaml
|
||||
contexts:
|
||||
production:
|
||||
namespaces:
|
||||
databases:
|
||||
- resource: postgres-primary
|
||||
port: 5432
|
||||
local_port: 5432
|
||||
alias: prod-db
|
||||
```
|
||||
|
||||
Connect with:
|
||||
```bash
|
||||
kportal # Start in another terminal
|
||||
psql -h localhost -p 5432 -U postgres
|
||||
```
|
||||
|
||||
### Multiple Services
|
||||
|
||||
```yaml
|
||||
contexts:
|
||||
dev:
|
||||
namespaces:
|
||||
default:
|
||||
- resource: api
|
||||
port: 8080
|
||||
local_port: 8080
|
||||
- resource: frontend
|
||||
port: 3000
|
||||
local_port: 3000
|
||||
- resource: redis
|
||||
port: 6379
|
||||
local_port: 6379
|
||||
```
|
||||
|
||||
Access:
|
||||
- API: `http://localhost:8080`
|
||||
- Frontend: `http://localhost:3000`
|
||||
- Redis: `redis-cli -p 6379`
|
||||
|
||||
### Cross-Context Setup
|
||||
|
||||
```yaml
|
||||
contexts:
|
||||
prod-us:
|
||||
namespaces:
|
||||
backend:
|
||||
- resource: api
|
||||
port: 8080
|
||||
local_port: 8080
|
||||
alias: prod-us-api
|
||||
|
||||
prod-eu:
|
||||
namespaces:
|
||||
backend:
|
||||
- resource: api
|
||||
port: 8080
|
||||
local_port: 8081 # Different local port
|
||||
alias: prod-eu-api
|
||||
```
|
||||
|
||||
Compare APIs across regions simultaneously.
|
||||
|
||||
### Debug Multiple Pod Versions
|
||||
|
||||
```yaml
|
||||
contexts:
|
||||
production:
|
||||
namespaces:
|
||||
default:
|
||||
# Version 1
|
||||
- resource: pod
|
||||
selector: app=myapp,version=v1
|
||||
port: 8080
|
||||
local_port: 8080
|
||||
alias: app-v1
|
||||
|
||||
# Version 2
|
||||
- resource: pod
|
||||
selector: app=myapp,version=v2
|
||||
port: 8080
|
||||
local_port: 8081
|
||||
alias: app-v2
|
||||
|
||||
# Debug port for v2
|
||||
- resource: pod
|
||||
selector: app=myapp,version=v2
|
||||
port: 6060
|
||||
local_port: 6060
|
||||
alias: app-v2-pprof
|
||||
```
|
||||
- `Ctrl+C` / `SIGTERM` - Graceful shutdown
|
||||
- `SIGHUP` - Reload configuration
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Port Already in Use
|
||||
|
||||
**Problem**: `Port 8080: already in use by PID 1234 (chrome)`
|
||||
|
||||
**Solutions**:
|
||||
```bash
|
||||
# Find the process
|
||||
lsof -i :8080
|
||||
|
||||
# Kill the process
|
||||
kill 1234
|
||||
|
||||
# Or use a different local port in config
|
||||
local_port: 8081
|
||||
lsof -i :<port>
|
||||
kill <pid>
|
||||
```
|
||||
|
||||
### Connection Refused
|
||||
|
||||
**Problem**: `dial tcp 127.0.0.1:8080: connect: connection refused`
|
||||
|
||||
**Common causes**:
|
||||
1. **Pod not ready yet** - Wait for status to change from "Starting" → "Active" (10s grace period)
|
||||
2. **Wrong port number** - Verify the pod/service actually exposes that port
|
||||
3. **Service not exposed** - Check with `kubectl get svc` and `kubectl describe svc <name>`
|
||||
|
||||
**Debug**:
|
||||
```bash
|
||||
# Check pod status
|
||||
kubectl get pods -n <namespace>
|
||||
|
||||
# Check if port is exposed
|
||||
kubectl describe pod <pod-name> -n <namespace>
|
||||
|
||||
# Check service endpoints
|
||||
kubectl get endpoints <service-name> -n <namespace>
|
||||
```
|
||||
1. Verify pod is running: `kubectl get pods -n <namespace>`
|
||||
2. Verify port is correct: `kubectl describe pod <pod>`
|
||||
3. Check service endpoints: `kubectl get endpoints <service>`
|
||||
|
||||
### Context Not Found
|
||||
|
||||
**Problem**: `context "prod" not found in kubeconfig`
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# List available contexts
|
||||
kubectl config get-contexts
|
||||
|
||||
# Verify context name matches
|
||||
kubectl config current-context
|
||||
|
||||
# Update your config to use the correct context name
|
||||
```
|
||||
|
||||
### Health Check Errors During Startup
|
||||
## 🔧 Development
|
||||
|
||||
**Problem**: Seeing "Error" status immediately after starting
|
||||
### Prerequisites
|
||||
|
||||
**This is normal!** kportal has a 10-second grace period. If the connection is still failing after 10 seconds, check:
|
||||
- Pod is running: `kubectl get pods`
|
||||
- Port is correct in config
|
||||
- Network connectivity to cluster
|
||||
- Go 1.23+
|
||||
- Kubernetes cluster access
|
||||
- kubectl configured
|
||||
|
||||
### Logs Covering UI
|
||||
### Build
|
||||
|
||||
**Problem**: Kubernetes client logs appearing over the interactive UI
|
||||
|
||||
**This is fixed in v0.1.5+**. The interactive mode now completely suppresses all logs including:
|
||||
- Standard Go `log` package
|
||||
- Kubernetes `klog` output
|
||||
- Any stderr/stdout leakage
|
||||
|
||||
If you still see logs, please file an issue!
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Make your changes and add tests
|
||||
4. Run checks: `make all`
|
||||
5. Commit your changes (follow [semantic commit messages](#semantic-versioning))
|
||||
6. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
7. Open a Pull Request
|
||||
|
||||
### 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`
|
||||
|
||||
Example commits:
|
||||
```bash
|
||||
git commit -m "feat: add health check grace period" # Bumps minor version
|
||||
git commit -m "fix: resolve port conflict detection" # Bumps patch version
|
||||
git commit -m "breaking: change config file format" # Bumps major version
|
||||
make build # Build binary
|
||||
make test # Run tests
|
||||
make all # fmt, vet, staticcheck, test
|
||||
make install # Install locally
|
||||
```
|
||||
|
||||
## 📄 License
|
||||
## Contributing
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
## License
|
||||
|
||||
- Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea) by Charm - An awesome framework for building terminal UIs
|
||||
- Styled with [Lipgloss](https://github.com/charmbracelet/lipgloss) - Terminal styling library
|
||||
- Inspired by [kftray](https://github.com/hcavarsan/kftray) - Original GUI port-forward manager
|
||||
- Uses [client-go](https://github.com/kubernetes/client-go) for Kubernetes integration
|
||||
- Version management by [semver-gen](https://github.com/lukaszraczylo/semver-generator)
|
||||
MIT License - see [LICENSE](LICENSE).
|
||||
|
||||
## 📚 Documentation
|
||||
## Acknowledgments
|
||||
|
||||
- [Bubble Tea](https://github.com/charmbracelet/bubbletea) - Terminal UI framework
|
||||
- [Lipgloss](https://github.com/charmbracelet/lipgloss) - Terminal styling
|
||||
- [client-go](https://github.com/kubernetes/client-go) - Kubernetes client
|
||||
- [kftray](https://github.com/hcavarsan/kftray) - Inspiration
|
||||
|
||||
## Links
|
||||
|
||||
- [Website](https://lukaszraczylo.github.io/kportal)
|
||||
- [Issue Tracker](https://github.com/lukaszraczylo/kportal/issues)
|
||||
- [Issues](https://github.com/lukaszraczylo/kportal/issues)
|
||||
- [Releases](https://github.com/lukaszraczylo/kportal/releases)
|
||||
- [Changelog](CHANGELOG.md)
|
||||
|
||||
## Signal Handling
|
||||
|
||||
- `Ctrl+C` / `SIGTERM`: Graceful shutdown (closes all forwards)
|
||||
- `SIGHUP`: Reload configuration file
|
||||
|
||||
---
|
||||
|
||||
Made with ❤️ by [Lukasz Raczylo](https://github.com/lukaszraczylo)
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
# Release Infrastructure Setup Summary
|
||||
|
||||
This document summarizes all the release infrastructure that has been set up for kportal.
|
||||
|
||||
## ✅ Completed Setup
|
||||
|
||||
### 1. GitHub Actions CI/CD Pipeline
|
||||
|
||||
**File**: `.github/workflows/release.yml`
|
||||
|
||||
**Features**:
|
||||
- Multi-platform binary builds (Linux, macOS, Windows - amd64 & arm64)
|
||||
- Automatic release creation on version tags
|
||||
- Binary archiving (tar.gz for Unix, zip for Windows)
|
||||
- SHA256 checksum generation
|
||||
- Automated Homebrew formula updates
|
||||
- Release notes generation
|
||||
|
||||
**How to trigger**:
|
||||
```bash
|
||||
# Commit with semantic versioning keywords
|
||||
git commit -m "feat: add new feature"
|
||||
|
||||
# Tag the release
|
||||
git tag -a v0.2.0 -m "Release v0.2.0"
|
||||
|
||||
# Push tags
|
||||
git push origin v0.2.0
|
||||
```
|
||||
|
||||
The pipeline will automatically:
|
||||
1. Build binaries for all platforms
|
||||
2. Create GitHub release with binaries
|
||||
3. Update Homebrew tap formula
|
||||
4. Generate release notes
|
||||
|
||||
### 2. Installation Methods
|
||||
|
||||
#### A. Homebrew Formula
|
||||
|
||||
**File**: `Formula/kportal.rb`
|
||||
|
||||
**Installation command**:
|
||||
```bash
|
||||
brew install lukaszraczylo/tap/kportal
|
||||
```
|
||||
|
||||
**Note**: Formula is automatically updated by CI/CD pipeline. You'll need to create a separate tap repository:
|
||||
1. Create repo: `https://github.com/lukaszraczylo/brew-taps`
|
||||
2. Add Formula/kportal.rb to that repo
|
||||
3. Set `HOMEBREW_TAP_TOKEN` secret in GitHub repository settings
|
||||
|
||||
#### B. Quick Install Script
|
||||
|
||||
**File**: `install.sh`
|
||||
|
||||
**Features**:
|
||||
- Auto-detects OS and architecture
|
||||
- Downloads appropriate binary
|
||||
- Extracts and installs to /usr/local/bin
|
||||
- Verifies installation
|
||||
- Colorful output with emoji indicators
|
||||
|
||||
**Installation command**:
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.sh | bash
|
||||
```
|
||||
|
||||
#### C. Manual Download
|
||||
|
||||
Users can download binaries directly from GitHub releases:
|
||||
```
|
||||
https://github.com/lukaszraczylo/kportal/releases
|
||||
```
|
||||
|
||||
### 3. Documentation
|
||||
|
||||
#### A. Comprehensive README.md
|
||||
|
||||
**File**: `README.md`
|
||||
|
||||
**Contents**:
|
||||
- Feature showcase with emojis
|
||||
- Multiple installation methods
|
||||
- Quick start guide
|
||||
- Configuration examples
|
||||
- Usage instructions
|
||||
- Advanced features documentation
|
||||
- Troubleshooting guide
|
||||
- Contributing guidelines
|
||||
|
||||
#### B. GitHub Pages Website
|
||||
|
||||
**File**: `docs/index.html`
|
||||
|
||||
**Features**:
|
||||
- Modern, responsive design with TailwindCSS
|
||||
- Hero section with clear CTA
|
||||
- Feature showcase cards
|
||||
- Installation guide
|
||||
- Configuration examples with syntax highlighting
|
||||
- Documentation links
|
||||
- Mobile-friendly
|
||||
|
||||
**URL** (once enabled): `https://lukaszraczylo.github.io/kportal`
|
||||
|
||||
**To enable**:
|
||||
1. Go to GitHub repository settings
|
||||
2. Pages section
|
||||
3. Source: Deploy from a branch
|
||||
4. Branch: main
|
||||
5. Folder: /docs
|
||||
|
||||
### 4. Supporting Files
|
||||
|
||||
#### CHANGELOG.md
|
||||
**File**: `CHANGELOG.md`
|
||||
|
||||
Tracks all changes following Keep a Changelog format. Update this file with each release.
|
||||
|
||||
#### CONTRIBUTING.md
|
||||
**File**: `CONTRIBUTING.md`
|
||||
|
||||
Guidelines for:
|
||||
- Bug reporting
|
||||
- Feature requests
|
||||
- Pull request process
|
||||
- Commit message format
|
||||
- Development setup
|
||||
- Testing guidelines
|
||||
|
||||
## 🚀 Release Workflow
|
||||
|
||||
### Standard Release Process
|
||||
|
||||
1. **Develop features**
|
||||
```bash
|
||||
git checkout -b feature/my-feature
|
||||
# Make changes
|
||||
make test
|
||||
make all
|
||||
```
|
||||
|
||||
2. **Commit with semantic messages**
|
||||
```bash
|
||||
git commit -m "feat: add amazing feature"
|
||||
git commit -m "fix: resolve bug in health check"
|
||||
```
|
||||
|
||||
3. **Update CHANGELOG.md**
|
||||
```markdown
|
||||
## [0.2.0] - 2025-11-24
|
||||
|
||||
### Added
|
||||
- Amazing new feature
|
||||
|
||||
### Fixed
|
||||
- Bug in health check
|
||||
```
|
||||
|
||||
4. **Tag the release**
|
||||
```bash
|
||||
git tag -a v0.2.0 -m "Release v0.2.0"
|
||||
git push origin main
|
||||
git push origin v0.2.0
|
||||
```
|
||||
|
||||
5. **CI/CD automatically**:
|
||||
- Builds all binaries
|
||||
- Creates GitHub release
|
||||
- Updates Homebrew formula
|
||||
- Attaches binaries and checksums
|
||||
|
||||
### Version Bumping (Semantic Versioning)
|
||||
|
||||
Version is automatically determined by semver-gen from commit messages:
|
||||
|
||||
- **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`
|
||||
|
||||
## 📦 Platform Support
|
||||
|
||||
### Supported Platforms
|
||||
|
||||
| OS | Architecture | Archive Format |
|
||||
|---------|-------------|----------------|
|
||||
| Linux | amd64 | tar.gz |
|
||||
| Linux | arm64 | tar.gz |
|
||||
| macOS | amd64 | tar.gz |
|
||||
| macOS | arm64 | tar.gz |
|
||||
| Windows | amd64 | zip |
|
||||
| Windows | arm64 | zip |
|
||||
|
||||
## 🔒 Required GitHub Secrets
|
||||
|
||||
For full automation, set these secrets in your GitHub repository:
|
||||
|
||||
1. **GITHUB_TOKEN** - Automatically provided by GitHub Actions
|
||||
2. **HOMEBREW_TAP_TOKEN** - Personal access token for updating Homebrew tap
|
||||
- Create at: https://github.com/settings/tokens
|
||||
- Permissions needed: `repo` scope
|
||||
- Add to repository secrets
|
||||
|
||||
## 📝 Next Steps
|
||||
|
||||
### 1. Enable GitHub Pages
|
||||
- Repository Settings → Pages → Source: main branch, /docs folder
|
||||
|
||||
### 2. Create Homebrew Tap Repository
|
||||
```bash
|
||||
# Create new repository
|
||||
gh repo create lukaszraczylo/brew-taps --public
|
||||
|
||||
# Clone and set up
|
||||
git clone https://github.com/lukaszraczylo/brew-taps
|
||||
cd brew-taps
|
||||
cp ../kportal/Formula/kportal.rb ./Formula/
|
||||
git add Formula/kportal.rb
|
||||
git commit -m "Initial formula for kportal"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### 3. Add GitHub Token to Secrets
|
||||
- Repository Settings → Secrets and variables → Actions
|
||||
- New repository secret
|
||||
- Name: `HOMEBREW_TAP_TOKEN`
|
||||
- Value: Your personal access token
|
||||
|
||||
### 4. Create First Release
|
||||
```bash
|
||||
cd kportal
|
||||
git add .
|
||||
git commit -m "feat: initial release setup"
|
||||
git push origin main
|
||||
git tag -a v0.1.5 -m "Release v0.1.5"
|
||||
git push origin v0.1.5
|
||||
```
|
||||
|
||||
### 5. Test Installation Methods
|
||||
|
||||
After first release, test:
|
||||
```bash
|
||||
# Homebrew (once tap is set up)
|
||||
brew install lukaszraczylo/tap/kportal
|
||||
|
||||
# Quick install script
|
||||
curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.sh | bash
|
||||
|
||||
# Manual download
|
||||
# Visit releases page and download binary
|
||||
```
|
||||
|
||||
## 🎨 Customization
|
||||
|
||||
### Update Website Colors
|
||||
|
||||
Edit `docs/index.html`:
|
||||
```javascript
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#3b82f6', // Blue
|
||||
secondary: '#8b5cf6', // Purple
|
||||
dark: '#0f172a', // Dark slate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Update Release Notes Template
|
||||
|
||||
Edit `.github/workflows/release.yml` in the "Generate release notes" step.
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
After releases, monitor:
|
||||
- GitHub Actions workflow runs
|
||||
- GitHub Releases page
|
||||
- Homebrew tap repository commits
|
||||
- Download statistics on releases page
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Release workflow fails
|
||||
- Check GitHub Actions logs
|
||||
- Verify all required secrets are set
|
||||
- Ensure tag follows v\d+.\d+.\d+ format
|
||||
|
||||
### Homebrew formula not updating
|
||||
- Verify HOMEBREW_TAP_TOKEN is valid
|
||||
- Check tap repository permissions
|
||||
- Review release workflow logs
|
||||
|
||||
### Install script fails
|
||||
- Test locally with different OS/arch combinations
|
||||
- Check release binary naming matches script expectations
|
||||
- Verify binaries are attached to release
|
||||
|
||||
## ✅ Checklist for First Release
|
||||
|
||||
- [ ] All code committed and pushed
|
||||
- [ ] GitHub Pages enabled
|
||||
- [ ] Homebrew tap repository created
|
||||
- [ ] HOMEBREW_TAP_TOKEN secret set
|
||||
- [ ] CHANGELOG.md updated
|
||||
- [ ] Version tag created and pushed
|
||||
- [ ] Release workflow completed successfully
|
||||
- [ ] Binaries attached to release
|
||||
- [ ] Homebrew formula updated
|
||||
- [ ] Install script tested
|
||||
- [ ] Documentation website live
|
||||
- [ ] README.md installation links work
|
||||
|
||||
---
|
||||
|
||||
**Documentation last updated**: 2025-11-23
|
||||
**Setup completed for**: kportal v0.1.5
|
||||
+69
-136
@@ -1,171 +1,104 @@
|
||||
# Interactive Add/Remove Wizards
|
||||
# Interactive Wizards
|
||||
|
||||
kportal now includes interactive wizards for adding and removing port forwards directly from the running UI!
|
||||
kportal includes wizards for adding and removing port forwards from the running UI.
|
||||
|
||||
## Quick Start
|
||||
## ⌨️ Quick Reference
|
||||
|
||||
Run kportal normally:
|
||||
```bash
|
||||
./kportal
|
||||
```
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `a` | Add new forward |
|
||||
| `d` | Delete forwards |
|
||||
|
||||
From the main view:
|
||||
- Press **`n`** to add a new port forward
|
||||
- Press **`d`** to delete existing port forwards
|
||||
## ➕ Add Forward Wizard
|
||||
|
||||
## Add Forward Wizard (`n` key)
|
||||
Press `a` from the main view to start the wizard.
|
||||
|
||||
The wizard guides you through 7 steps to add a new forward:
|
||||
### Steps
|
||||
|
||||
### Step 1: Select Context
|
||||
Choose from available Kubernetes contexts in your kubeconfig.
|
||||
1. **Context** - Select Kubernetes context
|
||||
2. **Namespace** - Select namespace
|
||||
3. **Resource Type** - Choose pod (prefix), pod (selector), or service
|
||||
4. **Resource** - Enter prefix, selector, or select service
|
||||
5. **Remote Port** - Enter port on the resource
|
||||
6. **Local Port** - Enter local port (validates availability)
|
||||
7. **Confirm** - Review and optionally add an alias
|
||||
|
||||
### Step 2: Select Namespace
|
||||
Pick the namespace where your resource lives.
|
||||
### Navigation
|
||||
|
||||
### Step 3: Select Resource Type
|
||||
Three options:
|
||||
- **Pod (by name prefix)** - Forward to a specific pod by prefix matching
|
||||
- **Pod (by label selector)** - Forward to pods matching labels (survives restarts)
|
||||
- **Service** - Most stable, load-balanced option
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `↑↓` / `j/k` | Navigate options |
|
||||
| `Enter` | Confirm and proceed |
|
||||
| `Esc` | Go back / Cancel |
|
||||
| `Ctrl+C` | Cancel immediately |
|
||||
|
||||
### Step 4: Enter Resource
|
||||
- **Pod prefix**: Type a prefix like `nginx-` to match pods
|
||||
- **Label selector**: Enter labels like `app=nginx,env=prod`
|
||||
- **Service**: Select from a list of services
|
||||
## 🗑️ Delete Forward Wizard
|
||||
|
||||
The wizard shows real-time validation and matching resources!
|
||||
Press `d` from the main view.
|
||||
|
||||
### Step 5: Remote Port
|
||||
Enter the port number on the remote resource. The wizard displays detected ports from running containers.
|
||||
### Navigation
|
||||
|
||||
### Step 6: Local Port
|
||||
Enter the local port to bind to. The wizard checks availability in real-time.
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `↑↓` / `j/k` | Navigate |
|
||||
| `Space` | Toggle selection |
|
||||
| `a` | Select all |
|
||||
| `n` | Deselect all |
|
||||
| `Enter` | Confirm deletion |
|
||||
| `Esc` | Cancel |
|
||||
|
||||
### Step 7: Confirmation
|
||||
Review your configuration and optionally add an alias (friendly name). Confirm to save!
|
||||
## 🎯 Resource Selection
|
||||
|
||||
### Navigation Keys
|
||||
### Pod by Prefix
|
||||
|
||||
- **`↑`/`↓`** or **`j`/`k`** - Navigate options
|
||||
- **`Enter`** - Confirm and proceed to next step
|
||||
- **`Esc`** - Go back one step (or cancel on first step)
|
||||
- **`Ctrl+C`** - Hard cancel and return to main view
|
||||
- **`Backspace`** - Delete characters in text fields
|
||||
|
||||
## Remove Forward Wizard (`d` key)
|
||||
|
||||
Multi-select interface for removing forwards:
|
||||
|
||||
1. **Select forwards**: Use arrow keys to navigate, `Space` to toggle selection
|
||||
2. **Confirm removal**: Press `Enter` and confirm your choice
|
||||
|
||||
### Navigation Keys
|
||||
|
||||
- **`↑`/`↓`** or **`j`/`k`** - Navigate forwards
|
||||
- **`Space`** - Toggle selection of current forward
|
||||
- **`a`** - Select all forwards
|
||||
- **`n`** - Deselect all forwards
|
||||
- **`Enter`** - Proceed to confirmation
|
||||
- **`Esc`** - Cancel and return to main view
|
||||
- **`Ctrl+C`** - Hard cancel
|
||||
|
||||
## Auto Hot-Reload
|
||||
|
||||
When you save a forward via the wizard:
|
||||
1. The wizard writes to `.kportal.yaml` atomically
|
||||
2. The file watcher detects the change (~100ms)
|
||||
3. The manager reloads and starts the new forward
|
||||
4. The UI updates automatically
|
||||
|
||||
No restart needed!
|
||||
|
||||
## Error Handling
|
||||
|
||||
The wizards handle errors gracefully:
|
||||
|
||||
- **Cluster unreachable**: Shows error but allows manual entry
|
||||
- **Port conflicts**: Displays which process is using the port
|
||||
- **Invalid selectors**: Shows validation errors in real-time
|
||||
- **Duplicate ports**: Prevents adding forwards with conflicting ports
|
||||
|
||||
## Tips
|
||||
|
||||
### Pod Prefix Matching
|
||||
When using pod prefix, you can type just the app name:
|
||||
Enter app name prefix to match pods:
|
||||
- `nginx` matches `nginx-deployment-abc123`
|
||||
- `postgres` matches `postgres-statefulset-0`
|
||||
|
||||
### Label Selectors
|
||||
Use standard Kubernetes label syntax:
|
||||
- `app=nginx` - Single label
|
||||
- `app=nginx,env=prod` - Multiple labels (comma-separated)
|
||||
- Real-time validation shows matching pods as you type!
|
||||
### Pod by Selector
|
||||
|
||||
### Aliases
|
||||
Use aliases for cleaner UI display:
|
||||
- Instead of: `production/default/pod/nginx-deployment-abc123:80→8080`
|
||||
- Shows as: `my-nginx:80→8080`
|
||||
Use Kubernetes label syntax:
|
||||
- `app=nginx`
|
||||
- `app=nginx,env=prod`
|
||||
|
||||
### Quick Selection
|
||||
In list views, you can use `j`/`k` (Vim-style) or arrow keys for navigation.
|
||||
Matching pods are shown in real-time.
|
||||
|
||||
## Example Workflow
|
||||
### Service
|
||||
|
||||
Adding a forward for a PostgreSQL database:
|
||||
Select from discovered services in the namespace.
|
||||
|
||||
1. Press `n` in main view
|
||||
2. Select context: `production` (arrow keys + Enter)
|
||||
3. Select namespace: `default` (arrow keys + Enter)
|
||||
4. Select type: `Service` (arrow keys + Enter)
|
||||
5. Select service: `postgres` (arrow keys + Enter)
|
||||
6. Enter remote port: `5432` (type + Enter)
|
||||
7. Enter local port: `5432` (type + Enter)
|
||||
8. Add alias: `prod-db` (optional, type + Enter)
|
||||
9. Confirm: Select "Add to .kportal.yaml" (Enter)
|
||||
## 🔄 Auto Hot-Reload
|
||||
|
||||
Done! The forward starts automatically within seconds.
|
||||
Changes are applied automatically:
|
||||
1. Wizard writes to `.kportal.yaml` atomically
|
||||
2. File watcher detects change (~100ms)
|
||||
3. Manager reloads and starts forward
|
||||
4. UI updates
|
||||
|
||||
## Architecture
|
||||
## Error Handling
|
||||
|
||||
The wizards use:
|
||||
- **Config Mutator**: Safe, atomic YAML writes (temp file + rename)
|
||||
- **K8s Discovery**: Lists contexts, namespaces, pods, services
|
||||
- **Modal Overlays**: Wizards appear centered over the main view
|
||||
- **Async Validation**: Port checks and selector validation run in background
|
||||
- **Hot-Reload Integration**: File watcher picks up changes automatically
|
||||
The wizards handle:
|
||||
- Cluster unreachable - allows manual entry
|
||||
- Port conflicts - shows which process is using the port
|
||||
- Invalid selectors - real-time validation
|
||||
- Duplicate ports - prevents conflicts
|
||||
|
||||
## Troubleshooting
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Wizards not appearing?
|
||||
Check that kportal can connect to your Kubernetes cluster:
|
||||
### Wizard not appearing
|
||||
|
||||
Verify cluster connectivity:
|
||||
```bash
|
||||
kubectl cluster-info
|
||||
```
|
||||
|
||||
### Port check showing wrong status?
|
||||
The port check happens asynchronously. Wait a moment after typing for validation.
|
||||
### Port validation delayed
|
||||
|
||||
### Changes not appearing?
|
||||
The file watcher triggers within 100ms. If changes aren't visible, check:
|
||||
Port checks run asynchronously. Wait briefly after typing.
|
||||
|
||||
### Changes not visible
|
||||
|
||||
Check:
|
||||
1. `.kportal.yaml` was written correctly
|
||||
2. No validation errors in the file
|
||||
3. kportal process is still running
|
||||
|
||||
---
|
||||
|
||||
**Navigation Summary**
|
||||
|
||||
Main View:
|
||||
- `n` - New forward wizard
|
||||
- `d` - Delete forward wizard
|
||||
- `Space` - Toggle forward on/off
|
||||
- `↑↓/jk` - Navigate forwards
|
||||
- `q` - Quit
|
||||
|
||||
Wizards:
|
||||
- `Enter` - Next step / Confirm
|
||||
- `Esc` - Previous step / Cancel
|
||||
- `Ctrl+C` - Hard cancel
|
||||
- `↑↓/jk` - Navigate
|
||||
- `Space` - Toggle (in delete wizard)
|
||||
2. No validation errors in file
|
||||
3. kportal process is running
|
||||
|
||||
+109
-8
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -12,12 +13,15 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
"github.com/nvm/kportal/internal/converter"
|
||||
"github.com/nvm/kportal/internal/forward"
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
"github.com/nvm/kportal/internal/logger"
|
||||
"github.com/nvm/kportal/internal/mdns"
|
||||
"github.com/nvm/kportal/internal/ui"
|
||||
"github.com/nvm/kportal/internal/version"
|
||||
"k8s.io/klog/v2"
|
||||
)
|
||||
|
||||
@@ -25,6 +29,10 @@ const (
|
||||
defaultConfigFile = ".kportal.yaml"
|
||||
initialForwardSettleTime = 100 * time.Millisecond
|
||||
tableUpdateInterval = 2 * time.Second
|
||||
|
||||
// GitHub repository info for update checks
|
||||
githubOwner = "lukaszraczylo"
|
||||
githubRepo = "kportal"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -33,16 +41,22 @@ var (
|
||||
logFormat = flag.String("log-format", "text", "Log format: text or json")
|
||||
check = flag.Bool("check", false, "Validate configuration and exit")
|
||||
showVersion = flag.Bool("version", false, "Show version and exit")
|
||||
checkUpdate = flag.Bool("update", false, "Check for updates and exit")
|
||||
convertInput = flag.String("convert", "", "Convert kftray JSON config to kportal YAML (provide input file path)")
|
||||
convertOutput = flag.String("convert-output", ".kportal.yaml", "Output file for converted configuration")
|
||||
version = "0.1.0" // Set via ldflags during build
|
||||
appVersion = "0.1.0" // Set via ldflags during build
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Printf("kportal version %s\n", version)
|
||||
fmt.Printf("kportal version %s\n", appVersion)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if *checkUpdate {
|
||||
checkForUpdates()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
@@ -91,16 +105,35 @@ func main() {
|
||||
|
||||
// Configure klog (used by kubernetes client-go) to route through our logger
|
||||
// This prevents k8s logs from interfering with the UI
|
||||
//
|
||||
// klog v2 uses multiple output mechanisms:
|
||||
// 1. SetOutput() - for basic text output
|
||||
// 2. SetLogger() - for structured/error logs (logr interface)
|
||||
//
|
||||
// We must configure BOTH to capture all logs including error messages
|
||||
// that would otherwise bypass SetOutput() and write directly to stderr.
|
||||
klog.LogToStderr(false) // Disable direct stderr writes
|
||||
if *verbose {
|
||||
// In verbose mode, route klog through our structured logger at DEBUG level
|
||||
// In verbose mode, route all klog through our structured logger at DEBUG level
|
||||
klogLogger := logger.New(logger.LevelDebug, logFmt, os.Stderr)
|
||||
|
||||
// Configure text output routing
|
||||
klogWriter := logger.NewKlogWriter(klogLogger)
|
||||
klog.SetOutput(klogWriter)
|
||||
|
||||
// Configure structured/error log routing via logr interface
|
||||
// This captures "Unhandled Error" and other structured logs that bypass SetOutput
|
||||
logrSink := logger.NewLogrAdapter(klogLogger)
|
||||
klog.SetLogger(logr.New(logrSink))
|
||||
} else {
|
||||
// In non-verbose mode, completely silence klog
|
||||
// In non-verbose mode, completely silence ALL klog output
|
||||
klog.SetOutput(io.Discard)
|
||||
|
||||
// Also silence structured/error logs via a discard logger
|
||||
silentLogger := logger.New(logger.LevelError+1, logFmt, io.Discard) // Level above ERROR = silence all
|
||||
logrSink := logger.NewLogrAdapter(silentLogger)
|
||||
klog.SetLogger(logr.New(logrSink))
|
||||
}
|
||||
klog.LogToStderr(false)
|
||||
|
||||
// Handle conversion mode
|
||||
if *convertInput != "" {
|
||||
@@ -157,7 +190,7 @@ func main() {
|
||||
|
||||
// Only log startup messages in verbose mode
|
||||
if *verbose {
|
||||
log.Printf("kportal v%s", version)
|
||||
log.Printf("kportal v%s", appVersion)
|
||||
log.Printf("Loading configuration from: %s", *configFile)
|
||||
}
|
||||
|
||||
@@ -177,6 +210,14 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Create mDNS publisher if enabled in config
|
||||
mdnsPublisher := mdns.NewPublisher(cfg.IsMDNSEnabled())
|
||||
manager.SetMDNSPublisher(mdnsPublisher)
|
||||
|
||||
if cfg.IsMDNSEnabled() && *verbose {
|
||||
log.Printf("mDNS hostname publishing enabled - aliases will be accessible via <alias>.local")
|
||||
}
|
||||
|
||||
// Create UI (bubbletea for interactive, simple table for verbose)
|
||||
var bubbleTeaUI *ui.BubbleTeaUI
|
||||
var tableUI *ui.TableUI
|
||||
@@ -189,17 +230,40 @@ func main() {
|
||||
} else {
|
||||
manager.DisableForward(id)
|
||||
}
|
||||
}, version)
|
||||
}, appVersion)
|
||||
|
||||
// Set wizard dependencies
|
||||
// Note: mutator is always available (for delete/edit), discovery requires valid kubeconfig (for add)
|
||||
bubbleTeaUI.SetWizardDependencies(discovery, mutator, *configFile)
|
||||
|
||||
// Check for updates in background (non-blocking)
|
||||
go func() {
|
||||
checker := version.NewChecker(githubOwner, githubRepo, appVersion)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if update := checker.CheckForUpdate(ctx); update != nil {
|
||||
bubbleTeaUI.SetUpdateAvailable(update.LatestVersion, update.ReleaseURL)
|
||||
}
|
||||
}()
|
||||
|
||||
manager.SetStatusUI(bubbleTeaUI)
|
||||
} else {
|
||||
// Verbose mode with simple table
|
||||
tableUI = ui.NewTableUI(*verbose)
|
||||
manager.SetStatusUI(tableUI)
|
||||
|
||||
// Check for updates and print to log
|
||||
go func() {
|
||||
checker := version.NewChecker(githubOwner, githubRepo, appVersion)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if update := checker.CheckForUpdate(ctx); update != nil {
|
||||
log.Printf("Update available: v%s (current: v%s) - %s",
|
||||
update.LatestVersion, update.CurrentVersion, update.ReleaseURL)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Start forwards
|
||||
@@ -263,7 +327,23 @@ func main() {
|
||||
|
||||
case os.Interrupt, syscall.SIGTERM:
|
||||
log.Printf("Received shutdown signal, stopping...")
|
||||
manager.Stop()
|
||||
|
||||
// Graceful shutdown with timeout - force exit if it takes too long
|
||||
shutdownDone := make(chan struct{})
|
||||
go func() {
|
||||
manager.Stop()
|
||||
close(shutdownDone)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-shutdownDone:
|
||||
log.Printf("Graceful shutdown complete")
|
||||
case <-time.After(5 * time.Second):
|
||||
log.Printf("Shutdown timed out, forcing exit...")
|
||||
case sig := <-sigChan:
|
||||
// Second signal received - force exit immediately
|
||||
log.Printf("Received second signal (%v), forcing exit...", sig)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
@@ -302,3 +382,24 @@ func main() {
|
||||
manager.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// checkForUpdates checks for available updates and prints the result
|
||||
func checkForUpdates() {
|
||||
fmt.Printf("kportal version %s\n", appVersion)
|
||||
fmt.Println("Checking for updates...")
|
||||
|
||||
checker := version.NewChecker(githubOwner, githubRepo, appVersion)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
update := checker.CheckForUpdate(ctx)
|
||||
if update == nil {
|
||||
fmt.Println("You are running the latest version.")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\nUpdate available: v%s\n", update.LatestVersion)
|
||||
fmt.Printf("Download: %s\n", update.ReleaseURL)
|
||||
fmt.Println("\nTo update, download the latest release from the URL above")
|
||||
fmt.Println("or use your package manager (e.g., 'brew upgrade kportal').")
|
||||
}
|
||||
|
||||
+426
-672
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,9 @@ require (
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/go-logr/logr v1.4.3
|
||||
github.com/grandcat/zeroconf v1.0.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/term v0.37.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
k8s.io/api v0.34.2
|
||||
k8s.io/apimachinery v0.34.2
|
||||
@@ -17,6 +18,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
||||
github.com/charmbracelet/colorprofile v0.3.3 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
||||
@@ -28,7 +30,6 @@ require (
|
||||
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
||||
github.com/go-openapi/swag v0.25.3 // indirect
|
||||
@@ -52,6 +53,7 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/miekg/dns v1.1.68 // indirect
|
||||
github.com/moby/spdystream v0.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
@@ -67,15 +69,19 @@ require (
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/mod v0.30.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/oauth2 v0.33.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/term v0.37.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20251121143641-b6aabc6c6745 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
|
||||
@@ -2,6 +2,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
|
||||
@@ -82,6 +84,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||
github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE=
|
||||
github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
|
||||
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=
|
||||
@@ -98,6 +102,9 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
|
||||
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
|
||||
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
|
||||
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
|
||||
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -120,6 +127,8 @@ github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM
|
||||
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
||||
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
@@ -149,10 +158,15 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
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/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
@@ -162,8 +176,11 @@ golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE
|
||||
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/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
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-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -179,10 +196,11 @@ golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
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-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
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=
|
||||
@@ -206,8 +224,8 @@ k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M=
|
||||
k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE=
|
||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||
k8s.io/kube-openapi v0.0.0-20251121143641-b6aabc6c6745 h1:c3rI/4s8ibM4vV5UOIlbgkBpwkylI5I9YiPlOtf2g4Q=
|
||||
k8s.io/kube-openapi v0.0.0-20251121143641-b6aabc6c6745/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
|
||||
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ=
|
||||
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
|
||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
||||
|
||||
+158
-38
@@ -1,19 +1,149 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
maxConfigSize = 10 * 1024 * 1024 // 10MB
|
||||
// maxConfigSize is the maximum allowed configuration file size (10MB)
|
||||
maxConfigSize = 10 * 1024 * 1024
|
||||
|
||||
// Default health check settings
|
||||
DefaultHealthCheckInterval = 3 * time.Second // How often to check connection health
|
||||
DefaultHealthCheckTimeout = 2 * time.Second // Timeout for health check probes
|
||||
DefaultHealthCheckMethod = "data-transfer" // More reliable than tcp-dial
|
||||
DefaultMaxConnectionAge = 25 * time.Minute // Reconnect before k8s 30min timeout
|
||||
DefaultMaxIdleTime = 10 * time.Minute // Reconnect if no activity
|
||||
|
||||
// Default reliability settings
|
||||
DefaultTCPKeepalive = 30 * time.Second // OS-level TCP keepalive interval
|
||||
DefaultDialTimeout = 30 * time.Second // Connection establishment timeout
|
||||
DefaultWatchdogPeriod = 30 * time.Second // Goroutine health check interval
|
||||
)
|
||||
|
||||
// Config represents the root configuration structure from .kportal.yaml
|
||||
type Config struct {
|
||||
Contexts []Context `yaml:"contexts"`
|
||||
Contexts []Context `yaml:"contexts"`
|
||||
HealthCheck *HealthCheckSpec `yaml:"healthCheck,omitempty"`
|
||||
Reliability *ReliabilitySpec `yaml:"reliability,omitempty"`
|
||||
MDNS *MDNSSpec `yaml:"mdns,omitempty"`
|
||||
}
|
||||
|
||||
// MDNSSpec configures mDNS (multicast DNS) hostname publishing
|
||||
// When enabled, forwards with aliases can be accessed via <alias>.local hostnames
|
||||
type MDNSSpec struct {
|
||||
Enabled bool `yaml:"enabled"` // Enable mDNS hostname publishing
|
||||
}
|
||||
|
||||
// HealthCheckSpec configures health check behavior
|
||||
type HealthCheckSpec struct {
|
||||
Interval string `yaml:"interval,omitempty"` // e.g., "3s", "5s"
|
||||
Timeout string `yaml:"timeout,omitempty"` // e.g., "2s"
|
||||
Method string `yaml:"method,omitempty"` // "tcp-dial" | "data-transfer"
|
||||
MaxConnectionAge string `yaml:"maxConnectionAge,omitempty"` // e.g., "25m" - reconnect before k8s timeout
|
||||
MaxIdleTime string `yaml:"maxIdleTime,omitempty"` // e.g., "10m" - reconnect if no activity
|
||||
}
|
||||
|
||||
// ReliabilitySpec configures connection reliability features
|
||||
type ReliabilitySpec struct {
|
||||
TCPKeepalive string `yaml:"tcpKeepalive,omitempty"` // e.g., "30s" - OS-level keepalive
|
||||
DialTimeout string `yaml:"dialTimeout,omitempty"` // e.g., "30s" - connection dial timeout
|
||||
RetryOnStale bool `yaml:"retryOnStale,omitempty"` // Auto-reconnect on stale detection
|
||||
WatchdogPeriod string `yaml:"watchdogPeriod,omitempty"` // e.g., "30s" - goroutine watchdog interval
|
||||
}
|
||||
|
||||
// parseDurationOrDefault parses a duration string and returns the default if empty or invalid.
|
||||
func parseDurationOrDefault(value string, defaultDur time.Duration) time.Duration {
|
||||
if value == "" {
|
||||
return defaultDur
|
||||
}
|
||||
if d, err := time.ParseDuration(value); err == nil {
|
||||
return d
|
||||
}
|
||||
return defaultDur
|
||||
}
|
||||
|
||||
// GetHealthCheckIntervalOrDefault returns the health check interval or default value
|
||||
func (c *Config) GetHealthCheckIntervalOrDefault() time.Duration {
|
||||
if c.HealthCheck == nil {
|
||||
return DefaultHealthCheckInterval
|
||||
}
|
||||
return parseDurationOrDefault(c.HealthCheck.Interval, DefaultHealthCheckInterval)
|
||||
}
|
||||
|
||||
// GetHealthCheckTimeoutOrDefault returns the health check timeout or default value
|
||||
func (c *Config) GetHealthCheckTimeoutOrDefault() time.Duration {
|
||||
if c.HealthCheck == nil {
|
||||
return DefaultHealthCheckTimeout
|
||||
}
|
||||
return parseDurationOrDefault(c.HealthCheck.Timeout, DefaultHealthCheckTimeout)
|
||||
}
|
||||
|
||||
// GetHealthCheckMethod returns the health check method or default
|
||||
func (c *Config) GetHealthCheckMethod() string {
|
||||
if c.HealthCheck != nil && c.HealthCheck.Method != "" {
|
||||
return c.HealthCheck.Method
|
||||
}
|
||||
return DefaultHealthCheckMethod
|
||||
}
|
||||
|
||||
// GetMaxConnectionAge returns the max connection age or default
|
||||
func (c *Config) GetMaxConnectionAge() time.Duration {
|
||||
if c.HealthCheck == nil {
|
||||
return DefaultMaxConnectionAge
|
||||
}
|
||||
return parseDurationOrDefault(c.HealthCheck.MaxConnectionAge, DefaultMaxConnectionAge)
|
||||
}
|
||||
|
||||
// GetMaxIdleTime returns the max idle time or default
|
||||
func (c *Config) GetMaxIdleTime() time.Duration {
|
||||
if c.HealthCheck == nil {
|
||||
return DefaultMaxIdleTime
|
||||
}
|
||||
return parseDurationOrDefault(c.HealthCheck.MaxIdleTime, DefaultMaxIdleTime)
|
||||
}
|
||||
|
||||
// GetTCPKeepalive returns the TCP keepalive duration or default
|
||||
func (c *Config) GetTCPKeepalive() time.Duration {
|
||||
if c.Reliability == nil {
|
||||
return DefaultTCPKeepalive
|
||||
}
|
||||
return parseDurationOrDefault(c.Reliability.TCPKeepalive, DefaultTCPKeepalive)
|
||||
}
|
||||
|
||||
// GetRetryOnStale returns whether to retry on stale connections
|
||||
func (c *Config) GetRetryOnStale() bool {
|
||||
if c.Reliability != nil {
|
||||
return c.Reliability.RetryOnStale
|
||||
}
|
||||
return true // Default: enabled
|
||||
}
|
||||
|
||||
// GetWatchdogPeriod returns the goroutine watchdog check period or default
|
||||
func (c *Config) GetWatchdogPeriod() time.Duration {
|
||||
if c.Reliability == nil {
|
||||
return DefaultWatchdogPeriod
|
||||
}
|
||||
return parseDurationOrDefault(c.Reliability.WatchdogPeriod, DefaultWatchdogPeriod)
|
||||
}
|
||||
|
||||
// GetDialTimeout returns the connection dial timeout or default
|
||||
func (c *Config) GetDialTimeout() time.Duration {
|
||||
if c.Reliability == nil {
|
||||
return DefaultDialTimeout
|
||||
}
|
||||
return parseDurationOrDefault(c.Reliability.DialTimeout, DefaultDialTimeout)
|
||||
}
|
||||
|
||||
// IsMDNSEnabled returns whether mDNS hostname publishing is enabled
|
||||
func (c *Config) IsMDNSEnabled() bool {
|
||||
return c.MDNS != nil && c.MDNS.Enabled
|
||||
}
|
||||
|
||||
// Context represents a Kubernetes context with its namespaces
|
||||
@@ -82,6 +212,25 @@ func (f *Forward) GetNamespace() string {
|
||||
return f.namespaceName
|
||||
}
|
||||
|
||||
// GetMDNSAlias returns the alias to use for mDNS hostname registration.
|
||||
// If an explicit alias is set, it returns that.
|
||||
// Otherwise, it generates one from the resource name (e.g., "service/logto" -> "logto").
|
||||
func (f *Forward) GetMDNSAlias() string {
|
||||
if f.Alias != "" {
|
||||
return f.Alias
|
||||
}
|
||||
|
||||
// Generate alias from resource name
|
||||
// Format is "type/name" (e.g., "service/logto", "pod/my-app")
|
||||
parts := strings.SplitN(f.Resource, "/", 2)
|
||||
if len(parts) == 2 && parts[1] != "" {
|
||||
return parts[1]
|
||||
}
|
||||
|
||||
// Fallback: can't generate a valid alias (e.g., "pod" with selector)
|
||||
return ""
|
||||
}
|
||||
|
||||
// LoadConfig loads and parses the configuration file from the given path.
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
// Validate file size before reading
|
||||
@@ -103,9 +252,15 @@ func LoadConfig(path string) (*Config, error) {
|
||||
}
|
||||
|
||||
// ParseConfig parses YAML configuration data into a Config struct.
|
||||
// It uses strict parsing that rejects unknown keys to catch typos.
|
||||
func ParseConfig(data []byte) (*Config, error) {
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
|
||||
// Use decoder with KnownFields to reject unknown keys (catches typos)
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||
decoder.KnownFields(true)
|
||||
|
||||
if err := decoder.Decode(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse YAML: %w", err)
|
||||
}
|
||||
|
||||
@@ -136,38 +291,3 @@ func (c *Config) GetAllForwards() []Forward {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -298,72 +298,6 @@ func TestConfig_GetAllForwards(t *testing.T) {
|
||||
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",
|
||||
|
||||
@@ -6,10 +6,15 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
minPort = 1
|
||||
maxPort = 65535
|
||||
MinPort = 1
|
||||
MaxPort = 65535
|
||||
)
|
||||
|
||||
// IsValidPort returns true if the port number is within the valid range (1-65535).
|
||||
func IsValidPort(port int) bool {
|
||||
return port >= MinPort && port <= MaxPort
|
||||
}
|
||||
|
||||
// ValidationError represents a configuration validation error with context.
|
||||
type ValidationError struct {
|
||||
Field string // The field that failed validation
|
||||
@@ -56,6 +61,11 @@ func (v *Validator) ValidateConfig(cfg *Config) []ValidationError {
|
||||
// Check for duplicate local ports
|
||||
errs = append(errs, v.validateDuplicatePorts(cfg)...)
|
||||
|
||||
// Validate mDNS configuration
|
||||
if cfg.IsMDNSEnabled() {
|
||||
errs = append(errs, v.validateMDNS(cfg)...)
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
@@ -84,7 +94,7 @@ func (v *Validator) validateStructure(cfg *Config) []ValidationError {
|
||||
Field: fmt.Sprintf("contexts[%d].namespaces", i),
|
||||
Message: fmt.Sprintf("Context '%s' must have at least one namespace", ctx.Name),
|
||||
})
|
||||
continue
|
||||
// Don't continue - still validate other aspects of the context if any
|
||||
}
|
||||
|
||||
for j, ns := range ctx.Namespaces {
|
||||
@@ -130,17 +140,17 @@ func (v *Validator) validateForward(fwd *Forward) []ValidationError {
|
||||
}
|
||||
|
||||
// Validate ports
|
||||
if fwd.Port < minPort || fwd.Port > maxPort {
|
||||
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),
|
||||
Message: fmt.Sprintf("Invalid port %d for forward %s (must be between %d and %d)", fwd.Port, fwd.ID(), MinPort, MaxPort),
|
||||
})
|
||||
}
|
||||
|
||||
if fwd.LocalPort < minPort || fwd.LocalPort > maxPort {
|
||||
if 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),
|
||||
Message: fmt.Sprintf("Invalid localPort %d for forward %s (must be between %d and %d)", fwd.LocalPort, fwd.ID(), MinPort, MaxPort),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -265,3 +275,85 @@ func FormatValidationErrors(errs []ValidationError) string {
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// validateMDNS validates mDNS configuration when enabled.
|
||||
// It checks that aliases used for mDNS hostnames are valid and unique.
|
||||
// This includes both explicit aliases and auto-generated ones from resource names.
|
||||
func (v *Validator) validateMDNS(cfg *Config) []ValidationError {
|
||||
var errs []ValidationError
|
||||
|
||||
aliasMap := make(map[string][]string) // alias -> list of forward IDs using it
|
||||
|
||||
for _, ctx := range cfg.Contexts {
|
||||
for _, ns := range ctx.Namespaces {
|
||||
for _, fwd := range ns.Forwards {
|
||||
// Get the mDNS alias (explicit or generated from resource name)
|
||||
mdnsAlias := fwd.GetMDNSAlias()
|
||||
if mdnsAlias == "" {
|
||||
// No alias available (e.g., "pod" with selector only)
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate alias is a valid hostname (RFC 1123)
|
||||
if !isValidHostname(mdnsAlias) {
|
||||
errs = append(errs, ValidationError{
|
||||
Field: "alias",
|
||||
Message: fmt.Sprintf("Forward %s has invalid mDNS hostname '%s' (must be a valid RFC 1123 hostname)", fwd.ID(), mdnsAlias),
|
||||
})
|
||||
}
|
||||
|
||||
aliasMap[mdnsAlias] = append(aliasMap[mdnsAlias], fwd.ID())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate aliases (would cause mDNS conflicts)
|
||||
for alias, forwards := range aliasMap {
|
||||
if len(forwards) > 1 {
|
||||
errs = append(errs, ValidationError{
|
||||
Field: "alias",
|
||||
Message: fmt.Sprintf("Duplicate mDNS hostname '%s' used by multiple forwards (would cause conflict)", alias),
|
||||
Context: map[string]string{
|
||||
"alias": alias,
|
||||
"forwards": strings.Join(forwards, ", "),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return errs
|
||||
}
|
||||
|
||||
// isValidHostname checks if a string is a valid RFC 1123 hostname.
|
||||
// Hostnames must start with alphanumeric, contain only alphanumeric and hyphens,
|
||||
// and be 1-63 characters long.
|
||||
func isValidHostname(name string) bool {
|
||||
if len(name) == 0 || len(name) > 63 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must start with alphanumeric
|
||||
if !isAlphanumeric(name[0]) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must end with alphanumeric
|
||||
if !isAlphanumeric(name[len(name)-1]) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check all characters
|
||||
for i := 0; i < len(name); i++ {
|
||||
c := name[i]
|
||||
if !isAlphanumeric(c) && c != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// isAlphanumeric returns true if the character is a letter or digit.
|
||||
func isAlphanumeric(c byte) bool {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
|
||||
}
|
||||
|
||||
@@ -701,3 +701,274 @@ func TestValidator_ValidateStructure(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_ValidateMDNS(t *testing.T) {
|
||||
validator := NewValidator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
expectErrors bool
|
||||
errorContains []string
|
||||
}{
|
||||
{
|
||||
name: "mDNS disabled - no validation",
|
||||
config: &Config{
|
||||
Contexts: []Context{
|
||||
{
|
||||
Name: "dev",
|
||||
Namespaces: []Namespace{
|
||||
{
|
||||
Name: "default",
|
||||
Forwards: []Forward{
|
||||
{Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "invalid_alias", contextName: "dev", namespaceName: "default"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectErrors: false,
|
||||
},
|
||||
{
|
||||
name: "mDNS enabled - valid aliases",
|
||||
config: &Config{
|
||||
MDNS: &MDNSSpec{Enabled: true},
|
||||
Contexts: []Context{
|
||||
{
|
||||
Name: "dev",
|
||||
Namespaces: []Namespace{
|
||||
{
|
||||
Name: "default",
|
||||
Forwards: []Forward{
|
||||
{Resource: "pod/app1", Port: 8080, LocalPort: 8080, Alias: "my-app", contextName: "dev", namespaceName: "default"},
|
||||
{Resource: "pod/app2", Port: 8081, LocalPort: 8081, Alias: "my-service", contextName: "dev", namespaceName: "default"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectErrors: false,
|
||||
},
|
||||
{
|
||||
name: "mDNS enabled - no alias (allowed)",
|
||||
config: &Config{
|
||||
MDNS: &MDNSSpec{Enabled: true},
|
||||
Contexts: []Context{
|
||||
{
|
||||
Name: "dev",
|
||||
Namespaces: []Namespace{
|
||||
{
|
||||
Name: "default",
|
||||
Forwards: []Forward{
|
||||
{Resource: "pod/app", Port: 8080, LocalPort: 8080, contextName: "dev", namespaceName: "default"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectErrors: false,
|
||||
},
|
||||
{
|
||||
name: "mDNS enabled - invalid alias with underscore",
|
||||
config: &Config{
|
||||
MDNS: &MDNSSpec{Enabled: true},
|
||||
Contexts: []Context{
|
||||
{
|
||||
Name: "dev",
|
||||
Namespaces: []Namespace{
|
||||
{
|
||||
Name: "default",
|
||||
Forwards: []Forward{
|
||||
{Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "my_app", contextName: "dev", namespaceName: "default"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectErrors: true,
|
||||
errorContains: []string{"invalid mDNS hostname", "RFC 1123"},
|
||||
},
|
||||
{
|
||||
name: "mDNS enabled - alias starts with hyphen",
|
||||
config: &Config{
|
||||
MDNS: &MDNSSpec{Enabled: true},
|
||||
Contexts: []Context{
|
||||
{
|
||||
Name: "dev",
|
||||
Namespaces: []Namespace{
|
||||
{
|
||||
Name: "default",
|
||||
Forwards: []Forward{
|
||||
{Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "-myapp", contextName: "dev", namespaceName: "default"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectErrors: true,
|
||||
errorContains: []string{"invalid mDNS hostname"},
|
||||
},
|
||||
{
|
||||
name: "mDNS enabled - alias ends with hyphen",
|
||||
config: &Config{
|
||||
MDNS: &MDNSSpec{Enabled: true},
|
||||
Contexts: []Context{
|
||||
{
|
||||
Name: "dev",
|
||||
Namespaces: []Namespace{
|
||||
{
|
||||
Name: "default",
|
||||
Forwards: []Forward{
|
||||
{Resource: "pod/app", Port: 8080, LocalPort: 8080, Alias: "myapp-", contextName: "dev", namespaceName: "default"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectErrors: true,
|
||||
errorContains: []string{"invalid mDNS hostname"},
|
||||
},
|
||||
{
|
||||
name: "mDNS enabled - duplicate aliases",
|
||||
config: &Config{
|
||||
MDNS: &MDNSSpec{Enabled: true},
|
||||
Contexts: []Context{
|
||||
{
|
||||
Name: "dev",
|
||||
Namespaces: []Namespace{
|
||||
{
|
||||
Name: "default",
|
||||
Forwards: []Forward{
|
||||
{Resource: "pod/app1", Port: 8080, LocalPort: 8080, Alias: "myapp", contextName: "dev", namespaceName: "default"},
|
||||
{Resource: "pod/app2", Port: 8081, LocalPort: 8081, Alias: "myapp", contextName: "dev", namespaceName: "default"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectErrors: true,
|
||||
errorContains: []string{"Duplicate mDNS hostname", "conflict"},
|
||||
},
|
||||
{
|
||||
name: "mDNS enabled - duplicate aliases across contexts",
|
||||
config: &Config{
|
||||
MDNS: &MDNSSpec{Enabled: true},
|
||||
Contexts: []Context{
|
||||
{
|
||||
Name: "cluster1",
|
||||
Namespaces: []Namespace{
|
||||
{
|
||||
Name: "default",
|
||||
Forwards: []Forward{
|
||||
{Resource: "pod/app1", Port: 8080, LocalPort: 8080, Alias: "shared-name", contextName: "cluster1", namespaceName: "default"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "cluster2",
|
||||
Namespaces: []Namespace{
|
||||
{
|
||||
Name: "default",
|
||||
Forwards: []Forward{
|
||||
{Resource: "pod/app2", Port: 8081, LocalPort: 8081, Alias: "shared-name", contextName: "cluster2", namespaceName: "default"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectErrors: true,
|
||||
errorContains: []string{"Duplicate mDNS hostname", "shared-name"},
|
||||
},
|
||||
}
|
||||
|
||||
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 in errors: %v", expectedMsg, errs)
|
||||
}
|
||||
} else {
|
||||
assert.Empty(t, errs, "expected no validation errors, got: %v", errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidHostname(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hostname string
|
||||
valid bool
|
||||
}{
|
||||
{"valid simple", "myservice", true},
|
||||
{"valid with hyphen", "my-service", true},
|
||||
{"valid with numbers", "service123", true},
|
||||
{"valid mixed", "my-service-123", true},
|
||||
{"valid uppercase", "MyService", true},
|
||||
{"valid single char", "a", true},
|
||||
{"valid single digit", "1", true},
|
||||
{"valid max length (63)", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true},
|
||||
{"invalid empty", "", false},
|
||||
{"invalid too long (64)", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false},
|
||||
{"invalid starts with hyphen", "-myservice", false},
|
||||
{"invalid ends with hyphen", "myservice-", false},
|
||||
{"invalid underscore", "my_service", false},
|
||||
{"invalid dot", "my.service", false},
|
||||
{"invalid space", "my service", false},
|
||||
{"invalid special char", "my@service", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isValidHostname(tt.hostname)
|
||||
assert.Equal(t, tt.valid, result, "isValidHostname(%q) = %v, want %v", tt.hostname, result, tt.valid)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAlphanumeric(t *testing.T) {
|
||||
tests := []struct {
|
||||
char byte
|
||||
valid bool
|
||||
}{
|
||||
{'a', true},
|
||||
{'z', true},
|
||||
{'A', true},
|
||||
{'Z', true},
|
||||
{'0', true},
|
||||
{'9', true},
|
||||
{'-', false},
|
||||
{'_', false},
|
||||
{'.', false},
|
||||
{' ', false},
|
||||
{'@', false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.char), func(t *testing.T) {
|
||||
result := isAlphanumeric(tt.char)
|
||||
assert.Equal(t, tt.valid, result, "isAlphanumeric(%q) = %v, want %v", tt.char, result, tt.valid)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+155
-14
@@ -10,11 +10,7 @@ import (
|
||||
"github.com/nvm/kportal/internal/healthcheck"
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
"github.com/nvm/kportal/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
healthCheckInterval = 5 * time.Second
|
||||
healthCheckTimeout = 2 * time.Second
|
||||
"github.com/nvm/kportal/internal/mdns"
|
||||
)
|
||||
|
||||
// StatusUpdater is an interface for updating forward status
|
||||
@@ -34,12 +30,16 @@ type Manager struct {
|
||||
portForwarder *k8s.PortForwarder
|
||||
portChecker *PortChecker
|
||||
healthChecker *healthcheck.Checker
|
||||
watchdog *Watchdog
|
||||
mdnsPublisher *mdns.Publisher
|
||||
verbose bool
|
||||
currentConfig *config.Config
|
||||
statusUI StatusUpdater
|
||||
}
|
||||
|
||||
// NewManager creates a new forward Manager.
|
||||
// The health checker will be created with default settings and can be
|
||||
// reconfigured via SetConfig().
|
||||
func NewManager(verbose bool) (*Manager, error) {
|
||||
clientPool, err := k8s.NewClientPool()
|
||||
if err != nil {
|
||||
@@ -49,8 +49,13 @@ func NewManager(verbose bool) (*Manager, error) {
|
||||
resolver := k8s.NewResourceResolver(clientPool)
|
||||
portForwarder := k8s.NewPortForwarder(clientPool, resolver)
|
||||
|
||||
// Create health checker: check every 5 seconds with 2 second timeout
|
||||
healthChecker := healthcheck.NewChecker(healthCheckInterval, healthCheckTimeout)
|
||||
// Create health checker with defaults: check every 3 seconds with 2 second timeout
|
||||
// Will be reconfigured when config is loaded
|
||||
healthChecker := healthcheck.NewChecker(3*time.Second, 2*time.Second)
|
||||
|
||||
// Create watchdog with default settings: check every 30 seconds, 60 second hang threshold
|
||||
// Will be reconfigured when config is loaded
|
||||
watchdog := NewWatchdog(30*time.Second, 60*time.Second)
|
||||
|
||||
return &Manager{
|
||||
workers: make(map[string]*ForwardWorker),
|
||||
@@ -59,15 +64,66 @@ func NewManager(verbose bool) (*Manager, error) {
|
||||
portForwarder: portForwarder,
|
||||
portChecker: NewPortChecker(),
|
||||
healthChecker: healthChecker,
|
||||
watchdog: watchdog,
|
||||
verbose: verbose,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// configureHealthChecker creates a new health checker with settings from config
|
||||
func (m *Manager) configureHealthChecker(cfg *config.Config) {
|
||||
// Stop existing health checker
|
||||
if m.healthChecker != nil {
|
||||
m.healthChecker.Stop()
|
||||
}
|
||||
|
||||
// Parse check method
|
||||
methodStr := cfg.GetHealthCheckMethod()
|
||||
var method healthcheck.CheckMethod
|
||||
switch methodStr {
|
||||
case "tcp-dial":
|
||||
method = healthcheck.CheckMethodTCPDial
|
||||
case "data-transfer":
|
||||
method = healthcheck.CheckMethodDataTransfer
|
||||
default:
|
||||
method = healthcheck.CheckMethodDataTransfer
|
||||
}
|
||||
|
||||
// Create new health checker with config settings
|
||||
m.healthChecker = healthcheck.NewCheckerWithOptions(healthcheck.CheckerOptions{
|
||||
Interval: cfg.GetHealthCheckIntervalOrDefault(),
|
||||
Timeout: cfg.GetHealthCheckTimeoutOrDefault(),
|
||||
Method: method,
|
||||
MaxConnectionAge: cfg.GetMaxConnectionAge(),
|
||||
MaxIdleTime: cfg.GetMaxIdleTime(),
|
||||
})
|
||||
|
||||
// Configure TCP settings on port forwarder
|
||||
tcpKeepalive := cfg.GetTCPKeepalive()
|
||||
dialTimeout := cfg.GetDialTimeout()
|
||||
m.portForwarder.SetTCPKeepalive(tcpKeepalive)
|
||||
m.portForwarder.SetDialTimeout(dialTimeout)
|
||||
|
||||
logger.Info("Health checker and reliability configured", map[string]interface{}{
|
||||
"interval": cfg.GetHealthCheckIntervalOrDefault().String(),
|
||||
"timeout": cfg.GetHealthCheckTimeoutOrDefault().String(),
|
||||
"method": methodStr,
|
||||
"max_connection_age": cfg.GetMaxConnectionAge().String(),
|
||||
"max_idle_time": cfg.GetMaxIdleTime().String(),
|
||||
"tcp_keepalive": tcpKeepalive.String(),
|
||||
"dial_timeout": dialTimeout.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// SetStatusUI sets the status updater for the manager
|
||||
func (m *Manager) SetStatusUI(ui StatusUpdater) {
|
||||
m.statusUI = ui
|
||||
}
|
||||
|
||||
// SetMDNSPublisher sets the mDNS publisher for the manager
|
||||
func (m *Manager) SetMDNSPublisher(publisher *mdns.Publisher) {
|
||||
m.mdnsPublisher = publisher
|
||||
}
|
||||
|
||||
// Start initializes and starts all port-forwards from the configuration.
|
||||
func (m *Manager) Start(cfg *config.Config) error {
|
||||
if cfg == nil {
|
||||
@@ -76,6 +132,20 @@ func (m *Manager) Start(cfg *config.Config) error {
|
||||
|
||||
m.currentConfig = cfg
|
||||
|
||||
// Configure health checker with settings from config
|
||||
m.configureHealthChecker(cfg)
|
||||
|
||||
// Start watchdog
|
||||
watchdogPeriod := cfg.GetWatchdogPeriod()
|
||||
m.watchdog.checkInterval = watchdogPeriod
|
||||
m.watchdog.hangThreshold = watchdogPeriod * 2 // Hang threshold is 2x check interval
|
||||
m.watchdog.Start()
|
||||
|
||||
logger.Info("Watchdog started", map[string]interface{}{
|
||||
"check_interval": watchdogPeriod.String(),
|
||||
"hang_threshold": (watchdogPeriod * 2).String(),
|
||||
})
|
||||
|
||||
// Get all forwards from config
|
||||
forwards := cfg.GetAllForwards()
|
||||
|
||||
@@ -119,8 +189,14 @@ func (m *Manager) Start(cfg *config.Config) error {
|
||||
func (m *Manager) Stop() {
|
||||
log.Printf("Stopping all port-forwards...")
|
||||
|
||||
// Stop health checker first
|
||||
// Stop health checker and watchdog first
|
||||
m.healthChecker.Stop()
|
||||
m.watchdog.Stop()
|
||||
|
||||
// Stop mDNS publisher
|
||||
if m.mdnsPublisher != nil {
|
||||
m.mdnsPublisher.Stop()
|
||||
}
|
||||
|
||||
m.workersMu.Lock()
|
||||
workers := make([]*ForwardWorker, 0, len(m.workers))
|
||||
@@ -273,31 +349,86 @@ func (m *Manager) startWorker(fwd config.Forward) error {
|
||||
m.statusUI.AddForward(fwd.ID(), &fwd)
|
||||
}
|
||||
|
||||
// Register with watchdog
|
||||
m.watchdog.RegisterWorker(fwd.ID(), func(forwardID string) {
|
||||
logger.Warn("Watchdog triggered reconnection for hung worker", map[string]interface{}{
|
||||
"forward_id": forwardID,
|
||||
})
|
||||
|
||||
// Find and trigger reconnect on hung worker
|
||||
m.workersMu.RLock()
|
||||
worker, exists := m.workers[forwardID]
|
||||
m.workersMu.RUnlock()
|
||||
|
||||
if exists {
|
||||
worker.TriggerReconnect("watchdog detected hung worker")
|
||||
}
|
||||
})
|
||||
|
||||
// Register with health checker
|
||||
m.healthChecker.Register(fwd.ID(), fwd.LocalPort, func(forwardID string, status healthcheck.Status, errorMsg string) {
|
||||
if m.statusUI != nil {
|
||||
m.statusUI.UpdateStatus(forwardID, string(status))
|
||||
|
||||
// Send error separately if there is one
|
||||
if status == healthcheck.StatusUnhealthy && errorMsg != "" {
|
||||
if (status == healthcheck.StatusUnhealthy || status == healthcheck.StatusStale) && errorMsg != "" {
|
||||
if ui, ok := m.statusUI.(interface{ SetError(id, msg string) }); ok {
|
||||
ui.SetError(forwardID, errorMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle stale connections: trigger reconnection if retryOnStale is enabled
|
||||
if status == healthcheck.StatusStale && m.currentConfig.GetRetryOnStale() {
|
||||
logger.Info("Stale connection detected, triggering reconnection", map[string]interface{}{
|
||||
"forward_id": forwardID,
|
||||
"reason": errorMsg,
|
||||
})
|
||||
|
||||
// Find and notify the worker to reconnect
|
||||
m.workersMu.RLock()
|
||||
worker, exists := m.workers[forwardID]
|
||||
m.workersMu.RUnlock()
|
||||
|
||||
if exists {
|
||||
worker.TriggerReconnect("stale connection")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Create and start worker
|
||||
worker := NewForwardWorker(fwd, m.portForwarder, m.verbose, m.statusUI, m.healthChecker)
|
||||
worker := NewForwardWorker(fwd, m.portForwarder, m.verbose, m.statusUI, m.healthChecker, m.watchdog)
|
||||
worker.Start()
|
||||
|
||||
// Store worker
|
||||
m.workers[fwd.ID()] = worker
|
||||
|
||||
// Register mDNS hostname if enabled
|
||||
// Uses explicit alias if set, otherwise generates from resource name
|
||||
if m.mdnsPublisher != nil {
|
||||
mdnsAlias := fwd.GetMDNSAlias()
|
||||
if mdnsAlias != "" {
|
||||
if err := m.mdnsPublisher.Register(fwd.ID(), mdnsAlias, fwd.LocalPort); err != nil {
|
||||
logger.Warn("Failed to register mDNS hostname", map[string]interface{}{
|
||||
"forward_id": fwd.ID(),
|
||||
"alias": mdnsAlias,
|
||||
"error": err.Error(),
|
||||
})
|
||||
// Don't fail the forward start - mDNS is optional
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// stopWorker stops and removes a forward worker.
|
||||
func (m *Manager) stopWorker(id string) error {
|
||||
return m.stopWorkerInternal(id, true)
|
||||
}
|
||||
|
||||
// stopWorkerInternal stops a worker with option to remove from UI or just update status
|
||||
func (m *Manager) stopWorkerInternal(id string, removeFromUI bool) error {
|
||||
m.workersMu.Lock()
|
||||
worker, exists := m.workers[id]
|
||||
if !exists {
|
||||
@@ -307,12 +438,22 @@ func (m *Manager) stopWorker(id string) error {
|
||||
delete(m.workers, id)
|
||||
m.workersMu.Unlock()
|
||||
|
||||
// Unregister from health checker
|
||||
// Unregister from health checker and watchdog
|
||||
m.healthChecker.Unregister(id)
|
||||
m.watchdog.UnregisterWorker(id)
|
||||
|
||||
// Notify UI to remove the forward
|
||||
// Unregister mDNS hostname
|
||||
if m.mdnsPublisher != nil {
|
||||
m.mdnsPublisher.Unregister(id)
|
||||
}
|
||||
|
||||
// Notify UI - either remove or update to disabled status
|
||||
if m.statusUI != nil {
|
||||
m.statusUI.Remove(id)
|
||||
if removeFromUI {
|
||||
m.statusUI.Remove(id)
|
||||
} else {
|
||||
m.statusUI.UpdateStatus(id, "Disabled")
|
||||
}
|
||||
}
|
||||
|
||||
// Stop the worker
|
||||
@@ -363,7 +504,7 @@ func (m *Manager) getResourceForPort(forwards []config.Forward, port int) string
|
||||
|
||||
// DisableForward temporarily stops a forward by ID
|
||||
func (m *Manager) DisableForward(id string) error {
|
||||
if err := m.stopWorker(id); err != nil {
|
||||
if err := m.stopWorkerInternal(id, false); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("Disabled: %s", id)
|
||||
|
||||
+144
-41
@@ -6,11 +6,20 @@ import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/nvm/kportal/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxPIDLength is the maximum length of a valid PID string (9 digits covers PIDs up to 999,999,999)
|
||||
maxPIDLength = 9
|
||||
// minNetstatFields is the minimum number of fields expected in netstat output
|
||||
minNetstatFields = 5
|
||||
)
|
||||
|
||||
// isValidPID validates that a PID string contains only digits
|
||||
func isValidPID(pid string) bool {
|
||||
if len(pid) == 0 || len(pid) > 9 {
|
||||
if len(pid) == 0 || len(pid) > maxPIDLength {
|
||||
return false
|
||||
}
|
||||
for _, c := range pid {
|
||||
@@ -21,6 +30,72 @@ func isValidPID(pid string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// processInfo holds information about a process using a port
|
||||
type processInfo struct {
|
||||
pid string
|
||||
name string
|
||||
isValid bool
|
||||
}
|
||||
|
||||
// formatProcessInfo formats process information for display
|
||||
func formatProcessInfo(info processInfo) string {
|
||||
if !info.isValid {
|
||||
return "unknown"
|
||||
}
|
||||
if info.name != "" {
|
||||
return fmt.Sprintf("%s (PID %s)", info.name, info.pid)
|
||||
}
|
||||
return fmt.Sprintf("PID %s", info.pid)
|
||||
}
|
||||
|
||||
// formatProcessList formats a list of processes into a human-readable string.
|
||||
// Returns "unknown" if the list is empty.
|
||||
func formatProcessList(processes []processInfo) string {
|
||||
if len(processes) == 0 {
|
||||
return "unknown"
|
||||
}
|
||||
if len(processes) == 1 {
|
||||
return formatProcessInfo(processes[0])
|
||||
}
|
||||
// Multiple processes - format as comma-separated list
|
||||
parts := make([]string, len(processes))
|
||||
for i, p := range processes {
|
||||
parts[i] = formatProcessInfo(p)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// getProcessNameByPID retrieves the process name for a given PID on Unix systems
|
||||
func getProcessNameByPID(pid string) string {
|
||||
cmd := exec.Command("ps", "-p", pid, "-o", "comm=")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
|
||||
// getProcessNameByPIDWindows retrieves the process name for a given PID on Windows
|
||||
func getProcessNameByPIDWindows(pid string) string {
|
||||
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %s", pid), "/FO", "CSV", "/NH")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Parse CSV output: "process.exe","1234","Console","1","12,345 K"
|
||||
csvLine := strings.TrimSpace(string(output))
|
||||
if csvLine == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := strings.Split(csvLine, ",")
|
||||
if len(parts) > 0 {
|
||||
return strings.Trim(parts[0], "\"")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// PortConflict represents a local port that is already in use.
|
||||
type PortConflict struct {
|
||||
Port int // The conflicting port number
|
||||
@@ -102,27 +177,55 @@ func (pc *PortChecker) getProcessUsingPortUnix(port int) string {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// Get the first PID if multiple are returned
|
||||
// Handle multiple PIDs (multiple processes on same port)
|
||||
pids := strings.Split(pidStr, "\n")
|
||||
pid := pids[0]
|
||||
var validProcesses []processInfo
|
||||
|
||||
if !isValidPID(pid) {
|
||||
return "unknown"
|
||||
for _, pid := range pids {
|
||||
pid = strings.TrimSpace(pid)
|
||||
if pid == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if !isValidPID(pid) {
|
||||
logger.Debug("Invalid PID format from lsof output", map[string]interface{}{
|
||||
"port": port,
|
||||
"raw_pid": pid,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
procName := getProcessNameByPID(pid)
|
||||
validProcesses = append(validProcesses, processInfo{
|
||||
pid: pid,
|
||||
name: procName,
|
||||
isValid: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
return formatProcessList(validProcesses)
|
||||
}
|
||||
|
||||
// isListeningState checks if a netstat line indicates a listening state.
|
||||
// This handles both English and potentially other locales by checking for common patterns.
|
||||
func isListeningState(line string, fields []string) bool {
|
||||
upperLine := strings.ToUpper(line)
|
||||
|
||||
// Check for common listening state indicators across locales
|
||||
// English: LISTENING, German: ABHÖREN, French: ÉCOUTE, etc.
|
||||
// The most reliable check is the state field position (4th field, 0-indexed = 3)
|
||||
// and that it's a TCP connection with 0.0.0.0:0 or *:* as foreign address
|
||||
if len(fields) >= minNetstatFields {
|
||||
state := strings.ToUpper(fields[3])
|
||||
// Common listening state values across Windows locales
|
||||
if state == "LISTENING" || state == "ABHÖREN" || state == "ÉCOUTE" ||
|
||||
state == "ESCUCHANDO" || state == "ASCOLTO" || state == "NASŁUCHIWANIE" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
procName := strings.TrimSpace(string(output))
|
||||
if procName == "" {
|
||||
return fmt.Sprintf("PID %s", pid)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s (PID %s)", procName, pid)
|
||||
// Fallback: check if line contains LISTENING (most common case)
|
||||
return strings.Contains(upperLine, "LISTENING")
|
||||
}
|
||||
|
||||
// getProcessUsingPortWindows uses netstat to find the process using a port on Windows.
|
||||
@@ -138,6 +241,8 @@ func (pc *PortChecker) getProcessUsingPortWindows(port int) string {
|
||||
lines := strings.Split(string(output), "\n")
|
||||
portStr := fmt.Sprintf(":%d", port)
|
||||
|
||||
var validProcesses []processInfo
|
||||
|
||||
for _, line := range lines {
|
||||
if !strings.Contains(line, portStr) {
|
||||
continue
|
||||
@@ -146,44 +251,42 @@ func (pc *PortChecker) getProcessUsingPortWindows(port int) string {
|
||||
// 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 {
|
||||
if len(fields) < minNetstatFields {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is a LISTENING state
|
||||
if !strings.Contains(strings.ToUpper(line), "LISTENING") {
|
||||
// Check if this is a LISTENING state (locale-aware)
|
||||
if !isListeningState(line, fields) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Verify the local address field actually contains our port
|
||||
// (avoid matching port in foreign address)
|
||||
localAddr := fields[1]
|
||||
if !strings.HasSuffix(localAddr, portStr) {
|
||||
continue
|
||||
}
|
||||
|
||||
pid := fields[len(fields)-1]
|
||||
|
||||
if !isValidPID(pid) {
|
||||
return "unknown"
|
||||
logger.Debug("Invalid PID format from netstat output", map[string]interface{}{
|
||||
"port": port,
|
||||
"raw_pid": pid,
|
||||
"line": line,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 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)
|
||||
procName := getProcessNameByPIDWindows(pid)
|
||||
validProcesses = append(validProcesses, processInfo{
|
||||
pid: pid,
|
||||
name: procName,
|
||||
isValid: true,
|
||||
})
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
return formatProcessList(validProcesses)
|
||||
}
|
||||
|
||||
// FormatConflicts formats port conflicts into a human-readable error message.
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nvm/kportal/internal/logger"
|
||||
)
|
||||
|
||||
// Watchdog monitors worker goroutines to detect hung workers
|
||||
type Watchdog struct {
|
||||
mu sync.RWMutex
|
||||
workers map[string]*workerState // key: forward ID
|
||||
checkInterval time.Duration
|
||||
hangThreshold time.Duration // How long without heartbeat before considered hung
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// workerState tracks the health of a single worker
|
||||
type workerState struct {
|
||||
forwardID string
|
||||
lastHeartbeat time.Time
|
||||
heartbeatCount uint64
|
||||
isHung bool
|
||||
onHungCallback func(forwardID string)
|
||||
}
|
||||
|
||||
// NewWatchdog creates a new goroutine watchdog
|
||||
func NewWatchdog(checkInterval, hangThreshold time.Duration) *Watchdog {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Watchdog{
|
||||
workers: make(map[string]*workerState),
|
||||
checkInterval: checkInterval,
|
||||
hangThreshold: hangThreshold,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the watchdog monitoring loop
|
||||
func (w *Watchdog) Start() {
|
||||
w.wg.Add(1)
|
||||
go w.monitorLoop()
|
||||
}
|
||||
|
||||
// Stop stops the watchdog
|
||||
func (w *Watchdog) Stop() {
|
||||
w.cancel()
|
||||
w.wg.Wait()
|
||||
}
|
||||
|
||||
// RegisterWorker adds a worker to monitor
|
||||
func (w *Watchdog) RegisterWorker(forwardID string, onHungCallback func(string)) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
w.workers[forwardID] = &workerState{
|
||||
forwardID: forwardID,
|
||||
lastHeartbeat: time.Now(),
|
||||
heartbeatCount: 0,
|
||||
isHung: false,
|
||||
onHungCallback: onHungCallback,
|
||||
}
|
||||
|
||||
logger.Debug("Watchdog registered worker", map[string]interface{}{
|
||||
"forward_id": forwardID,
|
||||
})
|
||||
}
|
||||
|
||||
// UnregisterWorker removes a worker from monitoring
|
||||
func (w *Watchdog) UnregisterWorker(forwardID string) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
delete(w.workers, forwardID)
|
||||
|
||||
logger.Debug("Watchdog unregistered worker", map[string]interface{}{
|
||||
"forward_id": forwardID,
|
||||
})
|
||||
}
|
||||
|
||||
// Heartbeat records that a worker is alive and processing
|
||||
// Workers should call this periodically (e.g., in their main loop)
|
||||
func (w *Watchdog) Heartbeat(forwardID string) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if state, exists := w.workers[forwardID]; exists {
|
||||
state.lastHeartbeat = time.Now()
|
||||
state.heartbeatCount++
|
||||
state.isHung = false
|
||||
}
|
||||
}
|
||||
|
||||
// GetWorkerState returns the current state of a worker (for testing)
|
||||
func (w *Watchdog) GetWorkerState(forwardID string) (lastHeartbeat time.Time, count uint64, exists bool) {
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
|
||||
if state, ok := w.workers[forwardID]; ok {
|
||||
return state.lastHeartbeat, state.heartbeatCount, true
|
||||
}
|
||||
return time.Time{}, 0, false
|
||||
}
|
||||
|
||||
// monitorLoop periodically checks all workers
|
||||
func (w *Watchdog) monitorLoop() {
|
||||
defer w.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(w.checkInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.checkWorkers()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hungWorkerInfo stores information about a hung worker for deferred callback execution
|
||||
type hungWorkerInfo struct {
|
||||
forwardID string
|
||||
callback func(string)
|
||||
}
|
||||
|
||||
// checkWorkers checks all registered workers for hung state
|
||||
func (w *Watchdog) checkWorkers() {
|
||||
// Collect hung workers while holding the lock
|
||||
var hungWorkers []hungWorkerInfo
|
||||
|
||||
w.mu.Lock()
|
||||
now := time.Now()
|
||||
for forwardID, state := range w.workers {
|
||||
timeSinceHeartbeat := now.Sub(state.lastHeartbeat)
|
||||
|
||||
// Check if worker is hung
|
||||
if timeSinceHeartbeat > w.hangThreshold {
|
||||
if !state.isHung {
|
||||
// First time detecting hung state
|
||||
state.isHung = true
|
||||
|
||||
logger.Warn("Watchdog detected hung worker", map[string]interface{}{
|
||||
"forward_id": forwardID,
|
||||
"time_since_heartbeat": timeSinceHeartbeat.String(),
|
||||
"hang_threshold": w.hangThreshold.String(),
|
||||
"heartbeat_count": state.heartbeatCount,
|
||||
})
|
||||
|
||||
// Collect callback for deferred execution outside the lock
|
||||
if state.onHungCallback != nil {
|
||||
hungWorkers = append(hungWorkers, hungWorkerInfo{
|
||||
forwardID: forwardID,
|
||||
callback: state.onHungCallback,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
w.mu.Unlock()
|
||||
|
||||
// Execute callbacks outside the lock to prevent deadlocks and ensure
|
||||
// consistent state during callback execution. Callbacks are idempotent
|
||||
// (they trigger reconnection via channels), so concurrent state changes
|
||||
// between detection and callback execution are safe.
|
||||
for _, hw := range hungWorkers {
|
||||
hw.callback(hw.forwardID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package forward
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
// WatchdogTestSuite contains tests for the watchdog
|
||||
type WatchdogTestSuite struct {
|
||||
suite.Suite
|
||||
watchdog *Watchdog
|
||||
}
|
||||
|
||||
func TestWatchdogSuite(t *testing.T) {
|
||||
suite.Run(t, new(WatchdogTestSuite))
|
||||
}
|
||||
|
||||
func (s *WatchdogTestSuite) SetupTest() {
|
||||
// Create watchdog with fast intervals for testing
|
||||
s.watchdog = NewWatchdog(100*time.Millisecond, 300*time.Millisecond)
|
||||
s.watchdog.Start()
|
||||
}
|
||||
|
||||
func (s *WatchdogTestSuite) TearDownTest() {
|
||||
if s.watchdog != nil {
|
||||
s.watchdog.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterUnregister tests basic registration and unregistration
|
||||
func (s *WatchdogTestSuite) TestRegisterUnregister() {
|
||||
callbackCalled := false
|
||||
callback := func(forwardID string) {
|
||||
callbackCalled = true
|
||||
}
|
||||
|
||||
// Register worker
|
||||
s.watchdog.RegisterWorker("test-forward", callback)
|
||||
|
||||
// Verify worker is registered
|
||||
_, _, exists := s.watchdog.GetWorkerState("test-forward")
|
||||
assert.True(s.T(), exists, "Worker should be registered")
|
||||
|
||||
// Unregister worker
|
||||
s.watchdog.UnregisterWorker("test-forward")
|
||||
|
||||
// Verify worker is unregistered
|
||||
_, _, exists = s.watchdog.GetWorkerState("test-forward")
|
||||
assert.False(s.T(), exists, "Worker should be unregistered")
|
||||
assert.False(s.T(), callbackCalled, "Callback should not have been called")
|
||||
}
|
||||
|
||||
// TestHeartbeat tests that heartbeats update worker state
|
||||
func (s *WatchdogTestSuite) TestHeartbeat() {
|
||||
s.watchdog.RegisterWorker("test-forward", nil)
|
||||
|
||||
// Send initial heartbeat
|
||||
s.watchdog.Heartbeat("test-forward")
|
||||
|
||||
lastHeartbeat1, count1, exists := s.watchdog.GetWorkerState("test-forward")
|
||||
require.True(s.T(), exists)
|
||||
assert.Equal(s.T(), uint64(1), count1)
|
||||
|
||||
// Wait a bit
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Send another heartbeat
|
||||
s.watchdog.Heartbeat("test-forward")
|
||||
|
||||
lastHeartbeat2, count2, exists := s.watchdog.GetWorkerState("test-forward")
|
||||
require.True(s.T(), exists)
|
||||
assert.Equal(s.T(), uint64(2), count2)
|
||||
assert.True(s.T(), lastHeartbeat2.After(lastHeartbeat1), "Second heartbeat should be after first")
|
||||
}
|
||||
|
||||
// TestHungWorkerDetection tests that hung workers are detected
|
||||
func (s *WatchdogTestSuite) TestHungWorkerDetection() {
|
||||
callbackCalled := make(chan string, 1)
|
||||
callback := func(forwardID string) {
|
||||
callbackCalled <- forwardID
|
||||
}
|
||||
|
||||
s.watchdog.RegisterWorker("test-forward", callback)
|
||||
|
||||
// Send initial heartbeat
|
||||
s.watchdog.Heartbeat("test-forward")
|
||||
|
||||
// Wait for worker to be considered hung (300ms threshold + 100ms check interval)
|
||||
timeout := time.After(1 * time.Second)
|
||||
|
||||
select {
|
||||
case forwardID := <-callbackCalled:
|
||||
assert.Equal(s.T(), "test-forward", forwardID)
|
||||
case <-timeout:
|
||||
s.T().Fatal("Timeout waiting for hung worker callback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHealthyWorkerNotDetectedAsHung tests that workers sending heartbeats are not considered hung
|
||||
func (s *WatchdogTestSuite) TestHealthyWorkerNotDetectedAsHung() {
|
||||
callbackCalled := false
|
||||
var mu sync.Mutex
|
||||
callback := func(forwardID string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
callbackCalled = true
|
||||
}
|
||||
|
||||
s.watchdog.RegisterWorker("test-forward", callback)
|
||||
|
||||
// Send periodic heartbeats (faster than hang threshold)
|
||||
ticker := time.NewTicker(50 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
for i := 0; i < 10; i++ {
|
||||
<-ticker.C
|
||||
s.watchdog.Heartbeat("test-forward")
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Wait for all heartbeats to complete
|
||||
<-done
|
||||
|
||||
// Check that callback was not called
|
||||
mu.Lock()
|
||||
assert.False(s.T(), callbackCalled, "Callback should not be called for healthy worker")
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// TestMultipleWorkers tests monitoring multiple workers simultaneously
|
||||
func (s *WatchdogTestSuite) TestMultipleWorkers() {
|
||||
callbacks := make(map[string]int)
|
||||
var mu sync.Mutex
|
||||
|
||||
makeCallback := func(id string) func(string) {
|
||||
return func(forwardID string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
callbacks[id]++
|
||||
}
|
||||
}
|
||||
|
||||
// Register multiple workers
|
||||
s.watchdog.RegisterWorker("worker-1", makeCallback("worker-1"))
|
||||
s.watchdog.RegisterWorker("worker-2", makeCallback("worker-2"))
|
||||
s.watchdog.RegisterWorker("worker-3", makeCallback("worker-3"))
|
||||
|
||||
// worker-1: Keep sending heartbeats (healthy)
|
||||
// Use a done channel to ensure goroutine exits before test ends
|
||||
ticker1 := time.NewTicker(50 * time.Millisecond)
|
||||
done := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer ticker1.Stop()
|
||||
for i := 0; i < 10; i++ {
|
||||
select {
|
||||
case <-ticker1.C:
|
||||
s.watchdog.Heartbeat("worker-1")
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// worker-2: Send initial heartbeat then stop (will become hung)
|
||||
s.watchdog.Heartbeat("worker-2")
|
||||
|
||||
// worker-3: Send initial heartbeat then stop (will become hung)
|
||||
s.watchdog.Heartbeat("worker-3")
|
||||
|
||||
// Wait for hung workers to be detected
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
|
||||
// Signal goroutine to stop and wait for it
|
||||
close(done)
|
||||
wg.Wait()
|
||||
|
||||
// Check results
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
assert.Equal(s.T(), 0, callbacks["worker-1"], "worker-1 should not trigger callback (healthy)")
|
||||
assert.Greater(s.T(), callbacks["worker-2"], 0, "worker-2 should trigger callback (hung)")
|
||||
assert.Greater(s.T(), callbacks["worker-3"], 0, "worker-3 should trigger callback (hung)")
|
||||
}
|
||||
|
||||
// TestCallbackOnlyOnFirstDetection tests that callback is only called once when hung is first detected
|
||||
func (s *WatchdogTestSuite) TestCallbackOnlyOnFirstDetection() {
|
||||
callbackCount := 0
|
||||
var mu sync.Mutex
|
||||
callback := func(forwardID string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
callbackCount++
|
||||
}
|
||||
|
||||
s.watchdog.RegisterWorker("test-forward", callback)
|
||||
|
||||
// Send initial heartbeat
|
||||
s.watchdog.Heartbeat("test-forward")
|
||||
|
||||
// Wait for multiple check cycles
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// Check that callback was only called once
|
||||
mu.Lock()
|
||||
assert.Equal(s.T(), 1, callbackCount, "Callback should only be called once")
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// TestHeartbeatResetsHungState tests that sending heartbeat after hung detection resets state
|
||||
func (s *WatchdogTestSuite) TestHeartbeatResetsHungState() {
|
||||
callbackCount := 0
|
||||
var mu sync.Mutex
|
||||
callback := func(forwardID string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
callbackCount++
|
||||
}
|
||||
|
||||
s.watchdog.RegisterWorker("test-forward", callback)
|
||||
|
||||
// Send initial heartbeat
|
||||
s.watchdog.Heartbeat("test-forward")
|
||||
|
||||
// Wait for hung detection
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
firstCount := callbackCount
|
||||
mu.Unlock()
|
||||
|
||||
assert.Equal(s.T(), 1, firstCount, "First hung detection should trigger callback")
|
||||
|
||||
// Send heartbeat to reset hung state
|
||||
s.watchdog.Heartbeat("test-forward")
|
||||
|
||||
// Wait for worker to become hung again
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
secondCount := callbackCount
|
||||
mu.Unlock()
|
||||
|
||||
assert.Equal(s.T(), 2, secondCount, "Second hung detection should trigger callback again")
|
||||
}
|
||||
|
||||
// TestConcurrentOperations tests thread safety
|
||||
func (s *WatchdogTestSuite) TestConcurrentOperations() {
|
||||
var wg sync.WaitGroup
|
||||
numWorkers := 10
|
||||
|
||||
for i := 0; i < numWorkers; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
forwardID := string(rune('a' + id))
|
||||
s.watchdog.RegisterWorker(forwardID, nil)
|
||||
for j := 0; j < 10; j++ {
|
||||
s.watchdog.Heartbeat(forwardID)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
s.watchdog.UnregisterWorker(forwardID)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
// If we get here without deadlocks or panics, test passes
|
||||
}
|
||||
|
||||
// TestStopWatchdog tests that stopping watchdog cleans up properly
|
||||
func TestStopWatchdog(t *testing.T) {
|
||||
watchdog := NewWatchdog(100*time.Millisecond, 300*time.Millisecond)
|
||||
watchdog.Start()
|
||||
|
||||
callbackCalled := false
|
||||
callback := func(forwardID string) {
|
||||
callbackCalled = true
|
||||
}
|
||||
|
||||
watchdog.RegisterWorker("test-forward", callback)
|
||||
watchdog.Heartbeat("test-forward")
|
||||
|
||||
// Stop watchdog before hang detection
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
watchdog.Stop()
|
||||
|
||||
// Wait to ensure no more callbacks after stop
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
assert.False(t, callbackCalled, "Callback should not be called after watchdog is stopped")
|
||||
}
|
||||
|
||||
// TestWatchdogWithZeroHeartbeats tests detecting hung worker that never sends heartbeats
|
||||
func (s *WatchdogTestSuite) TestWatchdogWithZeroHeartbeats() {
|
||||
callbackCalled := make(chan string, 1)
|
||||
callback := func(forwardID string) {
|
||||
callbackCalled <- forwardID
|
||||
}
|
||||
|
||||
// Register worker but never send heartbeat
|
||||
s.watchdog.RegisterWorker("test-forward", callback)
|
||||
|
||||
// Wait for hung detection
|
||||
timeout := time.After(1 * time.Second)
|
||||
|
||||
select {
|
||||
case forwardID := <-callbackCalled:
|
||||
assert.Equal(s.T(), "test-forward", forwardID)
|
||||
case <-timeout:
|
||||
s.T().Fatal("Timeout waiting for hung worker callback")
|
||||
}
|
||||
}
|
||||
+90
-14
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
@@ -20,21 +21,25 @@ const (
|
||||
|
||||
// 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
|
||||
statusUI StatusUpdater
|
||||
healthChecker *healthcheck.Checker
|
||||
startTime time.Time // Track when the worker started
|
||||
forward config.Forward
|
||||
portForwarder *k8s.PortForwarder
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
stopChan chan struct{}
|
||||
doneChan chan struct{}
|
||||
reconnectChan chan string // Channel to trigger reconnection
|
||||
verbose bool
|
||||
lastPod string // Track the last pod we connected to
|
||||
statusUI StatusUpdater
|
||||
healthChecker *healthcheck.Checker
|
||||
watchdog *Watchdog
|
||||
startTime time.Time // Track when the worker started
|
||||
forwardCancel context.CancelFunc // Cancel function for current forward attempt
|
||||
forwardCancelMu sync.Mutex // Protects forwardCancel
|
||||
}
|
||||
|
||||
// NewForwardWorker creates a new ForwardWorker for a single forward configuration.
|
||||
func NewForwardWorker(fwd config.Forward, portForwarder *k8s.PortForwarder, verbose bool, statusUI StatusUpdater, healthChecker *healthcheck.Checker) *ForwardWorker {
|
||||
func NewForwardWorker(fwd config.Forward, portForwarder *k8s.PortForwarder, verbose bool, statusUI StatusUpdater, healthChecker *healthcheck.Checker, watchdog *Watchdog) *ForwardWorker {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &ForwardWorker{
|
||||
@@ -44,13 +49,32 @@ func NewForwardWorker(fwd config.Forward, portForwarder *k8s.PortForwarder, verb
|
||||
cancel: cancel,
|
||||
stopChan: make(chan struct{}),
|
||||
doneChan: make(chan struct{}),
|
||||
reconnectChan: make(chan string, 1), // Buffered to avoid blocking
|
||||
verbose: verbose,
|
||||
statusUI: statusUI,
|
||||
healthChecker: healthChecker,
|
||||
watchdog: watchdog,
|
||||
startTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// TriggerReconnect triggers a reconnection (e.g., due to stale connection)
|
||||
func (w *ForwardWorker) TriggerReconnect(reason string) {
|
||||
// Cancel current forward if running
|
||||
w.forwardCancelMu.Lock()
|
||||
if w.forwardCancel != nil {
|
||||
w.forwardCancel()
|
||||
}
|
||||
w.forwardCancelMu.Unlock()
|
||||
|
||||
// Send reconnect signal (non-blocking)
|
||||
select {
|
||||
case w.reconnectChan <- reason:
|
||||
default:
|
||||
// Channel already has pending reconnect
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the port-forward worker in a goroutine.
|
||||
// The worker will continuously retry on failures with exponential backoff.
|
||||
func (w *ForwardWorker) Start() {
|
||||
@@ -61,13 +85,28 @@ func (w *ForwardWorker) Start() {
|
||||
func (w *ForwardWorker) Stop() {
|
||||
w.cancel()
|
||||
close(w.stopChan)
|
||||
<-w.doneChan // Wait for worker to finish
|
||||
|
||||
// Wait for worker to finish with timeout to prevent blocking forever
|
||||
select {
|
||||
case <-w.doneChan:
|
||||
// Worker finished gracefully
|
||||
case <-time.After(3 * time.Second):
|
||||
// Worker didn't finish in time, but we've cancelled its context
|
||||
// so it will clean up eventually
|
||||
log.Printf("[%s] Worker stop timed out, continuing...", w.forward.ID())
|
||||
}
|
||||
}
|
||||
|
||||
// run is the main worker loop that handles retries.
|
||||
func (w *ForwardWorker) run() {
|
||||
defer close(w.doneChan)
|
||||
|
||||
// Start heartbeat goroutine to continuously send heartbeats to watchdog
|
||||
// This prevents false "hung worker" detection when connections are long-lived
|
||||
if w.watchdog != nil {
|
||||
go w.heartbeatLoop()
|
||||
}
|
||||
|
||||
backoff := retry.NewBackoff()
|
||||
|
||||
for {
|
||||
@@ -173,6 +212,26 @@ func (w *ForwardWorker) run() {
|
||||
}
|
||||
}
|
||||
|
||||
// heartbeatLoop sends periodic heartbeats to the watchdog to prove the worker is alive
|
||||
// This runs in a separate goroutine and continues throughout the worker's lifetime
|
||||
func (w *ForwardWorker) heartbeatLoop() {
|
||||
// Send heartbeats every 15 seconds (well within typical 60s watchdog timeout)
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Send immediate heartbeat
|
||||
w.watchdog.Heartbeat(w.forward.ID())
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
w.watchdog.Heartbeat(w.forward.ID())
|
||||
case <-w.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// establishForward establishes a port-forward connection.
|
||||
// This blocks until the connection is closed or an error occurs.
|
||||
func (w *ForwardWorker) establishForward(podName string) error {
|
||||
@@ -184,11 +243,24 @@ func (w *ForwardWorker) establishForward(podName string) error {
|
||||
forwardCtx, forwardCancel := context.WithCancel(w.ctx)
|
||||
defer forwardCancel()
|
||||
|
||||
// Start a goroutine to monitor for stop signal
|
||||
// Store cancel function so TriggerReconnect can use it
|
||||
w.forwardCancelMu.Lock()
|
||||
w.forwardCancel = forwardCancel
|
||||
w.forwardCancelMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
w.forwardCancelMu.Lock()
|
||||
w.forwardCancel = nil
|
||||
w.forwardCancelMu.Unlock()
|
||||
}()
|
||||
|
||||
// Start a goroutine to monitor for stop signal and reconnect triggers
|
||||
go func() {
|
||||
select {
|
||||
case <-w.stopChan:
|
||||
close(stopChan)
|
||||
case <-w.reconnectChan:
|
||||
close(stopChan)
|
||||
case <-forwardCtx.Done():
|
||||
close(stopChan)
|
||||
}
|
||||
@@ -230,6 +302,10 @@ func (w *ForwardWorker) establishForward(podName string) error {
|
||||
if w.verbose {
|
||||
log.Printf("[%s] Port-forward connection established", w.forward.ID())
|
||||
}
|
||||
// Mark connection as established in health checker
|
||||
if w.healthChecker != nil {
|
||||
w.healthChecker.MarkConnected(w.forward.ID())
|
||||
}
|
||||
case err := <-errChan:
|
||||
return fmt.Errorf("failed to establish forward: %w", err)
|
||||
case <-w.ctx.Done():
|
||||
|
||||
+219
-70
@@ -3,13 +3,17 @@ package healthcheck
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
startupGracePeriod = 10 * time.Second
|
||||
dataTransferSize = 1024 // bytes to read in data transfer test
|
||||
)
|
||||
|
||||
// Status represents the health status of a port forward
|
||||
@@ -20,15 +24,26 @@ const (
|
||||
StatusUnhealthy Status = "Error"
|
||||
StatusStarting Status = "Starting"
|
||||
StatusReconnect Status = "Reconnecting"
|
||||
StatusStale Status = "Stale" // Connection is old or idle
|
||||
)
|
||||
|
||||
// CheckMethod represents the health check method
|
||||
type CheckMethod string
|
||||
|
||||
const (
|
||||
CheckMethodTCPDial CheckMethod = "tcp-dial" // Simple TCP connection test
|
||||
CheckMethodDataTransfer CheckMethod = "data-transfer" // Try to read data from connection
|
||||
)
|
||||
|
||||
// PortHealth represents the health status of a single port
|
||||
type PortHealth struct {
|
||||
Port int
|
||||
LastCheck time.Time
|
||||
Status Status
|
||||
ErrorMessage string
|
||||
RegisteredAt time.Time // When this port was registered
|
||||
Port int
|
||||
LastCheck time.Time
|
||||
Status Status
|
||||
ErrorMessage string
|
||||
RegisteredAt time.Time // When this port was registered
|
||||
ConnectionTime time.Time // When current connection was established
|
||||
LastActivity time.Time // Last time data was transferred
|
||||
}
|
||||
|
||||
// StatusCallback is called when a port's health status changes
|
||||
@@ -36,26 +51,52 @@ type StatusCallback func(forwardID string, status Status, errorMsg string)
|
||||
|
||||
// Checker performs periodic health checks on local ports
|
||||
type Checker struct {
|
||||
mu sync.RWMutex
|
||||
ports map[string]*PortHealth // key: forward ID
|
||||
callbacks map[string]StatusCallback
|
||||
interval time.Duration
|
||||
timeout time.Duration
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
mu sync.RWMutex
|
||||
ports map[string]*PortHealth // key: forward ID
|
||||
callbacks map[string]StatusCallback
|
||||
interval time.Duration
|
||||
timeout time.Duration
|
||||
method CheckMethod
|
||||
maxConnectionAge time.Duration
|
||||
maxIdleTime time.Duration
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewChecker creates a new health checker
|
||||
// CheckerOptions configures the health checker
|
||||
type CheckerOptions struct {
|
||||
Interval time.Duration
|
||||
Timeout time.Duration
|
||||
Method CheckMethod
|
||||
MaxConnectionAge time.Duration
|
||||
MaxIdleTime time.Duration
|
||||
}
|
||||
|
||||
// NewChecker creates a new health checker with default options
|
||||
func NewChecker(interval, timeout time.Duration) *Checker {
|
||||
return NewCheckerWithOptions(CheckerOptions{
|
||||
Interval: interval,
|
||||
Timeout: timeout,
|
||||
Method: CheckMethodDataTransfer,
|
||||
MaxConnectionAge: config.DefaultMaxConnectionAge,
|
||||
MaxIdleTime: config.DefaultMaxIdleTime,
|
||||
})
|
||||
}
|
||||
|
||||
// NewCheckerWithOptions creates a new health checker with custom options
|
||||
func NewCheckerWithOptions(opts CheckerOptions) *Checker {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Checker{
|
||||
ports: make(map[string]*PortHealth),
|
||||
callbacks: make(map[string]StatusCallback),
|
||||
interval: interval,
|
||||
timeout: timeout,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
ports: make(map[string]*PortHealth),
|
||||
callbacks: make(map[string]StatusCallback),
|
||||
interval: opts.Interval,
|
||||
timeout: opts.Timeout,
|
||||
method: opts.Method,
|
||||
maxConnectionAge: opts.MaxConnectionAge,
|
||||
maxIdleTime: opts.MaxIdleTime,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,11 +105,14 @@ func (c *Checker) Register(forwardID string, port int, callback StatusCallback)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
c.ports[forwardID] = &PortHealth{
|
||||
Port: port,
|
||||
LastCheck: time.Time{},
|
||||
Status: StatusStarting,
|
||||
RegisteredAt: time.Now(),
|
||||
Port: port,
|
||||
LastCheck: time.Time{},
|
||||
Status: StatusStarting,
|
||||
RegisteredAt: now,
|
||||
ConnectionTime: now,
|
||||
LastActivity: now,
|
||||
}
|
||||
c.callbacks[forwardID] = callback
|
||||
|
||||
@@ -77,6 +121,28 @@ func (c *Checker) Register(forwardID string, port int, callback StatusCallback)
|
||||
go c.checkLoop(forwardID)
|
||||
}
|
||||
|
||||
// MarkConnected marks a forward as having established a new connection
|
||||
func (c *Checker) MarkConnected(forwardID string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if health, exists := c.ports[forwardID]; exists {
|
||||
now := time.Now()
|
||||
health.ConnectionTime = now
|
||||
health.LastActivity = now
|
||||
}
|
||||
}
|
||||
|
||||
// RecordActivity records data transfer activity for a forward
|
||||
func (c *Checker) RecordActivity(forwardID string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if health, exists := c.ports[forwardID]; exists {
|
||||
health.LastActivity = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister removes a port from monitoring
|
||||
func (c *Checker) Unregister(forwardID string) {
|
||||
c.mu.Lock()
|
||||
@@ -86,44 +152,34 @@ func (c *Checker) Unregister(forwardID string) {
|
||||
delete(c.callbacks, forwardID)
|
||||
}
|
||||
|
||||
// MarkReconnecting marks a forward as reconnecting (called by worker)
|
||||
func (c *Checker) MarkReconnecting(forwardID string) {
|
||||
// markStatus is a helper to set a forward's status and notify on change.
|
||||
func (c *Checker) markStatus(forwardID string, newStatus Status) {
|
||||
c.mu.Lock()
|
||||
|
||||
if health, exists := c.ports[forwardID]; exists {
|
||||
oldStatus := health.Status
|
||||
health.Status = StatusReconnect
|
||||
health.LastCheck = time.Now()
|
||||
|
||||
health, exists := c.ports[forwardID]
|
||||
if !exists {
|
||||
c.mu.Unlock()
|
||||
|
||||
if oldStatus != StatusReconnect {
|
||||
c.notifyStatusChange(forwardID, StatusReconnect, "")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
oldStatus := health.Status
|
||||
health.Status = newStatus
|
||||
health.LastCheck = time.Now()
|
||||
c.mu.Unlock()
|
||||
|
||||
if oldStatus != newStatus {
|
||||
c.notifyStatusChange(forwardID, newStatus, "")
|
||||
}
|
||||
}
|
||||
|
||||
// MarkReconnecting marks a forward as reconnecting (called by worker)
|
||||
func (c *Checker) MarkReconnecting(forwardID string) {
|
||||
c.markStatus(forwardID, StatusReconnect)
|
||||
}
|
||||
|
||||
// MarkStarting marks a forward as starting (called by worker)
|
||||
func (c *Checker) MarkStarting(forwardID string) {
|
||||
c.mu.Lock()
|
||||
|
||||
if health, exists := c.ports[forwardID]; exists {
|
||||
oldStatus := health.Status
|
||||
health.Status = StatusStarting
|
||||
health.LastCheck = time.Now()
|
||||
|
||||
c.mu.Unlock()
|
||||
|
||||
if oldStatus != StatusStarting {
|
||||
c.notifyStatusChange(forwardID, StatusStarting, "")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Unlock()
|
||||
c.markStatus(forwardID, StatusStarting)
|
||||
}
|
||||
|
||||
// GetStatus returns the current health status of a forward
|
||||
@@ -137,6 +193,17 @@ func (c *Checker) GetStatus(forwardID string) (Status, bool) {
|
||||
return StatusUnhealthy, false
|
||||
}
|
||||
|
||||
// GetLastCheckTime returns the last health check time for a forward
|
||||
func (c *Checker) GetLastCheckTime(forwardID string) (time.Time, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if health, exists := c.ports[forwardID]; exists {
|
||||
return health.LastCheck, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// GetAllErrors returns all forwards with errors and their error messages
|
||||
func (c *Checker) GetAllErrors() map[string]string {
|
||||
c.mu.RLock()
|
||||
@@ -197,38 +264,64 @@ func (c *Checker) checkPort(forwardID string) {
|
||||
port := health.Port
|
||||
oldStatus := health.Status
|
||||
registeredAt := health.RegisteredAt
|
||||
connectionTime := health.ConnectionTime
|
||||
lastActivity := health.LastActivity
|
||||
c.mu.RUnlock()
|
||||
|
||||
// Attempt to connect to the local port
|
||||
ctx, cancel := context.WithTimeout(c.ctx, c.timeout)
|
||||
defer cancel()
|
||||
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||
|
||||
now := time.Now()
|
||||
newStatus := StatusHealthy
|
||||
errorMsg := ""
|
||||
|
||||
if err != nil {
|
||||
// Grace period: if forward is less than 10 seconds old, keep it as "Starting"
|
||||
// This avoids scary "Error" messages during initial connection attempts
|
||||
timeSinceStart := time.Since(registeredAt)
|
||||
if timeSinceStart < startupGracePeriod {
|
||||
newStatus = StatusStarting
|
||||
} else {
|
||||
newStatus = StatusUnhealthy
|
||||
}
|
||||
errorMsg = err.Error()
|
||||
// Check for stale connections based on age or idle time
|
||||
connectionAge := now.Sub(connectionTime)
|
||||
idleTime := now.Sub(lastActivity)
|
||||
|
||||
// Only enforce max connection age if the connection is ALSO idle
|
||||
// This prevents interrupting active transfers (e.g., database dumps)
|
||||
if c.maxConnectionAge > 0 && connectionAge > c.maxConnectionAge && idleTime > c.maxIdleTime {
|
||||
newStatus = StatusStale
|
||||
errorMsg = fmt.Sprintf("connection age %v exceeds max %v (and idle for %v)",
|
||||
connectionAge.Round(time.Second), c.maxConnectionAge, idleTime.Round(time.Second))
|
||||
} else if c.maxIdleTime > 0 && idleTime > c.maxIdleTime {
|
||||
newStatus = StatusStale
|
||||
errorMsg = fmt.Sprintf("idle time %v exceeds max %v", idleTime.Round(time.Second), c.maxIdleTime)
|
||||
} else {
|
||||
conn.Close()
|
||||
// Perform connectivity check
|
||||
var checkErr error
|
||||
switch c.method {
|
||||
case CheckMethodDataTransfer:
|
||||
checkErr = c.checkDataTransfer(port)
|
||||
case CheckMethodTCPDial:
|
||||
checkErr = c.checkTCPDial(port)
|
||||
default:
|
||||
checkErr = c.checkTCPDial(port)
|
||||
}
|
||||
|
||||
if checkErr != nil {
|
||||
// Grace period: if forward is less than 10 seconds old, keep it as "Starting"
|
||||
// This avoids scary "Error" messages during initial connection attempts
|
||||
timeSinceStart := now.Sub(registeredAt)
|
||||
if timeSinceStart < startupGracePeriod {
|
||||
newStatus = StatusStarting
|
||||
} else {
|
||||
newStatus = StatusUnhealthy
|
||||
}
|
||||
errorMsg = checkErr.Error()
|
||||
}
|
||||
}
|
||||
|
||||
// Update health status
|
||||
c.mu.Lock()
|
||||
if health, exists := c.ports[forwardID]; exists {
|
||||
health.Status = newStatus
|
||||
health.LastCheck = time.Now()
|
||||
health.LastCheck = now
|
||||
health.ErrorMessage = errorMsg
|
||||
|
||||
// Successful health check indicates connection is active
|
||||
// This prevents false positives where healthy connections are marked as idle
|
||||
if newStatus == StatusHealthy {
|
||||
health.LastActivity = now
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
@@ -238,6 +331,62 @@ func (c *Checker) checkPort(forwardID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// checkTCPDial performs a simple TCP dial test
|
||||
func (c *Checker) checkTCPDial(port int) error {
|
||||
ctx, cancel := context.WithTimeout(c.ctx, c.timeout)
|
||||
defer cancel()
|
||||
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkDataTransfer attempts to read data from the connection to verify tunnel health
|
||||
func (c *Checker) checkDataTransfer(port int) error {
|
||||
ctx, cancel := context.WithTimeout(c.ctx, c.timeout)
|
||||
defer cancel()
|
||||
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Set a short read deadline to detect hung connections
|
||||
// We don't expect to receive data, but we want to verify the connection isn't hung
|
||||
conn.SetReadDeadline(time.Now().Add(c.timeout))
|
||||
|
||||
// Try to read a small amount of data
|
||||
// Most servers will either:
|
||||
// 1. Send a banner (SSH, FTP, etc) - we'll read it successfully
|
||||
// 2. Wait for client to send first (HTTP, postgres) - we'll timeout (which is OK)
|
||||
// 3. Hung/stale connection - will timeout with different error
|
||||
buf := make([]byte, dataTransferSize)
|
||||
_, err = conn.Read(buf)
|
||||
|
||||
// We expect either:
|
||||
// - No error (banner received)
|
||||
// - EOF (connection closed by server after connect)
|
||||
// - Timeout (server waiting for client)
|
||||
// All of these indicate the tunnel is working
|
||||
if err == nil || err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Timeout is acceptable - server is waiting for us to send data first
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Other errors indicate a problem
|
||||
return fmt.Errorf("data transfer check failed: %w", err)
|
||||
}
|
||||
|
||||
// notifyStatusChange calls the callback for a forward
|
||||
func (c *Checker) notifyStatusChange(forwardID string, status Status, errorMsg string) {
|
||||
c.mu.RLock()
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
package healthcheck
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
// HealthCheckTestSuite contains tests for the health checker
|
||||
type HealthCheckTestSuite struct {
|
||||
suite.Suite
|
||||
checker *Checker
|
||||
listener net.Listener
|
||||
port int
|
||||
}
|
||||
|
||||
func TestHealthCheckSuite(t *testing.T) {
|
||||
suite.Run(t, new(HealthCheckTestSuite))
|
||||
}
|
||||
|
||||
func (s *HealthCheckTestSuite) SetupTest() {
|
||||
// Create a test listener on a random port
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(s.T(), err)
|
||||
s.listener = ln
|
||||
s.port = ln.Addr().(*net.TCPAddr).Port
|
||||
|
||||
// Create checker with fast intervals for testing
|
||||
s.checker = NewCheckerWithOptions(CheckerOptions{
|
||||
Interval: 100 * time.Millisecond,
|
||||
Timeout: 50 * time.Millisecond,
|
||||
Method: CheckMethodTCPDial,
|
||||
MaxConnectionAge: 500 * time.Millisecond,
|
||||
MaxIdleTime: 300 * time.Millisecond,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *HealthCheckTestSuite) TearDownTest() {
|
||||
if s.checker != nil {
|
||||
s.checker.Stop()
|
||||
}
|
||||
if s.listener != nil {
|
||||
s.listener.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegisterAndUnregister tests basic registration and unregistration
|
||||
func (s *HealthCheckTestSuite) TestRegisterAndUnregister() {
|
||||
callbackCalled := false
|
||||
var callbackStatus Status
|
||||
var mu sync.Mutex
|
||||
|
||||
callback := func(forwardID string, status Status, errorMsg string) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
callbackCalled = true
|
||||
callbackStatus = status
|
||||
}
|
||||
|
||||
// Register port
|
||||
s.checker.Register("test-forward", s.port, callback)
|
||||
|
||||
// Wait for health check to run
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Verify callback was called with healthy status
|
||||
mu.Lock()
|
||||
assert.True(s.T(), callbackCalled, "Callback should have been called")
|
||||
assert.Equal(s.T(), StatusHealthy, callbackStatus)
|
||||
mu.Unlock()
|
||||
|
||||
// Unregister
|
||||
s.checker.Unregister("test-forward")
|
||||
|
||||
// Verify port is no longer monitored
|
||||
status, exists := s.checker.GetStatus("test-forward")
|
||||
assert.False(s.T(), exists, "Port should no longer exist after unregister")
|
||||
assert.Equal(s.T(), StatusUnhealthy, status)
|
||||
}
|
||||
|
||||
// TestTCPDialMethod tests the TCP dial health check method
|
||||
func (s *HealthCheckTestSuite) TestTCPDialMethod() {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupPort bool
|
||||
expectedStatus Status
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "port available - healthy",
|
||||
setupPort: true,
|
||||
expectedStatus: StatusHealthy,
|
||||
description: "When port is listening, status should be healthy",
|
||||
},
|
||||
{
|
||||
name: "port unavailable - unhealthy",
|
||||
setupPort: false,
|
||||
expectedStatus: StatusUnhealthy,
|
||||
description: "When port is not listening, status should be unhealthy",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.Run(tt.name, func() {
|
||||
var testPort int
|
||||
var testListener net.Listener
|
||||
|
||||
if tt.setupPort {
|
||||
// Use the existing listener
|
||||
testPort = s.port
|
||||
} else {
|
||||
// Use a port that's not listening
|
||||
testPort = 54321 // Likely unused port
|
||||
}
|
||||
|
||||
// Create a new checker for this test
|
||||
checker := NewCheckerWithOptions(CheckerOptions{
|
||||
Interval: 100 * time.Millisecond,
|
||||
Timeout: 50 * time.Millisecond,
|
||||
Method: CheckMethodTCPDial,
|
||||
MaxConnectionAge: 0, // Disable for this test
|
||||
MaxIdleTime: 0, // Disable for this test
|
||||
})
|
||||
defer checker.Stop()
|
||||
|
||||
checker.Register("test-forward", testPort, nil)
|
||||
|
||||
// Wait for health checks to complete
|
||||
if !tt.setupPort {
|
||||
// For unhealthy case, wait for grace period
|
||||
time.Sleep(startupGracePeriod + 200*time.Millisecond)
|
||||
} else {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Check status directly
|
||||
status, exists := checker.GetStatus("test-forward")
|
||||
assert.True(s.T(), exists)
|
||||
assert.Equal(s.T(), tt.expectedStatus, status, tt.description)
|
||||
|
||||
if testListener != nil {
|
||||
testListener.Close()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataTransferMethod tests the data transfer health check method
|
||||
func (s *HealthCheckTestSuite) TestDataTransferMethod() {
|
||||
tests := []struct {
|
||||
name string
|
||||
serverBehavior string // "banner", "silent", "close", "none"
|
||||
expectedStatus Status
|
||||
}{
|
||||
{
|
||||
name: "server sends banner - healthy",
|
||||
serverBehavior: "banner",
|
||||
expectedStatus: StatusHealthy,
|
||||
},
|
||||
{
|
||||
name: "server waits silently - healthy (timeout OK)",
|
||||
serverBehavior: "silent",
|
||||
expectedStatus: StatusHealthy,
|
||||
},
|
||||
{
|
||||
name: "server closes connection - healthy (EOF OK)",
|
||||
serverBehavior: "close",
|
||||
expectedStatus: StatusHealthy,
|
||||
},
|
||||
{
|
||||
name: "no server listening - unhealthy",
|
||||
serverBehavior: "none",
|
||||
expectedStatus: StatusUnhealthy,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
s.Run(tt.name, func() {
|
||||
var testPort int
|
||||
var testListener net.Listener
|
||||
var err error
|
||||
|
||||
if tt.serverBehavior != "none" {
|
||||
// Start test server
|
||||
testListener, err = net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(s.T(), err)
|
||||
testPort = testListener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
// Handle connections based on behavior
|
||||
go func() {
|
||||
for {
|
||||
conn, err := testListener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
switch tt.serverBehavior {
|
||||
case "banner":
|
||||
conn.Write([]byte("220 Welcome\r\n"))
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
conn.Close()
|
||||
case "close":
|
||||
conn.Close()
|
||||
case "silent":
|
||||
// Just keep connection open
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
defer testListener.Close()
|
||||
} else {
|
||||
testPort = 54322 // Unused port
|
||||
}
|
||||
|
||||
// Create checker with data transfer method
|
||||
checker := NewCheckerWithOptions(CheckerOptions{
|
||||
Interval: 100 * time.Millisecond,
|
||||
Timeout: 50 * time.Millisecond,
|
||||
Method: CheckMethodDataTransfer,
|
||||
MaxConnectionAge: 0, // Disable for this test
|
||||
MaxIdleTime: 0, // Disable for this test
|
||||
})
|
||||
defer checker.Stop()
|
||||
|
||||
checker.Register("test-forward", testPort, nil)
|
||||
|
||||
// Wait for health checks to complete
|
||||
if tt.serverBehavior == "none" {
|
||||
// For unhealthy case, wait for grace period
|
||||
time.Sleep(startupGracePeriod + 200*time.Millisecond)
|
||||
} else {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Check status directly
|
||||
status, exists := checker.GetStatus("test-forward")
|
||||
assert.True(s.T(), exists)
|
||||
assert.Equal(s.T(), tt.expectedStatus, status)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConnectionAgeDetection tests max connection age detection
|
||||
func (s *HealthCheckTestSuite) TestConnectionAgeDetection() {
|
||||
statusChanges := make(chan Status, 10)
|
||||
callback := func(forwardID string, status Status, errorMsg string) {
|
||||
statusChanges <- status
|
||||
}
|
||||
|
||||
// Create checker with very short max connection age
|
||||
checker := NewCheckerWithOptions(CheckerOptions{
|
||||
Interval: 50 * time.Millisecond,
|
||||
Timeout: 25 * time.Millisecond,
|
||||
Method: CheckMethodTCPDial,
|
||||
MaxConnectionAge: 150 * time.Millisecond, // Very short for testing
|
||||
MaxIdleTime: 0, // Disable idle detection
|
||||
})
|
||||
defer checker.Stop()
|
||||
|
||||
checker.Register("test-forward", s.port, callback)
|
||||
|
||||
// Wait for initial healthy status
|
||||
var gotHealthy, gotStale bool
|
||||
timeout := time.After(1 * time.Second)
|
||||
|
||||
for {
|
||||
select {
|
||||
case status := <-statusChanges:
|
||||
if status == StatusHealthy || status == StatusStarting {
|
||||
gotHealthy = true
|
||||
}
|
||||
if status == StatusStale {
|
||||
gotStale = true
|
||||
}
|
||||
if gotHealthy && gotStale {
|
||||
return // Test passed
|
||||
}
|
||||
case <-timeout:
|
||||
s.T().Fatalf("Expected StatusStale after max connection age exceeded. gotHealthy=%v, gotStale=%v",
|
||||
gotHealthy, gotStale)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdleTimeDetection tests that connections with passing health checks are NOT marked as stale
|
||||
// This verifies that successful health checks update LastActivity, preventing false idle detection
|
||||
func (s *HealthCheckTestSuite) TestIdleTimeDetection() {
|
||||
statusChanges := make(chan Status, 10)
|
||||
callback := func(forwardID string, status Status, errorMsg string) {
|
||||
statusChanges <- status
|
||||
}
|
||||
|
||||
// Create checker with very short max idle time
|
||||
checker := NewCheckerWithOptions(CheckerOptions{
|
||||
Interval: 50 * time.Millisecond,
|
||||
Timeout: 25 * time.Millisecond,
|
||||
Method: CheckMethodTCPDial,
|
||||
MaxConnectionAge: 0, // Disable age detection
|
||||
MaxIdleTime: 150 * time.Millisecond, // Very short for testing
|
||||
})
|
||||
defer checker.Stop()
|
||||
|
||||
checker.Register("test-forward", s.port, callback)
|
||||
|
||||
// Wait long enough that idle time WOULD be exceeded if health checks didn't update LastActivity
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Verify connection is still healthy, not stale
|
||||
// This proves that successful health checks are updating LastActivity
|
||||
status, exists := checker.GetStatus("test-forward")
|
||||
require.True(s.T(), exists)
|
||||
assert.Equal(s.T(), StatusHealthy, status, "Connection with passing health checks should NOT be marked as stale")
|
||||
|
||||
// Verify we never received a StatusStale callback
|
||||
select {
|
||||
case status := <-statusChanges:
|
||||
if status == StatusStale {
|
||||
s.T().Fatal("Connection should NOT be marked as stale when health checks are passing")
|
||||
}
|
||||
default:
|
||||
// No stale status - this is correct
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkConnected tests that MarkConnected resets connection time
|
||||
func (s *HealthCheckTestSuite) TestMarkConnected() {
|
||||
checker := NewCheckerWithOptions(CheckerOptions{
|
||||
Interval: 50 * time.Millisecond,
|
||||
Timeout: 25 * time.Millisecond,
|
||||
Method: CheckMethodTCPDial,
|
||||
MaxConnectionAge: 200 * time.Millisecond,
|
||||
MaxIdleTime: 0,
|
||||
})
|
||||
defer checker.Stop()
|
||||
|
||||
statusChanges := make(chan Status, 10)
|
||||
callback := func(forwardID string, status Status, errorMsg string) {
|
||||
statusChanges <- status
|
||||
}
|
||||
|
||||
checker.Register("test-forward", s.port, callback)
|
||||
|
||||
// Wait a bit
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Mark as reconnected (resets connection time)
|
||||
checker.MarkConnected("test-forward")
|
||||
|
||||
// Wait for connection age to exceed (relative to first connection time)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Check status - should still be healthy because we reset connection time
|
||||
status, exists := checker.GetStatus("test-forward")
|
||||
assert.True(s.T(), exists)
|
||||
// Note: Might be StatusStale by now, but the key is that MarkConnected delayed it
|
||||
// This is a timing-sensitive test, so we just verify the functionality exists
|
||||
_ = status
|
||||
}
|
||||
|
||||
// TestRecordActivity tests that RecordActivity resets idle time
|
||||
func (s *HealthCheckTestSuite) TestRecordActivity() {
|
||||
checker := NewCheckerWithOptions(CheckerOptions{
|
||||
Interval: 50 * time.Millisecond,
|
||||
Timeout: 25 * time.Millisecond,
|
||||
Method: CheckMethodTCPDial,
|
||||
MaxConnectionAge: 0,
|
||||
MaxIdleTime: 200 * time.Millisecond,
|
||||
})
|
||||
defer checker.Stop()
|
||||
|
||||
statusChanges := make(chan Status, 10)
|
||||
callback := func(forwardID string, status Status, errorMsg string) {
|
||||
statusChanges <- status
|
||||
}
|
||||
|
||||
checker.Register("test-forward", s.port, callback)
|
||||
|
||||
// Periodically record activity to prevent idle detection
|
||||
ticker := time.NewTicker(80 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
go func() {
|
||||
for i := 0; i < 5; i++ {
|
||||
<-ticker.C
|
||||
checker.RecordActivity("test-forward")
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait longer than idle timeout
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Should still be healthy due to activity
|
||||
status, exists := checker.GetStatus("test-forward")
|
||||
assert.True(s.T(), exists)
|
||||
// May transition to stale eventually, but activity recording should have delayed it
|
||||
_ = status
|
||||
}
|
||||
|
||||
// TestMarkReconnecting tests the MarkReconnecting functionality
|
||||
func (s *HealthCheckTestSuite) TestMarkReconnecting() {
|
||||
statusChanges := make(chan Status, 10)
|
||||
callback := func(forwardID string, status Status, errorMsg string) {
|
||||
statusChanges <- status
|
||||
}
|
||||
|
||||
s.checker.Register("test-forward", s.port, callback)
|
||||
|
||||
// Wait for initial status
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// Mark as reconnecting
|
||||
s.checker.MarkReconnecting("test-forward")
|
||||
|
||||
// Should receive reconnecting status
|
||||
timeout := time.After(500 * time.Millisecond)
|
||||
gotReconnect := false
|
||||
for !gotReconnect {
|
||||
select {
|
||||
case status := <-statusChanges:
|
||||
if status == StatusReconnect {
|
||||
gotReconnect = true
|
||||
}
|
||||
case <-timeout:
|
||||
s.T().Fatal("Expected StatusReconnect")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartingGracePeriod tests that errors during grace period show as "Starting"
|
||||
func (s *HealthCheckTestSuite) TestStartingGracePeriod() {
|
||||
// Use a port that's not listening
|
||||
unavailablePort := 54323
|
||||
|
||||
checker := NewCheckerWithOptions(CheckerOptions{
|
||||
Interval: 50 * time.Millisecond,
|
||||
Timeout: 25 * time.Millisecond,
|
||||
Method: CheckMethodTCPDial,
|
||||
MaxConnectionAge: 0,
|
||||
MaxIdleTime: 0,
|
||||
})
|
||||
defer checker.Stop()
|
||||
|
||||
// Register without callback - we'll check status directly
|
||||
checker.Register("test-forward", unavailablePort, nil)
|
||||
|
||||
// Immediately check status - should be Starting or not yet checked
|
||||
status, exists := checker.GetStatus("test-forward")
|
||||
assert.True(s.T(), exists)
|
||||
// Initially should be Starting
|
||||
assert.Equal(s.T(), StatusStarting, status)
|
||||
|
||||
// Wait for grace period to expire
|
||||
time.Sleep(startupGracePeriod + 200*time.Millisecond)
|
||||
|
||||
// Now should be Unhealthy
|
||||
status, exists = checker.GetStatus("test-forward")
|
||||
assert.True(s.T(), exists)
|
||||
assert.Equal(s.T(), StatusUnhealthy, status)
|
||||
}
|
||||
|
||||
// TestGetAllErrors tests retrieving all error messages
|
||||
func (s *HealthCheckTestSuite) TestGetAllErrors() {
|
||||
// Create a new checker with faster intervals for this test
|
||||
checker := NewCheckerWithOptions(CheckerOptions{
|
||||
Interval: 100 * time.Millisecond,
|
||||
Timeout: 50 * time.Millisecond,
|
||||
Method: CheckMethodTCPDial,
|
||||
MaxConnectionAge: 0,
|
||||
MaxIdleTime: 0,
|
||||
})
|
||||
defer checker.Stop()
|
||||
|
||||
// Register multiple forwards
|
||||
checker.Register("forward1", s.port, nil)
|
||||
checker.Register("forward2", 54324, nil) // Unavailable port
|
||||
|
||||
// Wait for grace period to expire
|
||||
time.Sleep(startupGracePeriod + 300*time.Millisecond)
|
||||
|
||||
errors := checker.GetAllErrors()
|
||||
|
||||
// forward2 should have an error
|
||||
_, hasError := errors["forward2"]
|
||||
assert.True(s.T(), hasError, "forward2 should have an error")
|
||||
|
||||
// forward1 should not have an error
|
||||
_, hasError = errors["forward1"]
|
||||
assert.False(s.T(), hasError, "forward1 should not have an error")
|
||||
}
|
||||
|
||||
// TestConcurrentOperations tests thread safety
|
||||
func (s *HealthCheckTestSuite) TestConcurrentOperations() {
|
||||
var wg sync.WaitGroup
|
||||
numGoroutines := 10
|
||||
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
forwardID := fmt.Sprintf("forward-%d", id)
|
||||
s.checker.Register(forwardID, s.port, nil)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
s.checker.MarkConnected(forwardID)
|
||||
s.checker.RecordActivity(forwardID)
|
||||
status, _ := s.checker.GetStatus(forwardID)
|
||||
_ = status
|
||||
s.checker.Unregister(forwardID)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
// If we get here without deadlocks or panics, test passes
|
||||
}
|
||||
|
||||
// TestDefaultOptions tests that NewChecker uses sensible defaults
|
||||
func TestDefaultOptions(t *testing.T) {
|
||||
checker := NewChecker(5*time.Second, 2*time.Second)
|
||||
defer checker.Stop()
|
||||
|
||||
assert.Equal(t, 5*time.Second, checker.interval)
|
||||
assert.Equal(t, 2*time.Second, checker.timeout)
|
||||
assert.Equal(t, CheckMethodDataTransfer, checker.method)
|
||||
assert.Equal(t, 25*time.Minute, checker.maxConnectionAge)
|
||||
assert.Equal(t, 10*time.Minute, checker.maxIdleTime)
|
||||
}
|
||||
|
||||
// TestCustomOptions tests NewCheckerWithOptions
|
||||
func TestCustomOptions(t *testing.T) {
|
||||
opts := CheckerOptions{
|
||||
Interval: 1 * time.Second,
|
||||
Timeout: 500 * time.Millisecond,
|
||||
Method: CheckMethodTCPDial,
|
||||
MaxConnectionAge: 5 * time.Minute,
|
||||
MaxIdleTime: 2 * time.Minute,
|
||||
}
|
||||
|
||||
checker := NewCheckerWithOptions(opts)
|
||||
defer checker.Stop()
|
||||
|
||||
assert.Equal(t, 1*time.Second, checker.interval)
|
||||
assert.Equal(t, 500*time.Millisecond, checker.timeout)
|
||||
assert.Equal(t, CheckMethodTCPDial, checker.method)
|
||||
assert.Equal(t, 5*time.Minute, checker.maxConnectionAge)
|
||||
assert.Equal(t, 2*time.Minute, checker.maxIdleTime)
|
||||
}
|
||||
+67
-27
@@ -5,11 +5,12 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
)
|
||||
|
||||
// Discovery provides cluster introspection capabilities for the UI wizards.
|
||||
@@ -42,9 +43,10 @@ type ContainerInfo struct {
|
||||
|
||||
// PortInfo describes a port exposed by a container or service.
|
||||
type PortInfo struct {
|
||||
Name string
|
||||
Port int32
|
||||
Protocol string
|
||||
Name string
|
||||
Port int32
|
||||
TargetPort int32 // For services: the actual pod port to forward to
|
||||
Protocol string
|
||||
}
|
||||
|
||||
// ServiceInfo contains information about a service.
|
||||
@@ -206,7 +208,60 @@ func (d *Discovery) ListPodsWithSelector(ctx context.Context, contextName, names
|
||||
return pods, nil
|
||||
}
|
||||
|
||||
// resolveTargetPort resolves a service's targetPort to an actual port number.
|
||||
// If targetPort is numeric, it returns that number directly.
|
||||
// If targetPort is a named port, it looks up the port number from the backing pods.
|
||||
// Falls back to the service port if resolution fails.
|
||||
func (d *Discovery) resolveTargetPort(ctx context.Context, client kubernetes.Interface, namespace string, svc *corev1.Service, port *corev1.ServicePort) int32 {
|
||||
// If targetPort is not set, Kubernetes defaults to the service port
|
||||
if port.TargetPort.Type == intstr.Int && port.TargetPort.IntVal == 0 {
|
||||
return port.Port
|
||||
}
|
||||
|
||||
// If targetPort is numeric, use it directly
|
||||
if port.TargetPort.Type == intstr.Int {
|
||||
return port.TargetPort.IntVal
|
||||
}
|
||||
|
||||
// targetPort is a named port - need to look up from pods
|
||||
namedPort := port.TargetPort.StrVal
|
||||
if namedPort == "" {
|
||||
return port.Port
|
||||
}
|
||||
|
||||
// Get a backing pod to resolve the named port
|
||||
if len(svc.Spec.Selector) == 0 {
|
||||
// No selector, can't resolve - fall back to service port
|
||||
return port.Port
|
||||
}
|
||||
|
||||
selector := metav1.FormatLabelSelector(&metav1.LabelSelector{MatchLabels: svc.Spec.Selector})
|
||||
pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
|
||||
LabelSelector: selector,
|
||||
Limit: 1, // We only need one pod to resolve the port name
|
||||
})
|
||||
if err != nil || len(pods.Items) == 0 {
|
||||
// Can't get pods - fall back to service port
|
||||
return port.Port
|
||||
}
|
||||
|
||||
// Look up the named port in the pod's containers
|
||||
pod := &pods.Items[0]
|
||||
for _, container := range pod.Spec.Containers {
|
||||
for _, containerPort := range container.Ports {
|
||||
if containerPort.Name == namedPort {
|
||||
return containerPort.ContainerPort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Named port not found - fall back to service port
|
||||
return port.Port
|
||||
}
|
||||
|
||||
// ListServices returns all services in the given namespace.
|
||||
// For each service port, it resolves the targetPort to an actual port number
|
||||
// by looking up the backing pods when the targetPort is a named port.
|
||||
func (d *Discovery) ListServices(ctx context.Context, contextName, namespace string) ([]ServiceInfo, error) {
|
||||
client, err := d.pool.GetClient(contextName)
|
||||
if err != nil {
|
||||
@@ -222,10 +277,13 @@ func (d *Discovery) ListServices(ctx context.Context, contextName, namespace str
|
||||
for _, svc := range svcList.Items {
|
||||
ports := make([]PortInfo, 0, len(svc.Spec.Ports))
|
||||
for _, port := range svc.Spec.Ports {
|
||||
targetPort := d.resolveTargetPort(ctx, client, namespace, &svc, &port)
|
||||
|
||||
ports = append(ports, PortInfo{
|
||||
Name: port.Name,
|
||||
Port: port.Port,
|
||||
Protocol: string(port.Protocol),
|
||||
Name: port.Name,
|
||||
Port: port.Port,
|
||||
TargetPort: targetPort,
|
||||
Protocol: string(port.Protocol),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -293,29 +351,11 @@ func CheckPortAvailability(port int) (bool, string, error) {
|
||||
addr := fmt.Sprintf(":%d", port)
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
// Port is in use
|
||||
// Try to get process info (best-effort)
|
||||
processInfo := "unknown process"
|
||||
// Note: Getting process info requires platform-specific code
|
||||
// For now, just return a generic message
|
||||
return false, processInfo, nil
|
||||
// Port is in use - return error details
|
||||
return false, err.Error(), nil
|
||||
}
|
||||
|
||||
// Port is available, close the listener
|
||||
listener.Close()
|
||||
return true, "", nil
|
||||
}
|
||||
|
||||
// ValidatePort checks if a port number is valid.
|
||||
func ValidatePort(portStr string) (int, error) {
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid port number: %s", portStr)
|
||||
}
|
||||
|
||||
if port < 1 || port > 65535 {
|
||||
return 0, fmt.Errorf("port must be between 1 and 65535, got %d", port)
|
||||
}
|
||||
|
||||
return port, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
)
|
||||
|
||||
func TestResolveTargetPort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
servicePort corev1.ServicePort
|
||||
service *corev1.Service
|
||||
pods []corev1.Pod
|
||||
expectedPort int32
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "numeric targetPort",
|
||||
servicePort: corev1.ServicePort{
|
||||
Port: 80,
|
||||
TargetPort: intstr.FromInt(8000),
|
||||
},
|
||||
service: &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-svc",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{"app": "test"},
|
||||
},
|
||||
},
|
||||
pods: nil, // No pods needed for numeric targetPort
|
||||
expectedPort: 8000,
|
||||
description: "should use numeric targetPort directly",
|
||||
},
|
||||
{
|
||||
name: "named targetPort resolved from pod",
|
||||
servicePort: corev1.ServicePort{
|
||||
Name: "http",
|
||||
Port: 80,
|
||||
TargetPort: intstr.FromString("http"),
|
||||
},
|
||||
service: &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-svc",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{"app": "test"},
|
||||
},
|
||||
},
|
||||
pods: []corev1.Pod{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-pod",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "test"},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "main",
|
||||
Ports: []corev1.ContainerPort{
|
||||
{Name: "http", ContainerPort: 8000},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedPort: 8000,
|
||||
description: "should resolve named port from pod container",
|
||||
},
|
||||
{
|
||||
name: "targetPort not set - defaults to service port",
|
||||
servicePort: corev1.ServicePort{
|
||||
Port: 80,
|
||||
TargetPort: intstr.FromInt(0), // Not set
|
||||
},
|
||||
service: &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-svc",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{"app": "test"},
|
||||
},
|
||||
},
|
||||
pods: nil,
|
||||
expectedPort: 80,
|
||||
description: "should fall back to service port when targetPort is not set",
|
||||
},
|
||||
{
|
||||
name: "named targetPort with no matching pod",
|
||||
servicePort: corev1.ServicePort{
|
||||
Name: "http",
|
||||
Port: 80,
|
||||
TargetPort: intstr.FromString("http"),
|
||||
},
|
||||
service: &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-svc",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{"app": "test"},
|
||||
},
|
||||
},
|
||||
pods: nil, // No pods available
|
||||
expectedPort: 80,
|
||||
description: "should fall back to service port when no pods found",
|
||||
},
|
||||
{
|
||||
name: "service without selector",
|
||||
servicePort: corev1.ServicePort{
|
||||
Name: "http",
|
||||
Port: 80,
|
||||
TargetPort: intstr.FromString("http"),
|
||||
},
|
||||
service: &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-svc",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: nil, // No selector
|
||||
},
|
||||
},
|
||||
pods: nil,
|
||||
expectedPort: 80,
|
||||
description: "should fall back to service port when service has no selector",
|
||||
},
|
||||
{
|
||||
name: "named targetPort not found in pod containers",
|
||||
servicePort: corev1.ServicePort{
|
||||
Name: "http",
|
||||
Port: 80,
|
||||
TargetPort: intstr.FromString("nonexistent"),
|
||||
},
|
||||
service: &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-svc",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{"app": "test"},
|
||||
},
|
||||
},
|
||||
pods: []corev1.Pod{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-pod",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "test"},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "main",
|
||||
Ports: []corev1.ContainerPort{
|
||||
{Name: "http", ContainerPort: 8000},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedPort: 80,
|
||||
description: "should fall back to service port when named port not found in pod",
|
||||
},
|
||||
{
|
||||
name: "multiple containers with named port in second container",
|
||||
servicePort: corev1.ServicePort{
|
||||
Name: "metrics",
|
||||
Port: 9090,
|
||||
TargetPort: intstr.FromString("metrics"),
|
||||
},
|
||||
service: &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-svc",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{"app": "test"},
|
||||
},
|
||||
},
|
||||
pods: []corev1.Pod{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-pod",
|
||||
Namespace: "default",
|
||||
Labels: map[string]string{"app": "test"},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{
|
||||
Name: "main",
|
||||
Ports: []corev1.ContainerPort{
|
||||
{Name: "http", ContainerPort: 8000},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "sidecar",
|
||||
Ports: []corev1.ContainerPort{
|
||||
{Name: "metrics", ContainerPort: 9100},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedPort: 9100,
|
||||
description: "should find named port in any container",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create fake client with pods
|
||||
var objects []runtime.Object
|
||||
for i := range tt.pods {
|
||||
objects = append(objects, &tt.pods[i])
|
||||
}
|
||||
fakeClient := fake.NewSimpleClientset(objects...)
|
||||
|
||||
// Create discovery instance (we only need it to call resolveTargetPort)
|
||||
d := &Discovery{}
|
||||
|
||||
// Call resolveTargetPort
|
||||
result := d.resolveTargetPort(
|
||||
context.Background(),
|
||||
fakeClient,
|
||||
"default",
|
||||
tt.service,
|
||||
&tt.servicePort,
|
||||
)
|
||||
|
||||
assert.Equal(t, tt.expectedPort, result, tt.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortInfoTargetPort(t *testing.T) {
|
||||
// Test that PortInfo correctly stores TargetPort
|
||||
portInfo := PortInfo{
|
||||
Name: "http",
|
||||
Port: 80,
|
||||
TargetPort: 8000,
|
||||
Protocol: "TCP",
|
||||
}
|
||||
|
||||
assert.Equal(t, int32(80), portInfo.Port)
|
||||
assert.Equal(t, int32(8000), portInfo.TargetPort)
|
||||
assert.Equal(t, "http", portInfo.Name)
|
||||
assert.Equal(t, "TCP", portInfo.Protocol)
|
||||
}
|
||||
|
||||
func TestGetUniquePorts(t *testing.T) {
|
||||
// Test GetUniquePorts still works with the new PortInfo struct
|
||||
pods := []PodInfo{
|
||||
{
|
||||
Name: "pod1",
|
||||
Containers: []ContainerInfo{
|
||||
{
|
||||
Name: "main",
|
||||
Ports: []PortInfo{
|
||||
{Name: "http", Port: 8080},
|
||||
{Name: "metrics", Port: 9090},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "pod2",
|
||||
Containers: []ContainerInfo{
|
||||
{
|
||||
Name: "main",
|
||||
Ports: []PortInfo{
|
||||
{Name: "http", Port: 8080}, // Duplicate
|
||||
{Name: "grpc", Port: 50051},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ports := GetUniquePorts(pods)
|
||||
|
||||
// Should have 3 unique ports
|
||||
assert.Len(t, ports, 3)
|
||||
|
||||
// Should be sorted by port number
|
||||
assert.Equal(t, int32(8080), ports[0].Port)
|
||||
assert.Equal(t, int32(9090), ports[1].Port)
|
||||
assert.Equal(t, int32(50051), ports[2].Port)
|
||||
|
||||
// Names should be preserved
|
||||
assert.Equal(t, "http", ports[0].Name)
|
||||
assert.Equal(t, "metrics", ports[1].Name)
|
||||
assert.Equal(t, "grpc", ports[2].Name)
|
||||
}
|
||||
@@ -4,9 +4,13 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nvm/kportal/internal/config"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -17,18 +21,32 @@ import (
|
||||
|
||||
// PortForwarder handles Kubernetes port-forwarding operations.
|
||||
type PortForwarder struct {
|
||||
clientPool *ClientPool
|
||||
resolver *ResourceResolver
|
||||
clientPool *ClientPool
|
||||
resolver *ResourceResolver
|
||||
tcpKeepalive time.Duration // TCP keepalive interval
|
||||
dialTimeout time.Duration // Connection dial timeout
|
||||
}
|
||||
|
||||
// NewPortForwarder creates a new PortForwarder instance.
|
||||
// NewPortForwarder creates a new PortForwarder instance with default settings.
|
||||
func NewPortForwarder(clientPool *ClientPool, resolver *ResourceResolver) *PortForwarder {
|
||||
return &PortForwarder{
|
||||
clientPool: clientPool,
|
||||
resolver: resolver,
|
||||
clientPool: clientPool,
|
||||
resolver: resolver,
|
||||
tcpKeepalive: config.DefaultTCPKeepalive,
|
||||
dialTimeout: config.DefaultDialTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// SetTCPKeepalive configures the TCP keepalive interval for new connections.
|
||||
func (pf *PortForwarder) SetTCPKeepalive(keepalive time.Duration) {
|
||||
pf.tcpKeepalive = keepalive
|
||||
}
|
||||
|
||||
// SetDialTimeout configures the connection dial timeout.
|
||||
func (pf *PortForwarder) SetDialTimeout(timeout time.Duration) {
|
||||
pf.dialTimeout = timeout
|
||||
}
|
||||
|
||||
// ForwardRequest contains the parameters for a port-forward request.
|
||||
type ForwardRequest struct {
|
||||
ContextName string // Kubernetes context name
|
||||
@@ -124,6 +142,9 @@ func (pf *PortForwarder) forwardToService(ctx context.Context, req *ForwardReque
|
||||
}
|
||||
|
||||
// Get pods backing the service using label selector
|
||||
if len(service.Spec.Selector) == 0 {
|
||||
return fmt.Errorf("service %s has no selector (headless service without selector cannot be port-forwarded)", serviceName)
|
||||
}
|
||||
selector := metav1.FormatLabelSelector(&metav1.LabelSelector{MatchLabels: service.Spec.Selector})
|
||||
pods, err := client.CoreV1().Pods(req.Namespace).List(ctx, metav1.ListOptions{
|
||||
LabelSelector: selector,
|
||||
@@ -164,6 +185,19 @@ func (pf *PortForwarder) forwardToService(ctx context.Context, req *ForwardReque
|
||||
|
||||
// executePortForward performs the actual port-forward operation.
|
||||
func (pf *PortForwarder) executePortForward(config *rest.Config, url *url.URL, req *ForwardRequest) error {
|
||||
// Configure TCP settings on the underlying connection
|
||||
// This is set in the rest.Config which will be used by the SPDY transport
|
||||
if config.Dial == nil {
|
||||
// Create a custom dialer with configurable timeout and keepalive
|
||||
// - Timeout: How long to wait for connection to establish
|
||||
// - KeepAlive: TCP keepalive helps OS detect dead connections at network layer
|
||||
dialer := &net.Dialer{
|
||||
Timeout: pf.dialTimeout, // Configurable dial timeout
|
||||
KeepAlive: pf.tcpKeepalive, // Configurable keepalive interval
|
||||
}
|
||||
config.Dial = dialer.DialContext
|
||||
}
|
||||
|
||||
// Create SPDY roundtripper
|
||||
transport, upgrader, err := spdy.RoundTripperFor(config)
|
||||
if err != nil {
|
||||
@@ -228,6 +262,9 @@ func (pf *PortForwarder) GetPodForResource(ctx context.Context, contextName, nam
|
||||
return "", fmt.Errorf("failed to get service: %w", err)
|
||||
}
|
||||
|
||||
if len(service.Spec.Selector) == 0 {
|
||||
return "", fmt.Errorf("service %s has no selector (headless service without selector cannot be port-forwarded)", resourceName)
|
||||
}
|
||||
selector := metav1.FormatLabelSelector(&metav1.LabelSelector{MatchLabels: service.Spec.Selector})
|
||||
pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
|
||||
LabelSelector: selector,
|
||||
|
||||
@@ -228,29 +228,3 @@ func (r *ResourceResolver) InvalidateCache(contextName, namespace, resource stri
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,105 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"github.com/go-logr/logr"
|
||||
)
|
||||
|
||||
// LogrAdapter implements the logr.LogSink interface to route klog v2 logs
|
||||
// through our structured logger. This captures ALL klog output including
|
||||
// error logs, structured logs, and named logger output.
|
||||
type LogrAdapter struct {
|
||||
logger *Logger
|
||||
name string
|
||||
level int
|
||||
}
|
||||
|
||||
// NewLogrAdapter creates a new logr.LogSink that routes all klog v2 logs
|
||||
// through our structured logger.
|
||||
func NewLogrAdapter(logger *Logger) logr.LogSink {
|
||||
return &LogrAdapter{
|
||||
logger: logger,
|
||||
name: "",
|
||||
level: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the logger with runtime info (not used in our implementation).
|
||||
func (l *LogrAdapter) Init(info logr.RuntimeInfo) {
|
||||
// No-op: we don't need runtime info
|
||||
}
|
||||
|
||||
// Enabled tests whether this LogSink is enabled at the specified V-level.
|
||||
// We route all logs through our logger's level filtering.
|
||||
func (l *LogrAdapter) Enabled(level int) bool {
|
||||
// Map logr V-levels to our levels:
|
||||
// V(0) = Info level (always enabled if logger level <= Info)
|
||||
// V(1+) = Debug level (enabled if logger level <= Debug)
|
||||
if level == 0 {
|
||||
return l.logger.level <= LevelInfo
|
||||
}
|
||||
return l.logger.level <= LevelDebug
|
||||
}
|
||||
|
||||
// Info logs a non-error message with the given key/value pairs.
|
||||
func (l *LogrAdapter) Info(level int, msg string, keysAndValues ...interface{}) {
|
||||
fields := l.kvToMap(keysAndValues)
|
||||
if l.name != "" {
|
||||
fields["logger"] = l.name
|
||||
}
|
||||
|
||||
// Map logr V-levels to our levels:
|
||||
// V(0) = Info, V(1+) = Debug
|
||||
if level == 0 {
|
||||
l.logger.Info(msg, fields)
|
||||
} else {
|
||||
l.logger.Debug(msg, fields)
|
||||
}
|
||||
}
|
||||
|
||||
// Error logs an error message with the given key/value pairs.
|
||||
func (l *LogrAdapter) Error(err error, msg string, keysAndValues ...interface{}) {
|
||||
fields := l.kvToMap(keysAndValues)
|
||||
if l.name != "" {
|
||||
fields["logger"] = l.name
|
||||
}
|
||||
if err != nil {
|
||||
fields["error"] = err.Error()
|
||||
}
|
||||
|
||||
l.logger.Error(msg, fields)
|
||||
}
|
||||
|
||||
// WithValues returns a new LogSink with additional key/value pairs.
|
||||
func (l *LogrAdapter) WithValues(keysAndValues ...interface{}) logr.LogSink {
|
||||
// For simplicity, we don't implement value accumulation
|
||||
// Each log call receives all its keysAndValues directly
|
||||
return l
|
||||
}
|
||||
|
||||
// WithName returns a new LogSink with the specified name appended.
|
||||
func (l *LogrAdapter) WithName(name string) logr.LogSink {
|
||||
newLogger := *l
|
||||
if l.name == "" {
|
||||
newLogger.name = name
|
||||
} else {
|
||||
newLogger.name = l.name + "." + name
|
||||
}
|
||||
return &newLogger
|
||||
}
|
||||
|
||||
// kvToMap converts a slice of alternating keys and values to a map.
|
||||
func (l *LogrAdapter) kvToMap(keysAndValues []interface{}) map[string]interface{} {
|
||||
fields := make(map[string]interface{})
|
||||
fields["source"] = "k8s-client"
|
||||
|
||||
for i := 0; i < len(keysAndValues); i += 2 {
|
||||
if i+1 < len(keysAndValues) {
|
||||
key, ok := keysAndValues[i].(string)
|
||||
if ok {
|
||||
fields[key] = keysAndValues[i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/go-logr/logr"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLogrAdapter_Info(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
loggerLevel Level
|
||||
logrLevel int
|
||||
message string
|
||||
keysAndValues []interface{}
|
||||
expectOutput bool
|
||||
expectContains []string
|
||||
}{
|
||||
{
|
||||
name: "info log v0 with debug logger",
|
||||
loggerLevel: LevelDebug,
|
||||
logrLevel: 0,
|
||||
message: "Connection established",
|
||||
keysAndValues: []interface{}{"pod", "my-app-123", "port", 8080},
|
||||
expectOutput: true,
|
||||
expectContains: []string{"[INFO]", "Connection established", "pod", "my-app-123"},
|
||||
},
|
||||
{
|
||||
name: "info log v0 with info logger",
|
||||
loggerLevel: LevelInfo,
|
||||
logrLevel: 0,
|
||||
message: "Port forward ready",
|
||||
keysAndValues: []interface{}{},
|
||||
expectOutput: true,
|
||||
expectContains: []string{"[INFO]", "Port forward ready"},
|
||||
},
|
||||
{
|
||||
name: "info log v0 silenced with warn logger",
|
||||
loggerLevel: LevelWarn,
|
||||
logrLevel: 0,
|
||||
message: "This should not appear",
|
||||
keysAndValues: []interface{}{},
|
||||
expectOutput: false,
|
||||
expectContains: []string{},
|
||||
},
|
||||
{
|
||||
name: "debug log v1 with debug logger",
|
||||
loggerLevel: LevelDebug,
|
||||
logrLevel: 1,
|
||||
message: "Detailed connection info",
|
||||
keysAndValues: []interface{}{"details", "some-value"},
|
||||
expectOutput: true,
|
||||
expectContains: []string{"[DEBUG]", "Detailed connection info", "details"},
|
||||
},
|
||||
{
|
||||
name: "debug log v1 silenced with info logger",
|
||||
loggerLevel: LevelInfo,
|
||||
logrLevel: 1,
|
||||
message: "This debug should not appear",
|
||||
keysAndValues: []interface{}{},
|
||||
expectOutput: false,
|
||||
expectContains: []string{},
|
||||
},
|
||||
{
|
||||
name: "info with odd number of kvs (incomplete pair)",
|
||||
loggerLevel: LevelInfo,
|
||||
logrLevel: 0,
|
||||
message: "Message with incomplete kv",
|
||||
keysAndValues: []interface{}{"key1", "value1", "key2"}, // key2 has no value
|
||||
expectOutput: true,
|
||||
expectContains: []string{"[INFO]", "Message with incomplete kv", "key1", "value1"},
|
||||
},
|
||||
{
|
||||
name: "info with source field added automatically",
|
||||
loggerLevel: LevelInfo,
|
||||
logrLevel: 0,
|
||||
message: "Test source field",
|
||||
keysAndValues: []interface{}{},
|
||||
expectOutput: true,
|
||||
expectContains: []string{"[INFO]", "Test source field", "source:k8s-client"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := New(tt.loggerLevel, FormatText, buf)
|
||||
sink := NewLogrAdapter(logger)
|
||||
logrLogger := logr.New(sink)
|
||||
|
||||
logrLogger.V(tt.logrLevel).Info(tt.message, tt.keysAndValues...)
|
||||
|
||||
output := buf.String()
|
||||
if tt.expectOutput {
|
||||
for _, expected := range tt.expectContains {
|
||||
assert.Contains(t, output, expected, "Output should contain: %s", expected)
|
||||
}
|
||||
} else {
|
||||
assert.Empty(t, output, "No output expected for this log level")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogrAdapter_Error(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
loggerLevel Level
|
||||
err error
|
||||
message string
|
||||
keysAndValues []interface{}
|
||||
expectOutput bool
|
||||
expectContains []string
|
||||
}{
|
||||
{
|
||||
name: "error with error object",
|
||||
loggerLevel: LevelError,
|
||||
err: errors.New("connection failed"),
|
||||
message: "Port forward failed",
|
||||
keysAndValues: []interface{}{"pod", "my-app-123"},
|
||||
expectOutput: true,
|
||||
expectContains: []string{"[ERROR]", "Port forward failed", "connection failed", "pod", "my-app-123"},
|
||||
},
|
||||
{
|
||||
name: "error without error object",
|
||||
loggerLevel: LevelError,
|
||||
err: nil,
|
||||
message: "Generic error message",
|
||||
keysAndValues: []interface{}{},
|
||||
expectOutput: true,
|
||||
expectContains: []string{"[ERROR]", "Generic error message"},
|
||||
},
|
||||
{
|
||||
name: "error silenced with level above error",
|
||||
loggerLevel: LevelError + 1,
|
||||
err: errors.New("should not appear"),
|
||||
message: "This error should not appear",
|
||||
keysAndValues: []interface{}{},
|
||||
expectOutput: false,
|
||||
expectContains: []string{},
|
||||
},
|
||||
{
|
||||
name: "error with multiple kvs",
|
||||
loggerLevel: LevelError,
|
||||
err: errors.New("sandbox not found"),
|
||||
message: "Unhandled Error",
|
||||
keysAndValues: []interface{}{"pod", "test-pod", "uid", "abc123", "port", 8080},
|
||||
expectOutput: true,
|
||||
expectContains: []string{"[ERROR]", "Unhandled Error", "sandbox not found", "pod", "test-pod", "uid", "abc123"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := New(tt.loggerLevel, FormatText, buf)
|
||||
sink := NewLogrAdapter(logger)
|
||||
logrLogger := logr.New(sink)
|
||||
|
||||
logrLogger.Error(tt.err, tt.message, tt.keysAndValues...)
|
||||
|
||||
output := buf.String()
|
||||
if tt.expectOutput {
|
||||
for _, expected := range tt.expectContains {
|
||||
assert.Contains(t, output, expected, "Output should contain: %s", expected)
|
||||
}
|
||||
} else {
|
||||
assert.Empty(t, output, "No output expected for this log level")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogrAdapter_WithName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
loggerNames []string
|
||||
message string
|
||||
expectContains string
|
||||
}{
|
||||
{
|
||||
name: "single logger name",
|
||||
loggerNames: []string{"portforward"},
|
||||
message: "Test message",
|
||||
expectContains: "logger:portforward",
|
||||
},
|
||||
{
|
||||
name: "nested logger names",
|
||||
loggerNames: []string{"controller", "worker", "healthcheck"},
|
||||
message: "Nested message",
|
||||
expectContains: "logger:controller.worker.healthcheck",
|
||||
},
|
||||
{
|
||||
name: "no logger name",
|
||||
loggerNames: []string{},
|
||||
message: "No name message",
|
||||
expectContains: "source:k8s-client", // Should still have source but no logger field
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := New(LevelInfo, FormatText, buf)
|
||||
sink := NewLogrAdapter(logger)
|
||||
logrLogger := logr.New(sink)
|
||||
|
||||
// Apply WithName calls
|
||||
for _, name := range tt.loggerNames {
|
||||
logrLogger = logrLogger.WithName(name)
|
||||
}
|
||||
|
||||
logrLogger.Info(tt.message)
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, tt.expectContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogrAdapter_Enabled(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
loggerLevel Level
|
||||
logrLevel int
|
||||
expectEnabled bool
|
||||
}{
|
||||
{
|
||||
name: "v0 enabled with debug logger",
|
||||
loggerLevel: LevelDebug,
|
||||
logrLevel: 0,
|
||||
expectEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "v0 enabled with info logger",
|
||||
loggerLevel: LevelInfo,
|
||||
logrLevel: 0,
|
||||
expectEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "v0 disabled with warn logger",
|
||||
loggerLevel: LevelWarn,
|
||||
logrLevel: 0,
|
||||
expectEnabled: false,
|
||||
},
|
||||
{
|
||||
name: "v1 enabled with debug logger",
|
||||
loggerLevel: LevelDebug,
|
||||
logrLevel: 1,
|
||||
expectEnabled: true,
|
||||
},
|
||||
{
|
||||
name: "v1 disabled with info logger",
|
||||
loggerLevel: LevelInfo,
|
||||
logrLevel: 1,
|
||||
expectEnabled: false,
|
||||
},
|
||||
{
|
||||
name: "v2 enabled with debug logger",
|
||||
loggerLevel: LevelDebug,
|
||||
logrLevel: 2,
|
||||
expectEnabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
logger := New(tt.loggerLevel, FormatText, &bytes.Buffer{})
|
||||
sink := NewLogrAdapter(logger)
|
||||
|
||||
enabled := sink.Enabled(tt.logrLevel)
|
||||
assert.Equal(t, tt.expectEnabled, enabled)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogrAdapter_JSONFormat(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger := New(LevelInfo, FormatJSON, buf)
|
||||
sink := NewLogrAdapter(logger)
|
||||
logrLogger := logr.New(sink).WithName("test-component")
|
||||
|
||||
logrLogger.Info("Test JSON message", "key1", "value1", "key2", 123)
|
||||
|
||||
// Parse JSON output
|
||||
var entry logEntry
|
||||
err := json.Unmarshal(buf.Bytes(), &entry)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "INFO", entry.Level)
|
||||
assert.Equal(t, "Test JSON message", entry.Message)
|
||||
assert.Equal(t, "k8s-client", entry.Fields["source"])
|
||||
assert.Equal(t, "test-component", entry.Fields["logger"])
|
||||
assert.Equal(t, "value1", entry.Fields["key1"])
|
||||
assert.Equal(t, float64(123), entry.Fields["key2"]) // JSON numbers decode as float64
|
||||
}
|
||||
|
||||
func TestLogrAdapter_ConcurrentWrites(t *testing.T) {
|
||||
// Note: bytes.Buffer is not thread-safe for writes, so this test verifies
|
||||
// that our LogrAdapter doesn't panic under concurrent load, but we don't
|
||||
// verify exact output (since logger uses fmt.Fprintf which is also not thread-safe)
|
||||
buf := &bytes.Buffer{}
|
||||
logger := New(LevelDebug, FormatText, buf)
|
||||
sink := NewLogrAdapter(logger)
|
||||
logrLogger := logr.New(sink)
|
||||
|
||||
// Spawn multiple goroutines writing concurrently
|
||||
done := make(chan bool)
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(id int) {
|
||||
for j := 0; j < 100; j++ {
|
||||
logrLogger.Info("Concurrent message", "goroutine", id, "iteration", j)
|
||||
}
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Verify we got substantial output (not checking exact count due to buffer race)
|
||||
// The main goal is to ensure no panics occur during concurrent writes
|
||||
assert.NotEmpty(t, output, "Should have some log output")
|
||||
assert.Contains(t, output, "Concurrent message")
|
||||
}
|
||||
|
||||
func TestLogrAdapter_RealWorldKlogError(t *testing.T) {
|
||||
// Simulate the exact error message from the screenshot
|
||||
buf := &bytes.Buffer{}
|
||||
logger := New(LevelError, FormatText, buf)
|
||||
sink := NewLogrAdapter(logger)
|
||||
logrLogger := logr.New(sink).WithName("UnhandledError")
|
||||
|
||||
err := errors.New("an error occurred forwarding 8401 -> 8401: error forwarding port 8401 to pod 4e1e861c28e3b25a88b082e79788169b5d8a7a117904b7bb8c7cd59285cf1d308, uid : failed to find sandbox '4e1e861c28e3b25a88b082e79788169b5d8a7a117904b7bb8c7cd59285cf1d308' in store: not found")
|
||||
logrLogger.Error(err, "Unhandled Error")
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "[ERROR]")
|
||||
assert.Contains(t, output, "Unhandled Error")
|
||||
assert.Contains(t, output, "failed to find sandbox")
|
||||
assert.Contains(t, output, "logger:UnhandledError")
|
||||
}
|
||||
|
||||
func TestLogrAdapter_SilenceMode(t *testing.T) {
|
||||
// Test that logs are completely silenced when logger level is above error
|
||||
buf := &bytes.Buffer{}
|
||||
logger := New(LevelError+1, FormatText, buf)
|
||||
sink := NewLogrAdapter(logger)
|
||||
logrLogger := logr.New(sink)
|
||||
|
||||
// Try all log levels
|
||||
logrLogger.V(0).Info("Info message should not appear")
|
||||
logrLogger.V(1).Info("Debug message should not appear")
|
||||
logrLogger.Error(errors.New("error object"), "Error message should not appear")
|
||||
|
||||
output := buf.String()
|
||||
assert.Empty(t, output, "All logs should be silenced")
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -28,6 +29,7 @@ type Logger struct {
|
||||
level Level
|
||||
format Format
|
||||
output io.Writer
|
||||
mu sync.Mutex // Protects concurrent writes to output
|
||||
}
|
||||
|
||||
type logEntry struct {
|
||||
@@ -55,6 +57,9 @@ func (l *Logger) log(level Level, msg string, fields map[string]interface{}) {
|
||||
|
||||
levelStr := levelToString(level)
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if l.format == FormatJSON {
|
||||
entry := logEntry{
|
||||
Time: time.Now().Format(time.RFC3339),
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package mdns
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/grandcat/zeroconf"
|
||||
"github.com/nvm/kportal/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// shutdownTimeout is the maximum time to wait for mDNS server shutdown
|
||||
shutdownTimeout = 2 * time.Second
|
||||
|
||||
// mdnsDomain is the standard mDNS domain (RFC 6762)
|
||||
// This is always ".local" for multicast DNS - it's not configurable
|
||||
// and is different from your network's DNS search domain
|
||||
mdnsDomain = "local"
|
||||
)
|
||||
|
||||
// Publisher manages mDNS hostname registrations for port forwards.
|
||||
// It allows forwards with aliases to be accessible via <alias>.local hostnames.
|
||||
type Publisher struct {
|
||||
mu sync.RWMutex
|
||||
servers map[string]*zeroconf.Server // forwardID -> server
|
||||
aliases map[string]string // forwardID -> alias (for logging)
|
||||
enabled bool
|
||||
localIPs []string
|
||||
}
|
||||
|
||||
// NewPublisher creates a new mDNS Publisher.
|
||||
// If enabled is false, all registration calls will be no-ops.
|
||||
func NewPublisher(enabled bool) *Publisher {
|
||||
p := &Publisher{
|
||||
servers: make(map[string]*zeroconf.Server),
|
||||
aliases: make(map[string]string),
|
||||
enabled: enabled,
|
||||
localIPs: getLocalIPs(),
|
||||
}
|
||||
|
||||
if enabled {
|
||||
logger.Info("mDNS publisher initialized", map[string]interface{}{
|
||||
"domain": mdnsDomain,
|
||||
"local_ips": p.localIPs,
|
||||
})
|
||||
}
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
// Register publishes an mDNS hostname for a forward.
|
||||
// The hostname will be <alias>.local and will resolve to 127.0.0.1.
|
||||
// If the forward has no alias or mDNS is disabled, this is a no-op.
|
||||
func (p *Publisher) Register(forwardID, alias string, localPort int) error {
|
||||
if !p.enabled || alias == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Check if already registered
|
||||
if _, exists := p.servers[forwardID]; exists {
|
||||
logger.Debug("mDNS hostname already registered", map[string]interface{}{
|
||||
"forward_id": forwardID,
|
||||
"alias": alias,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Register the mDNS service
|
||||
// We use a generic service type and rely on the hostname registration
|
||||
server, err := zeroconf.RegisterProxy(
|
||||
alias, // Instance name (shown in service discovery)
|
||||
"_kportal._tcp", // Service type (custom for kportal)
|
||||
"local.", // Domain
|
||||
localPort, // Port
|
||||
alias, // Hostname (will be <alias>.local)
|
||||
[]string{"127.0.0.1"}, // IPs to resolve to
|
||||
[]string{fmt.Sprintf("forward=%s", forwardID)}, // TXT records
|
||||
nil, // interfaces (nil = all)
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register mDNS for %s: %w", alias, err)
|
||||
}
|
||||
|
||||
p.servers[forwardID] = server
|
||||
p.aliases[forwardID] = alias
|
||||
|
||||
logger.Info("mDNS hostname registered", map[string]interface{}{
|
||||
"forward_id": forwardID,
|
||||
"hostname": GetHostname(alias),
|
||||
"port": localPort,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unregister removes the mDNS hostname for a forward.
|
||||
func (p *Publisher) Unregister(forwardID string) {
|
||||
if !p.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
server, exists := p.servers[forwardID]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
alias := p.aliases[forwardID]
|
||||
shutdownWithTimeout(server, forwardID)
|
||||
delete(p.servers, forwardID)
|
||||
delete(p.aliases, forwardID)
|
||||
|
||||
logger.Info("mDNS hostname unregistered", map[string]interface{}{
|
||||
"forward_id": forwardID,
|
||||
"hostname": GetHostname(alias),
|
||||
})
|
||||
}
|
||||
|
||||
// Stop shuts down all mDNS registrations.
|
||||
func (p *Publisher) Stop() {
|
||||
if !p.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Shutdown all servers concurrently with timeout
|
||||
var wg sync.WaitGroup
|
||||
for forwardID, server := range p.servers {
|
||||
wg.Add(1)
|
||||
go func(id string, srv *zeroconf.Server) {
|
||||
defer wg.Done()
|
||||
shutdownWithTimeout(srv, id)
|
||||
}(forwardID, server)
|
||||
}
|
||||
|
||||
// Wait for all shutdowns to complete (or timeout)
|
||||
wg.Wait()
|
||||
|
||||
p.servers = make(map[string]*zeroconf.Server)
|
||||
p.aliases = make(map[string]string)
|
||||
|
||||
logger.Info("mDNS publisher stopped", nil)
|
||||
}
|
||||
|
||||
// shutdownSettleTime is a small delay after zeroconf shutdown to allow internal
|
||||
// goroutines to exit cleanly. This works around a race condition in the
|
||||
// grandcat/zeroconf library where recv4() can access ipv4conn after shutdown()
|
||||
// sets it to nil. See: https://github.com/grandcat/zeroconf/issues/95
|
||||
// Note: 100ms is needed for CI environments where timing can be more variable.
|
||||
const shutdownSettleTime = 100 * time.Millisecond
|
||||
|
||||
// shutdownWithTimeout attempts to shutdown a zeroconf server with a timeout.
|
||||
// If shutdown hangs, it logs a warning and returns anyway.
|
||||
func shutdownWithTimeout(server *zeroconf.Server, forwardID string) {
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
server.Shutdown()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Shutdown completed successfully
|
||||
// Add a small settle time to let internal goroutines exit cleanly.
|
||||
// This works around a race condition in zeroconf where recv4() can
|
||||
// access ipv4conn after shutdown() sets it to nil.
|
||||
time.Sleep(shutdownSettleTime)
|
||||
case <-time.After(shutdownTimeout):
|
||||
logger.Warn("mDNS shutdown timed out, continuing anyway", map[string]interface{}{
|
||||
"forward_id": forwardID,
|
||||
"timeout": shutdownTimeout.String(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// IsEnabled returns whether mDNS publishing is enabled.
|
||||
func (p *Publisher) IsEnabled() bool {
|
||||
return p.enabled
|
||||
}
|
||||
|
||||
// GetDomain returns the mDNS domain being used (always "local" per RFC 6762).
|
||||
func (p *Publisher) GetDomain() string {
|
||||
return mdnsDomain
|
||||
}
|
||||
|
||||
// GetHostname returns the full mDNS hostname for an alias.
|
||||
// Example: GetHostname("myapp") returns "myapp.local"
|
||||
func GetHostname(alias string) string {
|
||||
return alias + "." + mdnsDomain
|
||||
}
|
||||
|
||||
// GetRegisteredCount returns the number of currently registered hostnames.
|
||||
func (p *Publisher) GetRegisteredCount() int {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return len(p.servers)
|
||||
}
|
||||
|
||||
// getLocalIPs returns the local IP addresses for logging purposes.
|
||||
func getLocalIPs() []string {
|
||||
var ips []string
|
||||
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return []string{"127.0.0.1"}
|
||||
}
|
||||
|
||||
for _, addr := range addrs {
|
||||
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||
if ipnet.IP.To4() != nil {
|
||||
ips = append(ips, ipnet.IP.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(ips) == 0 {
|
||||
return []string{"127.0.0.1"}
|
||||
}
|
||||
|
||||
return ips
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package mdns
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Note: Tests that actually register mDNS services require network I/O
|
||||
// and can be slow or hang in CI environments. We test the logic paths
|
||||
// without actually calling zeroconf for most tests.
|
||||
|
||||
func TestNewPublisher_Disabled(t *testing.T) {
|
||||
p := NewPublisher(false)
|
||||
|
||||
assert.False(t, p.IsEnabled())
|
||||
assert.Equal(t, 0, p.GetRegisteredCount())
|
||||
}
|
||||
|
||||
func TestNewPublisher_Enabled(t *testing.T) {
|
||||
p := NewPublisher(true)
|
||||
|
||||
assert.True(t, p.IsEnabled())
|
||||
assert.Equal(t, 0, p.GetRegisteredCount())
|
||||
}
|
||||
|
||||
func TestRegister_WhenDisabled_NoOp(t *testing.T) {
|
||||
p := NewPublisher(false)
|
||||
|
||||
err := p.Register("forward-1", "test-alias", 8080)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, p.GetRegisteredCount())
|
||||
}
|
||||
|
||||
func TestRegister_EmptyAlias_NoOp(t *testing.T) {
|
||||
p := NewPublisher(true)
|
||||
|
||||
err := p.Register("forward-1", "", 8080)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, p.GetRegisteredCount())
|
||||
}
|
||||
|
||||
func TestUnregister_WhenDisabled_NoOp(t *testing.T) {
|
||||
p := NewPublisher(false)
|
||||
|
||||
// Should not panic
|
||||
p.Unregister("forward-1")
|
||||
}
|
||||
|
||||
func TestUnregister_NotRegistered_NoOp(t *testing.T) {
|
||||
p := NewPublisher(true)
|
||||
|
||||
// Should not panic
|
||||
p.Unregister("non-existent")
|
||||
assert.Equal(t, 0, p.GetRegisteredCount())
|
||||
}
|
||||
|
||||
func TestStop_WhenDisabled_NoOp(t *testing.T) {
|
||||
p := NewPublisher(false)
|
||||
|
||||
// Should not panic
|
||||
p.Stop()
|
||||
}
|
||||
|
||||
func TestStop_WhenNoRegistrations(t *testing.T) {
|
||||
p := NewPublisher(true)
|
||||
|
||||
// Should not panic
|
||||
p.Stop()
|
||||
assert.Equal(t, 0, p.GetRegisteredCount())
|
||||
}
|
||||
|
||||
func TestGetLocalIPs(t *testing.T) {
|
||||
ips := getLocalIPs()
|
||||
|
||||
// Should return at least one IP
|
||||
assert.NotEmpty(t, ips, "getLocalIPs should return at least one IP")
|
||||
|
||||
// All IPs should be non-empty strings
|
||||
for _, ip := range ips {
|
||||
assert.NotEmpty(t, ip, "IP address should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
// Integration tests - only run when explicitly requested
|
||||
// These tests actually register mDNS services and require network access
|
||||
|
||||
func TestRegister_Integration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping mDNS integration test in short mode")
|
||||
}
|
||||
|
||||
p := NewPublisher(true)
|
||||
defer p.Stop()
|
||||
|
||||
err := p.Register("forward-1", "test-service", 8080)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, p.GetRegisteredCount())
|
||||
}
|
||||
|
||||
func TestRegister_Duplicate_Idempotent_Integration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping mDNS integration test in short mode")
|
||||
}
|
||||
|
||||
p := NewPublisher(true)
|
||||
defer p.Stop()
|
||||
|
||||
// First registration
|
||||
err := p.Register("forward-1", "test-service", 8080)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, p.GetRegisteredCount())
|
||||
|
||||
// Second registration with same ID should be idempotent
|
||||
err = p.Register("forward-1", "test-service", 8080)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, p.GetRegisteredCount())
|
||||
}
|
||||
|
||||
func TestRegister_MultipleForwards_Integration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping mDNS integration test in short mode")
|
||||
}
|
||||
|
||||
p := NewPublisher(true)
|
||||
defer p.Stop()
|
||||
|
||||
err1 := p.Register("forward-1", "service-a", 8080)
|
||||
err2 := p.Register("forward-2", "service-b", 8081)
|
||||
err3 := p.Register("forward-3", "service-c", 8082)
|
||||
|
||||
assert.NoError(t, err1)
|
||||
assert.NoError(t, err2)
|
||||
assert.NoError(t, err3)
|
||||
assert.Equal(t, 3, p.GetRegisteredCount())
|
||||
}
|
||||
|
||||
func TestUnregister_Success_Integration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping mDNS integration test in short mode")
|
||||
}
|
||||
|
||||
p := NewPublisher(true)
|
||||
defer p.Stop()
|
||||
|
||||
p.Register("forward-1", "test-service", 8080)
|
||||
assert.Equal(t, 1, p.GetRegisteredCount())
|
||||
|
||||
p.Unregister("forward-1")
|
||||
assert.Equal(t, 0, p.GetRegisteredCount())
|
||||
}
|
||||
@@ -67,8 +67,3 @@ func (b *Backoff) calculateJitter(delay time.Duration) time.Duration {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -158,10 +158,12 @@ func TestBackoff_ExponentialProgression(t *testing.T) {
|
||||
// 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
|
||||
// With 10% jitter on each value:
|
||||
// Lower bound: (2.0 * 0.9) / 1.1 ≈ 1.636
|
||||
// Upper bound: (2.0 * 1.1) / 0.9 ≈ 2.444
|
||||
// We use 1.6x to 2.5x as a reasonable range to account for jitter variance
|
||||
ratio := float64(delays[i]) / float64(delays[i-1])
|
||||
assert.GreaterOrEqual(t, ratio, 1.7, "exponential growth should be ~2x")
|
||||
assert.GreaterOrEqual(t, ratio, 1.6, "exponential growth should be ~2x")
|
||||
assert.LessOrEqual(t, ratio, 2.5, "exponential growth should be ~2x")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,11 @@ type BubbleTeaUI struct {
|
||||
version string
|
||||
errors map[string]string // Track error messages by forward ID
|
||||
|
||||
// Update notification
|
||||
updateAvailable bool
|
||||
updateVersion string
|
||||
updateURL string
|
||||
|
||||
// Modal wizard state
|
||||
viewMode ViewMode
|
||||
addWizard *AddWizardState
|
||||
@@ -96,6 +101,16 @@ func (ui *BubbleTeaUI) SetWizardDependencies(discovery *k8s.Discovery, mutator *
|
||||
ui.configPath = configPath
|
||||
}
|
||||
|
||||
// SetUpdateAvailable sets the update notification to be displayed
|
||||
func (ui *BubbleTeaUI) SetUpdateAvailable(version, url string) {
|
||||
ui.mu.Lock()
|
||||
defer ui.mu.Unlock()
|
||||
|
||||
ui.updateAvailable = true
|
||||
ui.updateVersion = version
|
||||
ui.updateURL = url
|
||||
}
|
||||
|
||||
// Start starts the bubbletea application
|
||||
func (ui *BubbleTeaUI) Start() error {
|
||||
m := model{ui: ui}
|
||||
@@ -169,8 +184,9 @@ func (ui *BubbleTeaUI) UpdateStatus(id string, status string) {
|
||||
if fwd, ok := ui.forwards[id]; ok {
|
||||
fwd.Status = status
|
||||
}
|
||||
// Clear error if status is not Error
|
||||
if status != "Error" {
|
||||
// Only clear error when forward becomes Active again
|
||||
// This keeps error visible during Reconnecting/Starting states
|
||||
if status == "Active" {
|
||||
delete(ui.errors, id)
|
||||
}
|
||||
ui.mu.Unlock()
|
||||
@@ -266,7 +282,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.ui.addWizard = nil
|
||||
m.ui.removeWizard = nil
|
||||
m.ui.mu.Unlock()
|
||||
return m, nil
|
||||
return m, tea.ClearScreen
|
||||
}
|
||||
|
||||
return m, nil
|
||||
@@ -356,6 +372,15 @@ func (m model) renderMainView() string {
|
||||
// Title with version
|
||||
title := fmt.Sprintf("kportal v%s - Port Forwarding Status", m.ui.version)
|
||||
b.WriteString(titleStyle.Render(title))
|
||||
|
||||
// Show update notification if available
|
||||
if m.ui.updateAvailable {
|
||||
updateStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("42")). // Green
|
||||
Bold(true)
|
||||
updateMsg := fmt.Sprintf(" Update available: v%s", m.ui.updateVersion)
|
||||
b.WriteString(updateStyle.Render(updateMsg))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Header
|
||||
@@ -378,7 +403,7 @@ func (m model) renderMainView() string {
|
||||
}
|
||||
|
||||
isSelected := (idx == m.ui.selectedIndex)
|
||||
isDisabled := m.ui.disabledMap[id]
|
||||
isDisabled := m.ui.disabledMap[id] || fwd.Status == "Disabled"
|
||||
|
||||
// Selection indicator
|
||||
indicator := " "
|
||||
@@ -574,6 +599,15 @@ func (ui *BubbleTeaUI) moveSelection(delta int) {
|
||||
}
|
||||
}
|
||||
|
||||
// resetDeleteConfirmation resets the delete confirmation dialog state.
|
||||
// Caller must hold ui.mu lock.
|
||||
func (ui *BubbleTeaUI) resetDeleteConfirmation() {
|
||||
ui.deleteConfirming = false
|
||||
ui.deleteConfirmID = ""
|
||||
ui.deleteConfirmAlias = ""
|
||||
ui.deleteConfirmCursor = 0
|
||||
}
|
||||
|
||||
// renderDeleteConfirmation renders the delete confirmation dialog
|
||||
func (m model) renderDeleteConfirmation() string {
|
||||
m.ui.mu.RLock()
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// InteractiveController handles keyboard input and selection state
|
||||
type InteractiveController struct {
|
||||
mu sync.RWMutex
|
||||
selectedIndex int
|
||||
forwardIDs []string // Ordered list of forward IDs
|
||||
disabledMap map[string]bool // Tracks which forwards are disabled
|
||||
toggleCallback func(id string, enable bool)
|
||||
enabled bool
|
||||
oldTermState *term.State
|
||||
}
|
||||
|
||||
// NewInteractiveController creates a new interactive controller
|
||||
func NewInteractiveController(toggleCallback func(id string, enable bool)) *InteractiveController {
|
||||
return &InteractiveController{
|
||||
selectedIndex: 0,
|
||||
forwardIDs: make([]string, 0),
|
||||
disabledMap: make(map[string]bool),
|
||||
toggleCallback: toggleCallback,
|
||||
enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Enable puts the terminal in raw mode for keyboard input
|
||||
func (ic *InteractiveController) Enable() error {
|
||||
ic.mu.Lock()
|
||||
defer ic.mu.Unlock()
|
||||
|
||||
if ic.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save current terminal state
|
||||
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to enable raw mode: %w", err)
|
||||
}
|
||||
|
||||
ic.oldTermState = oldState
|
||||
ic.enabled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disable restores the terminal to normal mode
|
||||
func (ic *InteractiveController) Disable() error {
|
||||
ic.mu.Lock()
|
||||
defer ic.mu.Unlock()
|
||||
|
||||
if !ic.enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
if ic.oldTermState != nil {
|
||||
if err := term.Restore(int(os.Stdin.Fd()), ic.oldTermState); err != nil {
|
||||
return fmt.Errorf("failed to restore terminal: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
ic.enabled = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateForwardsList updates the list of forwards for navigation
|
||||
func (ic *InteractiveController) UpdateForwardsList(ids []string) {
|
||||
ic.mu.Lock()
|
||||
defer ic.mu.Unlock()
|
||||
|
||||
ic.forwardIDs = ids
|
||||
|
||||
// Ensure selected index is valid
|
||||
if ic.selectedIndex >= len(ic.forwardIDs) {
|
||||
ic.selectedIndex = len(ic.forwardIDs) - 1
|
||||
}
|
||||
if ic.selectedIndex < 0 && len(ic.forwardIDs) > 0 {
|
||||
ic.selectedIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
// MoveUp moves selection up
|
||||
func (ic *InteractiveController) MoveUp() {
|
||||
ic.mu.Lock()
|
||||
defer ic.mu.Unlock()
|
||||
|
||||
if ic.selectedIndex > 0 {
|
||||
ic.selectedIndex--
|
||||
}
|
||||
}
|
||||
|
||||
// MoveDown moves selection down
|
||||
func (ic *InteractiveController) MoveDown() {
|
||||
ic.mu.Lock()
|
||||
defer ic.mu.Unlock()
|
||||
|
||||
if ic.selectedIndex < len(ic.forwardIDs)-1 {
|
||||
ic.selectedIndex++
|
||||
}
|
||||
}
|
||||
|
||||
// ToggleSelected toggles the enable/disable state of the selected forward
|
||||
func (ic *InteractiveController) ToggleSelected() {
|
||||
ic.mu.Lock()
|
||||
if ic.selectedIndex < 0 || ic.selectedIndex >= len(ic.forwardIDs) {
|
||||
ic.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
selectedID := ic.forwardIDs[ic.selectedIndex]
|
||||
currentlyDisabled := ic.disabledMap[selectedID]
|
||||
newState := !currentlyDisabled
|
||||
ic.disabledMap[selectedID] = newState
|
||||
ic.mu.Unlock()
|
||||
|
||||
// Call the toggle callback
|
||||
if ic.toggleCallback != nil {
|
||||
ic.toggleCallback(selectedID, !newState) // enable is inverse of disabled
|
||||
}
|
||||
}
|
||||
|
||||
// GetSelectedIndex returns the current selection index
|
||||
func (ic *InteractiveController) GetSelectedIndex() int {
|
||||
ic.mu.RLock()
|
||||
defer ic.mu.RUnlock()
|
||||
return ic.selectedIndex
|
||||
}
|
||||
|
||||
// IsDisabled returns whether a forward is disabled
|
||||
func (ic *InteractiveController) IsDisabled(id string) bool {
|
||||
ic.mu.RLock()
|
||||
defer ic.mu.RUnlock()
|
||||
return ic.disabledMap[id]
|
||||
}
|
||||
|
||||
// GetSelectedID returns the ID of the currently selected forward
|
||||
func (ic *InteractiveController) GetSelectedID() string {
|
||||
ic.mu.RLock()
|
||||
defer ic.mu.RUnlock()
|
||||
|
||||
if ic.selectedIndex < 0 || ic.selectedIndex >= len(ic.forwardIDs) {
|
||||
return ""
|
||||
}
|
||||
return ic.forwardIDs[ic.selectedIndex]
|
||||
}
|
||||
|
||||
// HandleKey processes keyboard input and returns true if should continue
|
||||
func (ic *InteractiveController) HandleKey(b []byte) bool {
|
||||
if len(b) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle single byte keys
|
||||
if len(b) == 1 {
|
||||
switch b[0] {
|
||||
case 'q', 'Q', 3: // q, Q, or Ctrl+C
|
||||
return false
|
||||
case ' ', '\r': // Space or Enter to toggle
|
||||
ic.ToggleSelected()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Handle escape sequences (arrow keys)
|
||||
if len(b) == 3 && b[0] == 27 && b[1] == 91 {
|
||||
switch b[2] {
|
||||
case 65: // Up arrow
|
||||
ic.MoveUp()
|
||||
case 66: // Down arrow
|
||||
ic.MoveDown()
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
+7
-48
@@ -23,10 +23,9 @@ type ForwardStatus struct {
|
||||
|
||||
// TableUI manages the terminal table display
|
||||
type TableUI struct {
|
||||
mu sync.RWMutex
|
||||
forwards map[string]*ForwardStatus // key is forward ID
|
||||
verbose bool
|
||||
interactive *InteractiveController
|
||||
mu sync.RWMutex
|
||||
forwards map[string]*ForwardStatus // key is forward ID
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewTableUI creates a new table UI manager
|
||||
@@ -37,13 +36,6 @@ func NewTableUI(verbose bool) *TableUI {
|
||||
}
|
||||
}
|
||||
|
||||
// SetInteractiveController sets the interactive controller
|
||||
func (t *TableUI) SetInteractiveController(ic *InteractiveController) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.interactive = ic
|
||||
}
|
||||
|
||||
// AddForward registers a new forward for display
|
||||
func (t *TableUI) AddForward(id string, fwd *config.Forward) {
|
||||
t.mu.Lock()
|
||||
@@ -126,27 +118,10 @@ func (t *TableUI) Render() {
|
||||
}
|
||||
}
|
||||
|
||||
// Update interactive controller with current forward IDs (in display order)
|
||||
if t.interactive != nil {
|
||||
ids := make([]string, len(entries))
|
||||
for i, entry := range entries {
|
||||
ids[i] = entry.id
|
||||
}
|
||||
t.interactive.UpdateForwardsList(ids)
|
||||
}
|
||||
|
||||
// Print each forward
|
||||
for i, entry := range entries {
|
||||
for _, entry := range entries {
|
||||
fwd := entry.fwd
|
||||
|
||||
// Check if this row is selected
|
||||
isSelected := false
|
||||
isDisabled := false
|
||||
if t.interactive != nil {
|
||||
isSelected = (i == t.interactive.GetSelectedIndex())
|
||||
isDisabled = t.interactive.IsDisabled(entry.id)
|
||||
}
|
||||
|
||||
// Truncate long names
|
||||
alias := truncate(fwd.Alias, 25)
|
||||
resource := truncate(fwd.Resource, 25)
|
||||
@@ -154,8 +129,8 @@ func (t *TableUI) Render() {
|
||||
// Color code status with indicator
|
||||
statusStr := formatStatusWithIndicator(fwd.Status)
|
||||
|
||||
// Build the row content
|
||||
rowContent := fmt.Sprintf(" %-15s %-18s %-25s %-10s %-25s %-12d %-12d %s",
|
||||
// Print the row
|
||||
fmt.Printf(" %-15s %-18s %-25s %-10s %-25s %-12d %-12d %s\n",
|
||||
fwd.Context,
|
||||
fwd.Namespace,
|
||||
alias,
|
||||
@@ -164,26 +139,10 @@ func (t *TableUI) Render() {
|
||||
fwd.RemotePort,
|
||||
fwd.LocalPort,
|
||||
statusStr)
|
||||
|
||||
// Apply selection highlighting or disabled styling
|
||||
if isSelected {
|
||||
// Replace leading spaces with arrow, then apply reverse video to entire line
|
||||
rowContent = "\033[7m> " + rowContent[2:] + "\033[0m"
|
||||
} else if isDisabled {
|
||||
// Apply dimmed styling to entire line
|
||||
rowContent = "\033[2m" + rowContent + "\033[0m"
|
||||
}
|
||||
|
||||
fmt.Println(rowContent)
|
||||
}
|
||||
|
||||
fmt.Println(strings.Repeat("=", 130))
|
||||
helpText := "Total forwards: %d | ↑↓: Navigate | Space: Toggle | q: Quit"
|
||||
if !t.verbose {
|
||||
fmt.Printf(helpText+"\n", len(t.forwards))
|
||||
} else {
|
||||
fmt.Printf("Total forwards: %d | Press Ctrl+C to stop\n", len(t.forwards))
|
||||
}
|
||||
fmt.Printf("Total forwards: %d | Press Ctrl+C to stop\n", len(t.forwards))
|
||||
|
||||
// In verbose mode, add a newline to separate from logs
|
||||
if t.verbose {
|
||||
|
||||
@@ -144,8 +144,25 @@ func validateSelectorCmd(discovery *k8s.Discovery, contextName, namespace, selec
|
||||
}
|
||||
|
||||
// checkPortCmd checks if a local port is available
|
||||
func checkPortCmd(port int) tea.Cmd {
|
||||
func checkPortCmd(port int, configPath string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// First check if port is already in the configuration
|
||||
cfg, err := config.LoadConfig(configPath)
|
||||
if err == nil {
|
||||
// Check all forwards in config for this port
|
||||
allForwards := cfg.GetAllForwards()
|
||||
for _, fwd := range allForwards {
|
||||
if fwd.LocalPort == port {
|
||||
return PortCheckedMsg{
|
||||
port: port,
|
||||
available: false,
|
||||
message: fmt.Sprintf("✗ Port %d already assigned to %s", port, fwd.ID()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then check if port is available at OS level
|
||||
available, processInfo, err := k8s.CheckPortAvailability(port)
|
||||
|
||||
msg := ""
|
||||
|
||||
+115
-52
@@ -10,6 +10,19 @@ import (
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
)
|
||||
|
||||
// isFilterableStep returns true if the step supports search/filter
|
||||
func isFilterableStep(step AddWizardStep) bool {
|
||||
switch step {
|
||||
case StepSelectContext, StepSelectNamespace:
|
||||
return true
|
||||
case StepEnterResource:
|
||||
// Only service selection is filterable (pod prefix and selector are text input)
|
||||
return true // We'll check resource type in the handler
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// handleMainViewKeys handles keyboard input in the main view
|
||||
func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// If delete confirmation is showing, handle it separately
|
||||
@@ -160,12 +173,8 @@ func (m model) handleDeleteConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
// Cancel deletion
|
||||
m.ui.deleteConfirming = false
|
||||
m.ui.deleteConfirmID = ""
|
||||
m.ui.deleteConfirmAlias = ""
|
||||
m.ui.deleteConfirmCursor = 0 // Reset cursor
|
||||
m.ui.resetDeleteConfirmation()
|
||||
m.ui.mu.Unlock()
|
||||
// Force a repaint by returning the model
|
||||
return m, tea.ClearScreen
|
||||
|
||||
case "left", "h", "right", "l":
|
||||
@@ -178,26 +187,18 @@ func (m model) handleDeleteConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
// Confirm deletion (either Enter on Yes or pressing 'y')
|
||||
if m.ui.deleteConfirmCursor == 0 || msg.String() == "y" {
|
||||
id := m.ui.deleteConfirmID
|
||||
m.ui.deleteConfirming = false
|
||||
m.ui.deleteConfirmID = ""
|
||||
m.ui.deleteConfirmAlias = ""
|
||||
m.ui.resetDeleteConfirmation()
|
||||
m.ui.mu.Unlock()
|
||||
return m, removeForwardByIDCmd(m.ui.mutator, id)
|
||||
}
|
||||
// Enter on No = cancel
|
||||
m.ui.deleteConfirming = false
|
||||
m.ui.deleteConfirmID = ""
|
||||
m.ui.deleteConfirmAlias = ""
|
||||
m.ui.deleteConfirmCursor = 0 // Reset cursor
|
||||
m.ui.resetDeleteConfirmation()
|
||||
m.ui.mu.Unlock()
|
||||
return m, tea.ClearScreen
|
||||
|
||||
case "n":
|
||||
// Quick 'n' for no
|
||||
m.ui.deleteConfirming = false
|
||||
m.ui.deleteConfirmID = ""
|
||||
m.ui.deleteConfirmAlias = ""
|
||||
m.ui.deleteConfirmCursor = 0 // Reset cursor
|
||||
m.ui.resetDeleteConfirmation()
|
||||
m.ui.mu.Unlock()
|
||||
return m, tea.ClearScreen
|
||||
}
|
||||
@@ -224,6 +225,12 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.ClearScreen
|
||||
|
||||
case "esc":
|
||||
// If there's an active search filter, clear it instead of going back
|
||||
if wizard.searchFilter != "" && isFilterableStep(wizard.step) {
|
||||
wizard.clearSearchFilter()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// In edit mode, Esc always cancels (don't navigate back through skipped steps)
|
||||
if wizard.isEditing {
|
||||
m.ui.viewMode = ViewModeMain
|
||||
@@ -240,9 +247,7 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
} else {
|
||||
// Go back one step
|
||||
wizard.step--
|
||||
wizard.cursor = 0
|
||||
wizard.clearTextInput()
|
||||
wizard.error = nil
|
||||
wizard.resetInput()
|
||||
|
||||
// Reset input mode based on the step we're going back to
|
||||
switch wizard.step {
|
||||
@@ -300,26 +305,48 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m.handleAddWizardEnter()
|
||||
|
||||
case "backspace":
|
||||
// Allow backspace in text input mode OR when focused on alias in confirmation
|
||||
// Allow backspace in text input mode OR when focused on alias in confirmation OR when filtering
|
||||
canBackspace := wizard.inputMode == InputModeText ||
|
||||
(wizard.step == StepConfirmation && wizard.confirmationFocus == FocusAlias)
|
||||
if canBackspace && len(wizard.textInput) > 0 {
|
||||
wizard.textInput = wizard.textInput[:len(wizard.textInput)-1]
|
||||
(wizard.step == StepConfirmation && wizard.confirmationFocus == FocusAlias) ||
|
||||
(wizard.inputMode == InputModeList && isFilterableStep(wizard.step) && len(wizard.searchFilter) > 0)
|
||||
|
||||
if canBackspace {
|
||||
if isFilterableStep(wizard.step) && wizard.inputMode == InputModeList && len(wizard.searchFilter) > 0 {
|
||||
// Backspace in search filter
|
||||
wizard.searchFilter = wizard.searchFilter[:len(wizard.searchFilter)-1]
|
||||
wizard.cursor = 0
|
||||
wizard.scrollOffset = 0
|
||||
} else if len(wizard.textInput) > 0 {
|
||||
wizard.textInput = wizard.textInput[:len(wizard.textInput)-1]
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
// Handle text input
|
||||
canTypeText := wizard.inputMode == InputModeText ||
|
||||
(wizard.step == StepConfirmation && wizard.confirmationFocus == FocusAlias)
|
||||
if canTypeText && len(msg.String()) == 1 {
|
||||
wizard.handleTextInput(rune(msg.String()[0]))
|
||||
(wizard.step == StepConfirmation && wizard.confirmationFocus == FocusAlias) ||
|
||||
(wizard.inputMode == InputModeList && isFilterableStep(wizard.step))
|
||||
|
||||
// Trigger validation for selector
|
||||
if wizard.step == StepEnterResource && wizard.selectedResourceType == ResourceTypePodSelector {
|
||||
if len(wizard.textInput) > 0 {
|
||||
wizard.loading = true
|
||||
wizard.error = nil
|
||||
return m, validateSelectorCmd(m.ui.discovery, wizard.selectedContext, wizard.selectedNamespace, wizard.textInput)
|
||||
if canTypeText && len(msg.String()) == 1 {
|
||||
// If in list mode on filterable step, add to search filter instead of textInput
|
||||
if wizard.inputMode == InputModeList && isFilterableStep(wizard.step) {
|
||||
char := rune(msg.String()[0])
|
||||
// Only allow printable characters
|
||||
if char >= 32 && char < 127 {
|
||||
wizard.searchFilter += string(char)
|
||||
wizard.cursor = 0
|
||||
wizard.scrollOffset = 0
|
||||
}
|
||||
} else {
|
||||
wizard.handleTextInput(rune(msg.String()[0]))
|
||||
|
||||
// Trigger validation for selector
|
||||
if wizard.step == StepEnterResource && wizard.selectedResourceType == ResourceTypePodSelector {
|
||||
if len(wizard.textInput) > 0 {
|
||||
wizard.loading = true
|
||||
wizard.error = nil
|
||||
return m, validateSelectorCmd(m.ui.discovery, wizard.selectedContext, wizard.selectedNamespace, wizard.textInput)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -332,21 +359,30 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
||||
wizard := m.ui.addWizard
|
||||
|
||||
// Don't process Enter if we're currently loading
|
||||
if wizard.loading {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch wizard.step {
|
||||
case StepSelectContext:
|
||||
if wizard.cursor >= 0 && wizard.cursor < len(wizard.contexts) {
|
||||
wizard.selectedContext = wizard.contexts[wizard.cursor]
|
||||
filteredContexts := wizard.getFilteredContexts()
|
||||
if wizard.cursor >= 0 && wizard.cursor < len(filteredContexts) {
|
||||
wizard.selectedContext = filteredContexts[wizard.cursor]
|
||||
wizard.step = StepSelectNamespace
|
||||
wizard.cursor = 0
|
||||
wizard.clearSearchFilter()
|
||||
wizard.loading = true
|
||||
return m, loadNamespacesCmd(m.ui.discovery, wizard.selectedContext)
|
||||
}
|
||||
|
||||
case StepSelectNamespace:
|
||||
if wizard.cursor >= 0 && wizard.cursor < len(wizard.namespaces) {
|
||||
wizard.selectedNamespace = wizard.namespaces[wizard.cursor]
|
||||
filteredNamespaces := wizard.getFilteredNamespaces()
|
||||
if wizard.cursor >= 0 && wizard.cursor < len(filteredNamespaces) {
|
||||
wizard.selectedNamespace = filteredNamespaces[wizard.cursor]
|
||||
wizard.step = StepSelectResourceType
|
||||
wizard.cursor = 0
|
||||
wizard.clearSearchFilter()
|
||||
wizard.inputMode = InputModeList
|
||||
}
|
||||
|
||||
@@ -403,13 +439,17 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
case ResourceTypeService:
|
||||
if wizard.cursor >= 0 && wizard.cursor < len(wizard.services) {
|
||||
wizard.resourceValue = wizard.services[wizard.cursor].Name
|
||||
filteredServices := wizard.getFilteredServices()
|
||||
if wizard.cursor >= 0 && wizard.cursor < len(filteredServices) {
|
||||
wizard.resourceValue = filteredServices[wizard.cursor].Name
|
||||
|
||||
// Get ports from selected service (must do this BEFORE clearing search filter)
|
||||
wizard.detectedPorts = filteredServices[wizard.cursor].Ports
|
||||
|
||||
wizard.step = StepEnterRemotePort
|
||||
wizard.clearTextInput()
|
||||
wizard.clearSearchFilter()
|
||||
|
||||
// Get ports from selected service
|
||||
wizard.detectedPorts = wizard.services[wizard.cursor].Ports
|
||||
if len(wizard.detectedPorts) > 0 {
|
||||
wizard.inputMode = InputModeList
|
||||
wizard.cursor = 0
|
||||
@@ -428,7 +468,14 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
||||
wizard.clearTextInput()
|
||||
} else if wizard.cursor >= 0 && wizard.cursor < len(wizard.detectedPorts) {
|
||||
// Selected a detected port
|
||||
wizard.remotePort = int(wizard.detectedPorts[wizard.cursor].Port)
|
||||
// For services, use TargetPort (actual pod port) if available
|
||||
// For pods, TargetPort is 0, so use Port (container port)
|
||||
selectedPort := wizard.detectedPorts[wizard.cursor]
|
||||
if selectedPort.TargetPort > 0 {
|
||||
wizard.remotePort = int(selectedPort.TargetPort)
|
||||
} else {
|
||||
wizard.remotePort = int(selectedPort.Port)
|
||||
}
|
||||
wizard.step = StepEnterLocalPort
|
||||
wizard.clearTextInput()
|
||||
wizard.inputMode = InputModeText
|
||||
@@ -437,7 +484,7 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
||||
} else {
|
||||
// Text mode - manual entry
|
||||
port, err := strconv.Atoi(wizard.textInput)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
if err != nil || !config.IsValidPort(port) {
|
||||
wizard.error = fmt.Errorf("invalid port number")
|
||||
} else {
|
||||
wizard.remotePort = port
|
||||
@@ -449,17 +496,14 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
||||
|
||||
case StepEnterLocalPort:
|
||||
port, err := strconv.Atoi(wizard.textInput)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
if err != nil || !config.IsValidPort(port) {
|
||||
wizard.error = fmt.Errorf("invalid port number")
|
||||
} else {
|
||||
// Check port availability before proceeding
|
||||
wizard.localPort = port
|
||||
wizard.step = StepConfirmation
|
||||
wizard.clearTextInput()
|
||||
wizard.cursor = 0
|
||||
wizard.inputMode = InputModeList
|
||||
wizard.error = nil
|
||||
wizard.loading = true
|
||||
return m, checkPortCmd(port)
|
||||
wizard.error = nil
|
||||
return m, checkPortCmd(port, m.ui.configPath)
|
||||
}
|
||||
|
||||
case StepConfirmation:
|
||||
@@ -472,6 +516,12 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
||||
|
||||
// Handle button selection
|
||||
if wizard.cursor == 0 {
|
||||
// Check if port is available before saving
|
||||
if !wizard.portAvailable {
|
||||
wizard.error = fmt.Errorf("port %d is not available. Please choose a different port", wizard.localPort)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Confirmed - save the forward
|
||||
wizard.alias = wizard.textInput
|
||||
|
||||
@@ -501,9 +551,10 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
||||
|
||||
return m, saveForwardCmd(m.ui.mutator, wizard.selectedContext, wizard.selectedNamespace, fwd)
|
||||
} else {
|
||||
// Cancelled
|
||||
// Cancelled - return to main view with screen clear
|
||||
m.ui.viewMode = ViewModeMain
|
||||
m.ui.addWizard = nil
|
||||
return m, tea.ClearScreen
|
||||
}
|
||||
|
||||
case StepSuccess:
|
||||
@@ -513,9 +564,10 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
||||
m.ui.addWizard.loading = true
|
||||
return m, loadContextsCmd(m.ui.discovery)
|
||||
} else {
|
||||
// Return to main view
|
||||
// Return to main view with screen clear
|
||||
m.ui.viewMode = ViewModeMain
|
||||
m.ui.addWizard = nil
|
||||
return m, tea.ClearScreen
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,6 +775,17 @@ func (m model) handlePortChecked(msg PortCheckedMsg) (tea.Model, tea.Cmd) {
|
||||
m.ui.addWizard.loading = false
|
||||
m.ui.addWizard.portAvailable = msg.available
|
||||
m.ui.addWizard.portCheckMsg = msg.message
|
||||
|
||||
// Only proceed to confirmation if port is available
|
||||
if msg.available {
|
||||
m.ui.addWizard.step = StepConfirmation
|
||||
m.ui.addWizard.clearTextInput()
|
||||
m.ui.addWizard.cursor = 0
|
||||
m.ui.addWizard.inputMode = InputModeList
|
||||
} else {
|
||||
// Port is not available - show error and stay on local port step
|
||||
m.ui.addWizard.error = fmt.Errorf("port %d is in use, please choose another port", msg.port)
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
@@ -759,5 +822,5 @@ func (m model) handleForwardsRemoved(msg ForwardsRemovedMsg) (tea.Model, tea.Cmd
|
||||
// If there was an error, it will be logged but we don't show it in UI for now
|
||||
// The config watcher will either reload (success) or keep old config (failure)
|
||||
|
||||
return m, nil
|
||||
return m, tea.ClearScreen
|
||||
}
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
)
|
||||
|
||||
// filterStrings filters a slice of strings by a search filter (case-insensitive substring match)
|
||||
func filterStrings(items []string, filter string) []string {
|
||||
if filter == "" {
|
||||
return items
|
||||
}
|
||||
filtered := []string{}
|
||||
filterLower := strings.ToLower(filter)
|
||||
for _, item := range items {
|
||||
if strings.Contains(strings.ToLower(item), filterLower) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// matchesFilter checks if a string matches the filter (case-insensitive substring match)
|
||||
func matchesFilter(item, filter string) bool {
|
||||
if filter == "" {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(strings.ToLower(item), strings.ToLower(filter))
|
||||
}
|
||||
|
||||
// ViewMode represents the current view state of the UI
|
||||
type ViewMode int
|
||||
|
||||
@@ -87,6 +112,7 @@ type AddWizardState struct {
|
||||
cursor int
|
||||
scrollOffset int // For scrolling long lists
|
||||
textInput string
|
||||
searchFilter string // For filtering lists (contexts, namespaces, services)
|
||||
loading bool
|
||||
error error
|
||||
|
||||
@@ -142,14 +168,14 @@ func (w *AddWizardState) moveCursor(delta int) {
|
||||
|
||||
switch w.step {
|
||||
case StepSelectContext:
|
||||
maxItems = len(w.contexts)
|
||||
maxItems = len(w.getFilteredContexts())
|
||||
case StepSelectNamespace:
|
||||
maxItems = len(w.namespaces)
|
||||
maxItems = len(w.getFilteredNamespaces())
|
||||
case StepSelectResourceType:
|
||||
maxItems = 3 // Three resource types
|
||||
case StepEnterResource:
|
||||
if w.selectedResourceType == ResourceTypeService {
|
||||
maxItems = len(w.services)
|
||||
maxItems = len(w.getFilteredServices())
|
||||
}
|
||||
case StepEnterRemotePort:
|
||||
if len(w.detectedPorts) > 0 {
|
||||
@@ -300,3 +326,50 @@ func (w *RemoveWizardState) getSelectedForwards() []RemovableForward {
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
// getFilteredContexts returns contexts filtered by search string
|
||||
func (w *AddWizardState) getFilteredContexts() []string {
|
||||
if w.searchFilter == "" {
|
||||
return w.contexts
|
||||
}
|
||||
return filterStrings(w.contexts, w.searchFilter)
|
||||
}
|
||||
|
||||
// getFilteredNamespaces returns namespaces filtered by search string
|
||||
func (w *AddWizardState) getFilteredNamespaces() []string {
|
||||
if w.searchFilter == "" {
|
||||
return w.namespaces
|
||||
}
|
||||
return filterStrings(w.namespaces, w.searchFilter)
|
||||
}
|
||||
|
||||
// getFilteredServices returns services filtered by search string
|
||||
func (w *AddWizardState) getFilteredServices() []k8s.ServiceInfo {
|
||||
if w.searchFilter == "" {
|
||||
return w.services
|
||||
}
|
||||
filtered := []k8s.ServiceInfo{}
|
||||
for _, svc := range w.services {
|
||||
if matchesFilter(svc.Name, w.searchFilter) {
|
||||
filtered = append(filtered, svc)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// clearSearchFilter clears the search filter and resets cursor/scroll
|
||||
func (w *AddWizardState) clearSearchFilter() {
|
||||
w.searchFilter = ""
|
||||
w.cursor = 0
|
||||
w.scrollOffset = 0
|
||||
}
|
||||
|
||||
// resetInput clears text input, search filter, and error state.
|
||||
// Use this when navigating between wizard steps.
|
||||
func (w *AddWizardState) resetInput() {
|
||||
w.textInput = ""
|
||||
w.searchFilter = ""
|
||||
w.cursor = 0
|
||||
w.scrollOffset = 0
|
||||
w.error = nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nvm/kportal/internal/k8s"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFilterStrings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
items []string
|
||||
filter string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "empty filter returns all items",
|
||||
items: []string{"namespace-1", "namespace-2", "namespace-3"},
|
||||
filter: "",
|
||||
expected: []string{"namespace-1", "namespace-2", "namespace-3"},
|
||||
},
|
||||
{
|
||||
name: "filter matches multiple items",
|
||||
items: []string{"prod-api", "prod-db", "staging-api", "dev-api"},
|
||||
filter: "prod",
|
||||
expected: []string{"prod-api", "prod-db"},
|
||||
},
|
||||
{
|
||||
name: "filter matches single item",
|
||||
items: []string{"namespace-1", "namespace-2", "namespace-3"},
|
||||
filter: "2",
|
||||
expected: []string{"namespace-2"},
|
||||
},
|
||||
{
|
||||
name: "filter matches no items",
|
||||
items: []string{"namespace-1", "namespace-2", "namespace-3"},
|
||||
filter: "xyz",
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "case insensitive matching",
|
||||
items: []string{"Production", "Staging", "Development"},
|
||||
filter: "prod",
|
||||
expected: []string{"Production"},
|
||||
},
|
||||
{
|
||||
name: "partial string matching",
|
||||
items: []string{"my-app-frontend", "my-app-backend", "other-service"},
|
||||
filter: "app",
|
||||
expected: []string{"my-app-frontend", "my-app-backend"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := filterStrings(tt.items, tt.filter)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesFilter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item string
|
||||
filter string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "empty filter matches everything",
|
||||
item: "namespace-1",
|
||||
filter: "",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "exact match",
|
||||
item: "namespace-1",
|
||||
filter: "namespace-1",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "partial match",
|
||||
item: "production-api",
|
||||
filter: "prod",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
item: "namespace-1",
|
||||
filter: "xyz",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "case insensitive match",
|
||||
item: "Production",
|
||||
filter: "prod",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := matchesFilter(tt.item, tt.filter)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFilteredContexts(t *testing.T) {
|
||||
wizard := &AddWizardState{
|
||||
contexts: []string{"prod-cluster", "staging-cluster", "dev-cluster", "test-cluster"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filter string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "no filter returns all",
|
||||
filter: "",
|
||||
expected: []string{"prod-cluster", "staging-cluster", "dev-cluster", "test-cluster"},
|
||||
},
|
||||
{
|
||||
name: "filter by 'prod'",
|
||||
filter: "prod",
|
||||
expected: []string{"prod-cluster"},
|
||||
},
|
||||
{
|
||||
name: "filter by 'cluster'",
|
||||
filter: "cluster",
|
||||
expected: []string{"prod-cluster", "staging-cluster", "dev-cluster", "test-cluster"},
|
||||
},
|
||||
{
|
||||
name: "filter by 'staging'",
|
||||
filter: "staging",
|
||||
expected: []string{"staging-cluster"},
|
||||
},
|
||||
{
|
||||
name: "filter with no matches",
|
||||
filter: "xyz",
|
||||
expected: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
wizard.searchFilter = tt.filter
|
||||
result := wizard.getFilteredContexts()
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFilteredNamespaces(t *testing.T) {
|
||||
wizard := &AddWizardState{
|
||||
namespaces: []string{
|
||||
"kube-system", "kube-public", "default",
|
||||
"prod-api", "prod-db", "staging-api", "staging-db",
|
||||
"monitoring", "logging",
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filter string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "no filter returns all",
|
||||
filter: "",
|
||||
expected: []string{
|
||||
"kube-system", "kube-public", "default",
|
||||
"prod-api", "prod-db", "staging-api", "staging-db",
|
||||
"monitoring", "logging",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "filter by 'prod'",
|
||||
filter: "prod",
|
||||
expected: []string{"prod-api", "prod-db"},
|
||||
},
|
||||
{
|
||||
name: "filter by 'kube'",
|
||||
filter: "kube",
|
||||
expected: []string{"kube-system", "kube-public"},
|
||||
},
|
||||
{
|
||||
name: "filter by 'api'",
|
||||
filter: "api",
|
||||
expected: []string{"prod-api", "staging-api"},
|
||||
},
|
||||
{
|
||||
name: "filter by 'ing' (partial match)",
|
||||
filter: "ing",
|
||||
expected: []string{"staging-api", "staging-db", "monitoring", "logging"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
wizard.searchFilter = tt.filter
|
||||
result := wizard.getFilteredNamespaces()
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFilteredServices(t *testing.T) {
|
||||
wizard := &AddWizardState{
|
||||
services: []k8s.ServiceInfo{
|
||||
{Name: "api-gateway"},
|
||||
{Name: "api-backend"},
|
||||
{Name: "database"},
|
||||
{Name: "redis-cache"},
|
||||
{Name: "postgres-db"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
filter string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "no filter returns all",
|
||||
filter: "",
|
||||
expected: []string{"api-gateway", "api-backend", "database", "redis-cache", "postgres-db"},
|
||||
},
|
||||
{
|
||||
name: "filter by 'api'",
|
||||
filter: "api",
|
||||
expected: []string{"api-gateway", "api-backend"},
|
||||
},
|
||||
{
|
||||
name: "filter by 'db'",
|
||||
filter: "db",
|
||||
expected: []string{"postgres-db"},
|
||||
},
|
||||
{
|
||||
name: "filter by 'base'",
|
||||
filter: "base",
|
||||
expected: []string{"database"},
|
||||
},
|
||||
{
|
||||
name: "filter by 'redis'",
|
||||
filter: "redis",
|
||||
expected: []string{"redis-cache"},
|
||||
},
|
||||
{
|
||||
name: "filter with no matches",
|
||||
filter: "xyz",
|
||||
expected: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
wizard.searchFilter = tt.filter
|
||||
result := wizard.getFilteredServices()
|
||||
resultNames := make([]string, len(result))
|
||||
for i, svc := range result {
|
||||
resultNames[i] = svc.Name
|
||||
}
|
||||
assert.Equal(t, tt.expected, resultNames)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearSearchFilter(t *testing.T) {
|
||||
wizard := &AddWizardState{
|
||||
searchFilter: "test",
|
||||
cursor: 5,
|
||||
scrollOffset: 10,
|
||||
}
|
||||
|
||||
wizard.clearSearchFilter()
|
||||
|
||||
assert.Equal(t, "", wizard.searchFilter, "searchFilter should be cleared")
|
||||
assert.Equal(t, 0, wizard.cursor, "cursor should be reset to 0")
|
||||
assert.Equal(t, 0, wizard.scrollOffset, "scrollOffset should be reset to 0")
|
||||
}
|
||||
|
||||
func TestMoveCursorWithFilteredLists(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
step AddWizardStep
|
||||
contexts []string
|
||||
namespaces []string
|
||||
searchFilter string
|
||||
initialCursor int
|
||||
delta int
|
||||
expectedCursor int
|
||||
}{
|
||||
{
|
||||
name: "move down in filtered contexts",
|
||||
step: StepSelectContext,
|
||||
contexts: []string{"prod-1", "prod-2", "staging-1", "dev-1"},
|
||||
searchFilter: "prod",
|
||||
initialCursor: 0,
|
||||
delta: 1,
|
||||
expectedCursor: 1,
|
||||
},
|
||||
{
|
||||
name: "cannot move beyond filtered list",
|
||||
step: StepSelectContext,
|
||||
contexts: []string{"prod-1", "prod-2", "staging-1", "dev-1"},
|
||||
searchFilter: "prod",
|
||||
initialCursor: 1,
|
||||
delta: 1,
|
||||
expectedCursor: 1, // Should stay at 1 (last item in filtered list)
|
||||
},
|
||||
{
|
||||
name: "move up in filtered list",
|
||||
step: StepSelectNamespace,
|
||||
namespaces: []string{"ns-1", "ns-2", "ns-3", "other"},
|
||||
searchFilter: "ns",
|
||||
initialCursor: 2,
|
||||
delta: -1,
|
||||
expectedCursor: 1,
|
||||
},
|
||||
{
|
||||
name: "cannot move above 0",
|
||||
step: StepSelectNamespace,
|
||||
namespaces: []string{"ns-1", "ns-2", "ns-3"},
|
||||
searchFilter: "ns",
|
||||
initialCursor: 0,
|
||||
delta: -1,
|
||||
expectedCursor: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
wizard := &AddWizardState{
|
||||
step: tt.step,
|
||||
inputMode: InputModeList,
|
||||
cursor: tt.initialCursor,
|
||||
contexts: tt.contexts,
|
||||
namespaces: tt.namespaces,
|
||||
searchFilter: tt.searchFilter,
|
||||
}
|
||||
|
||||
wizard.moveCursor(tt.delta)
|
||||
|
||||
assert.Equal(t, tt.expectedCursor, wizard.cursor)
|
||||
})
|
||||
}
|
||||
}
|
||||
+116
-46
@@ -45,6 +45,12 @@ func (m model) renderSelectContext() string {
|
||||
b.WriteString(renderHeader("Add Port Forward", renderProgress(1, 7)))
|
||||
b.WriteString("Select Kubernetes Context:\n\n")
|
||||
|
||||
// Show search input if there's a filter active
|
||||
if wizard.searchFilter != "" {
|
||||
b.WriteString(renderTextInput("Filter: ", wizard.searchFilter, true))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if wizard.loading {
|
||||
b.WriteString(spinnerStyle.Render("⣾ Loading contexts..."))
|
||||
} else if wizard.error != nil {
|
||||
@@ -52,46 +58,56 @@ func (m model) renderSelectContext() string {
|
||||
} else if len(wizard.contexts) == 0 {
|
||||
b.WriteString(mutedStyle.Render("No contexts found in kubeconfig"))
|
||||
} else {
|
||||
const viewportHeight = 20
|
||||
totalItems := len(wizard.contexts)
|
||||
filteredContexts := wizard.getFilteredContexts()
|
||||
if len(filteredContexts) == 0 {
|
||||
b.WriteString(mutedStyle.Render("No matching contexts"))
|
||||
} else {
|
||||
const viewportHeight = 20
|
||||
totalItems := len(filteredContexts)
|
||||
|
||||
// Show scroll up indicator if there are items above the viewport
|
||||
if wizard.scrollOffset > 0 {
|
||||
b.WriteString(mutedStyle.Render(" ↑ More above ↑") + "\n")
|
||||
}
|
||||
|
||||
// Calculate visible range
|
||||
start := wizard.scrollOffset
|
||||
end := wizard.scrollOffset + viewportHeight
|
||||
if end > totalItems {
|
||||
end = totalItems
|
||||
}
|
||||
|
||||
// Render visible contexts with (current) marker on first one
|
||||
for i := start; i < end; i++ {
|
||||
prefix := " "
|
||||
text := wizard.contexts[i]
|
||||
if i == 0 {
|
||||
text += mutedStyle.Render(" (current)")
|
||||
// Show scroll up indicator if there are items above the viewport
|
||||
if wizard.scrollOffset > 0 {
|
||||
b.WriteString(mutedStyle.Render(" ↑ More above ↑") + "\n")
|
||||
}
|
||||
|
||||
if i == wizard.cursor {
|
||||
prefix = "▸ "
|
||||
b.WriteString(selectedStyle.Render(prefix + text))
|
||||
} else {
|
||||
b.WriteString(prefix + text)
|
||||
// Calculate visible range
|
||||
start := wizard.scrollOffset
|
||||
end := wizard.scrollOffset + viewportHeight
|
||||
if end > totalItems {
|
||||
end = totalItems
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Show scroll down indicator if there are items below the viewport
|
||||
if end < totalItems {
|
||||
b.WriteString(mutedStyle.Render(" ↓ More below ↓") + "\n")
|
||||
// Render visible contexts with (current) marker on first one (only if not filtered)
|
||||
for i := start; i < end; i++ {
|
||||
prefix := " "
|
||||
text := filteredContexts[i]
|
||||
// Only show (current) marker if no filter and this is the first item in original list
|
||||
if wizard.searchFilter == "" && i == 0 {
|
||||
text += mutedStyle.Render(" (current)")
|
||||
}
|
||||
|
||||
if i == wizard.cursor {
|
||||
prefix = "▸ "
|
||||
b.WriteString(selectedStyle.Render(prefix + text))
|
||||
} else {
|
||||
b.WriteString(prefix + text)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Show scroll down indicator if there are items below the viewport
|
||||
if end < totalItems {
|
||||
b.WriteString(mutedStyle.Render(" ↓ More below ↓") + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select Esc/Ctrl+C: Cancel"))
|
||||
if wizard.searchFilter != "" {
|
||||
b.WriteString(helpStyle.Render(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Cancel", len(wizard.getFilteredContexts()), len(wizard.contexts))))
|
||||
} else {
|
||||
b.WriteString(helpStyle.Render("Type to filter ↑/↓: Navigate Enter: Select Esc/Ctrl+C: Cancel"))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -105,6 +121,12 @@ func (m model) renderSelectNamespace() string {
|
||||
|
||||
b.WriteString("Select Namespace:\n\n")
|
||||
|
||||
// Show search input if there's a filter active
|
||||
if wizard.searchFilter != "" {
|
||||
b.WriteString(renderTextInput("Filter: ", wizard.searchFilter, true))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if wizard.loading {
|
||||
b.WriteString(spinnerStyle.Render("⣾ Loading namespaces..."))
|
||||
} else if wizard.error != nil {
|
||||
@@ -113,11 +135,20 @@ func (m model) renderSelectNamespace() string {
|
||||
} else if len(wizard.namespaces) == 0 {
|
||||
b.WriteString(mutedStyle.Render("No namespaces found"))
|
||||
} else {
|
||||
b.WriteString(renderList(wizard.namespaces, wizard.cursor, " ", wizard.scrollOffset))
|
||||
filteredNamespaces := wizard.getFilteredNamespaces()
|
||||
if len(filteredNamespaces) == 0 {
|
||||
b.WriteString(mutedStyle.Render("No matching namespaces"))
|
||||
} else {
|
||||
b.WriteString(renderList(filteredNamespaces, wizard.cursor, " ", wizard.scrollOffset))
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
|
||||
if wizard.searchFilter != "" {
|
||||
b.WriteString(helpStyle.Render(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredNamespaces()), len(wizard.namespaces))))
|
||||
} else {
|
||||
b.WriteString(helpStyle.Render("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -242,21 +273,41 @@ func (m model) renderEnterResource() string {
|
||||
case ResourceTypeService:
|
||||
b.WriteString("Select service:\n\n")
|
||||
|
||||
// Show search input if there's a filter active
|
||||
if wizard.searchFilter != "" {
|
||||
b.WriteString(renderTextInput("Filter: ", wizard.searchFilter, true))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
if wizard.loading {
|
||||
b.WriteString(spinnerStyle.Render("⣾ Loading services..."))
|
||||
} else if len(wizard.services) == 0 {
|
||||
b.WriteString(mutedStyle.Render("No services found"))
|
||||
} else {
|
||||
serviceNames := make([]string, len(wizard.services))
|
||||
for i, svc := range wizard.services {
|
||||
serviceNames[i] = svc.Name
|
||||
filteredServices := wizard.getFilteredServices()
|
||||
if len(filteredServices) == 0 {
|
||||
b.WriteString(mutedStyle.Render("No matching services"))
|
||||
} else {
|
||||
serviceNames := make([]string, len(filteredServices))
|
||||
for i, svc := range filteredServices {
|
||||
serviceNames[i] = svc.Name
|
||||
}
|
||||
b.WriteString(renderList(serviceNames, wizard.cursor, " ", wizard.scrollOffset))
|
||||
}
|
||||
b.WriteString(renderList(serviceNames, wizard.cursor, " ", wizard.scrollOffset))
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel"))
|
||||
// Show appropriate help text based on resource type and filter state
|
||||
if wizard.selectedResourceType == ResourceTypeService {
|
||||
if wizard.searchFilter != "" {
|
||||
b.WriteString(helpStyle.Render(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredServices()), len(wizard.services))))
|
||||
} else {
|
||||
b.WriteString(helpStyle.Render("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
|
||||
}
|
||||
} else {
|
||||
b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel"))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
@@ -298,9 +349,20 @@ func (m model) renderEnterRemotePort() string {
|
||||
// Render detected ports within viewport
|
||||
for i := start; i < end && i < len(wizard.detectedPorts); i++ {
|
||||
port := wizard.detectedPorts[i]
|
||||
portDesc := fmt.Sprintf("%d", port.Port)
|
||||
if port.Name != "" {
|
||||
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
||||
// For services, show both service port and target port if they differ
|
||||
var portDesc string
|
||||
if port.TargetPort > 0 && port.TargetPort != port.Port {
|
||||
// Service with different target port: "80 → 8000 (http)"
|
||||
portDesc = fmt.Sprintf("%d → %d", port.Port, port.TargetPort)
|
||||
if port.Name != "" {
|
||||
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
||||
}
|
||||
} else {
|
||||
// Pod port or service with same port
|
||||
portDesc = fmt.Sprintf("%d", port.Port)
|
||||
if port.Name != "" {
|
||||
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
||||
}
|
||||
}
|
||||
|
||||
prefix := " "
|
||||
@@ -322,7 +384,7 @@ func (m model) renderEnterRemotePort() string {
|
||||
prefix = "▸ "
|
||||
b.WriteString(selectedStyle.Render(prefix + manualOption))
|
||||
} else {
|
||||
b.WriteString(mutedStyle.Render(prefix + manualOption))
|
||||
b.WriteString(prefix + mutedStyle.Render(manualOption))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
@@ -339,9 +401,17 @@ func (m model) renderEnterRemotePort() string {
|
||||
if len(wizard.detectedPorts) > 0 {
|
||||
b.WriteString(mutedStyle.Render("Detected ports:\n"))
|
||||
for _, port := range wizard.detectedPorts {
|
||||
portDesc := fmt.Sprintf("%d", port.Port)
|
||||
if port.Name != "" {
|
||||
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
||||
var portDesc string
|
||||
if port.TargetPort > 0 && port.TargetPort != port.Port {
|
||||
portDesc = fmt.Sprintf("%d → %d", port.Port, port.TargetPort)
|
||||
if port.Name != "" {
|
||||
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
||||
}
|
||||
} else {
|
||||
portDesc = fmt.Sprintf("%d", port.Port)
|
||||
if port.Name != "" {
|
||||
portDesc += fmt.Sprintf(" (%s)", port.Name)
|
||||
}
|
||||
}
|
||||
b.WriteString(mutedStyle.Render(fmt.Sprintf(" • %s\n", portDesc)))
|
||||
}
|
||||
@@ -392,7 +462,7 @@ func (m model) renderEnterLocalPort() string {
|
||||
} else {
|
||||
b.WriteString(errorStyle.Render(wizard.portCheckMsg))
|
||||
}
|
||||
} else if wizard.textInput != "" && wizard.localPort > 0 {
|
||||
} else if wizard.textInput != "" {
|
||||
b.WriteString(mutedStyle.Render("Press Enter to check availability"))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// GitHubAPIURL is the GitHub API endpoint for releases
|
||||
githubReleasesURL = "https://api.github.com/repos/%s/%s/releases/latest"
|
||||
// requestTimeout is the timeout for HTTP requests
|
||||
requestTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// ReleaseInfo contains information about a GitHub release
|
||||
type ReleaseInfo struct {
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// UpdateInfo contains information about an available update
|
||||
type UpdateInfo struct {
|
||||
CurrentVersion string
|
||||
LatestVersion string
|
||||
ReleaseURL string
|
||||
ReleaseName string
|
||||
}
|
||||
|
||||
// Checker checks for new versions on GitHub
|
||||
type Checker struct {
|
||||
owner string
|
||||
repo string
|
||||
current string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewChecker creates a new version checker
|
||||
func NewChecker(owner, repo, currentVersion string) *Checker {
|
||||
return &Checker{
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
current: normalizeVersion(currentVersion),
|
||||
client: &http.Client{
|
||||
Timeout: requestTimeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CheckForUpdate checks if a newer version is available.
|
||||
// Returns nil if current version is up to date or if check fails.
|
||||
// This is designed to fail silently - network errors should not impact the user.
|
||||
func (c *Checker) CheckForUpdate(ctx context.Context) *UpdateInfo {
|
||||
release, err := c.fetchLatestRelease(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
latestVersion := normalizeVersion(release.TagName)
|
||||
if isNewerVersion(latestVersion, c.current) {
|
||||
return &UpdateInfo{
|
||||
CurrentVersion: c.current,
|
||||
LatestVersion: latestVersion,
|
||||
ReleaseURL: release.HTMLURL,
|
||||
ReleaseName: release.Name,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchLatestRelease fetches the latest release info from GitHub API
|
||||
func (c *Checker) fetchLatestRelease(ctx context.Context) (*ReleaseInfo, error) {
|
||||
url := fmt.Sprintf(githubReleasesURL, c.owner, c.repo)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
||||
req.Header.Set("User-Agent", "kportal-version-checker")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var release ReleaseInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &release, nil
|
||||
}
|
||||
|
||||
// normalizeVersion removes 'v' or 'V' prefix and trims whitespace
|
||||
func normalizeVersion(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
v = strings.TrimPrefix(v, "v")
|
||||
v = strings.TrimPrefix(v, "V")
|
||||
return v
|
||||
}
|
||||
|
||||
// isNewerVersion compares two semver-like versions.
|
||||
// Returns true if latest is newer than current.
|
||||
func isNewerVersion(latest, current string) bool {
|
||||
latestParts := parseVersion(latest)
|
||||
currentParts := parseVersion(current)
|
||||
|
||||
// Compare each part
|
||||
for i := 0; i < len(latestParts) && i < len(currentParts); i++ {
|
||||
if latestParts[i] > currentParts[i] {
|
||||
return true
|
||||
}
|
||||
if latestParts[i] < currentParts[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// If all compared parts are equal, longer version is newer
|
||||
// e.g., 1.0.1 > 1.0
|
||||
return len(latestParts) > len(currentParts)
|
||||
}
|
||||
|
||||
// parseVersion splits a version string into numeric parts
|
||||
func parseVersion(v string) []int {
|
||||
// Remove any suffix like -beta, -rc1, etc.
|
||||
if idx := strings.IndexAny(v, "-+"); idx != -1 {
|
||||
v = v[:idx]
|
||||
}
|
||||
|
||||
parts := strings.Split(v, ".")
|
||||
result := make([]int, 0, len(parts))
|
||||
|
||||
for _, p := range parts {
|
||||
var num int
|
||||
fmt.Sscanf(p, "%d", &num)
|
||||
result = append(result, num)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// FormatUpdateMessage formats a user-friendly update notification
|
||||
func (u *UpdateInfo) FormatUpdateMessage() string {
|
||||
return fmt.Sprintf("New version available: %s (current: %s) - %s",
|
||||
u.LatestVersion, u.CurrentVersion, u.ReleaseURL)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNormalizeVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"v1.0.0", "1.0.0"},
|
||||
{"1.0.0", "1.0.0"},
|
||||
{" v2.1.3 ", "2.1.3"},
|
||||
{"V1.0.0", "1.0.0"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
result := normalizeVersion(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected []int
|
||||
}{
|
||||
{"1.0.0", []int{1, 0, 0}},
|
||||
{"2.1.3", []int{2, 1, 3}},
|
||||
{"1.0", []int{1, 0}},
|
||||
{"10.20.30", []int{10, 20, 30}},
|
||||
{"1.0.0-beta", []int{1, 0, 0}},
|
||||
{"1.0.0-rc1", []int{1, 0, 0}},
|
||||
{"1.0.0+build123", []int{1, 0, 0}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
result := parseVersion(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNewerVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
latest string
|
||||
current string
|
||||
expected bool
|
||||
}{
|
||||
{"major version bump", "2.0.0", "1.0.0", true},
|
||||
{"minor version bump", "1.1.0", "1.0.0", true},
|
||||
{"patch version bump", "1.0.1", "1.0.0", true},
|
||||
{"same version", "1.0.0", "1.0.0", false},
|
||||
{"current is newer major", "1.0.0", "2.0.0", false},
|
||||
{"current is newer minor", "1.0.0", "1.1.0", false},
|
||||
{"current is newer patch", "1.0.0", "1.0.1", false},
|
||||
{"multi-digit versions", "1.10.0", "1.9.0", true},
|
||||
{"longer version is newer", "1.0.1", "1.0", true},
|
||||
{"shorter version is older", "1.0", "1.0.1", false},
|
||||
{"complex comparison", "2.1.3", "2.1.2", true},
|
||||
{"real world example", "0.2.0", "0.1.0", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isNewerVersion(tt.latest, tt.current)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateInfo_FormatUpdateMessage(t *testing.T) {
|
||||
info := &UpdateInfo{
|
||||
CurrentVersion: "0.1.0",
|
||||
LatestVersion: "0.2.0",
|
||||
ReleaseURL: "https://github.com/nvm/kportal/releases/tag/v0.2.0",
|
||||
}
|
||||
|
||||
msg := info.FormatUpdateMessage()
|
||||
assert.Contains(t, msg, "0.2.0")
|
||||
assert.Contains(t, msg, "0.1.0")
|
||||
assert.Contains(t, msg, "https://github.com/nvm/kportal/releases/tag/v0.2.0")
|
||||
}
|
||||
Reference in New Issue
Block a user