mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-14 02:31:39 +00:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bb6fbc48d | |||
| f4334ebdc9 | |||
| 50f94bda87 | |||
| d9888f1a56 | |||
| 7dec532e18 | |||
| aa7695b3be | |||
| 1bacd31f27 | |||
| bfecbdf056 | |||
| 754108474c | |||
| 690c587c0a | |||
| 0d03f228f9 | |||
| 2a44c6ff9c | |||
| 8672d932bb | |||
| 87317adb91 | |||
| ced7e80a06 | |||
| 13723733df | |||
| 9538623bcb | |||
| 8bb377909c | |||
| 263a0370d3 | |||
| 62eca4a9a1 | |||
| ea20a037b9 | |||
| 46db732f87 | |||
| a297ba7073 | |||
| 518879dc56 | |||
| 649227b201 | |||
| 28e2fc315a | |||
| ba77cb6aa9 | |||
| 23cd45a3d7 | |||
| dbbc96a200 | |||
| 2498a3aa98 | |||
| 3f5c1d3a5f | |||
| 035b1cdd01 | |||
| 32e88efd9a | |||
| 6d8677026f | |||
| b7a32e4aab | |||
| 1167847fd4 | |||
| 3a7cc6f502 | |||
| 49acba5679 | |||
| 39fe4286b4 |
@@ -0,0 +1,2 @@
|
|||||||
|
github: [lukaszraczylo]
|
||||||
|
custom: [monzo.me/lukaszraczylo]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
name: Autoupdate go.mod and go.sum
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 * * *"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
actions: write
|
||||||
|
pull-requests: write
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
autoupdate:
|
||||||
|
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
|
||||||
|
with:
|
||||||
|
go-version: ">=1.24"
|
||||||
|
release-workflow: "release.yml"
|
||||||
|
secrets: inherit
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Pull Request
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "**"
|
||||||
|
- "!main"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
actions: write
|
||||||
|
pull-requests: write
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pr-checks:
|
||||||
|
uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main
|
||||||
|
with:
|
||||||
|
go-version: ">=1.24"
|
||||||
@@ -5,90 +5,17 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- '**.go'
|
- "**.go"
|
||||||
- 'go.mod'
|
- "go.mod"
|
||||||
- 'go.sum'
|
- "go.sum"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
|
||||||
name: Test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.23'
|
|
||||||
|
|
||||||
- name: Run tests with race detector
|
|
||||||
run: go test -race -v ./...
|
|
||||||
|
|
||||||
version:
|
|
||||||
name: Calculate Version
|
|
||||||
needs: test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
version: ${{ steps.version_formatted.outputs.version }}
|
|
||||||
version_tag: ${{ steps.version_formatted.outputs.version_tag }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Calculate semantic version
|
|
||||||
id: semver
|
|
||||||
uses: lukaszraczylo/semver-generator@v1
|
|
||||||
with:
|
|
||||||
config_file: semver.yaml
|
|
||||||
repository_local: true
|
|
||||||
|
|
||||||
- name: Format version
|
|
||||||
id: version_formatted
|
|
||||||
run: |
|
|
||||||
VERSION="${{ steps.semver.outputs.semantic_version }}"
|
|
||||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
|
||||||
echo "version_tag=v${VERSION}" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Display version
|
|
||||||
run: |
|
|
||||||
echo "Calculated version: ${{ steps.version_formatted.outputs.version }}"
|
|
||||||
echo "Version tag: ${{ steps.version_formatted.outputs.version_tag }}"
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
name: Release with GoReleaser
|
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
|
||||||
needs: version
|
with:
|
||||||
runs-on: ubuntu-latest
|
go-version: ">=1.24"
|
||||||
steps:
|
secrets: inherit
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.23'
|
|
||||||
|
|
||||||
- name: Create and push tag
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git tag -a ${{ needs.version.outputs.version_tag }} -m "Release ${{ needs.version.outputs.version_tag }}"
|
|
||||||
git push origin ${{ needs.version.outputs.version_tag }}
|
|
||||||
|
|
||||||
- name: Run GoReleaser
|
|
||||||
uses: goreleaser/goreleaser-action@v6
|
|
||||||
with:
|
|
||||||
distribution: goreleaser
|
|
||||||
version: '~> v2'
|
|
||||||
args: release --clean
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: ["main"]
|
branches: ["main"]
|
||||||
paths:
|
paths:
|
||||||
- 'docs/**'
|
- "docs/**"
|
||||||
|
|
||||||
# Allows you to run this workflow manually from the Actions tab
|
# Allows you to run this workflow manually from the Actions tab
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
uses: actions/upload-pages-artifact@v3
|
uses: actions/upload-pages-artifact@v3
|
||||||
with:
|
with:
|
||||||
# Upload entire repository
|
# Upload entire repository
|
||||||
path: 'docs/'
|
path: "docs/"
|
||||||
- name: Deploy to GitHub Pages
|
- name: Deploy to GitHub Pages
|
||||||
id: deployment
|
id: deployment
|
||||||
uses: actions/deploy-pages@v4
|
uses: actions/deploy-pages@v4
|
||||||
|
|||||||
+15
-11
@@ -19,15 +19,15 @@ builds:
|
|||||||
- arm64
|
- arm64
|
||||||
ldflags:
|
ldflags:
|
||||||
- -s -w
|
- -s -w
|
||||||
- -X main.version={{.Version}}
|
- -X main.appVersion={{.Version}}
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- id: kportal
|
- id: kportal
|
||||||
format: tar.gz
|
formats: [tar.gz]
|
||||||
name_template: "kportal-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
name_template: "kportal-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||||
format_overrides:
|
format_overrides:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
format: zip
|
formats: [zip]
|
||||||
files:
|
files:
|
||||||
- LICENSE
|
- LICENSE
|
||||||
- README.md
|
- README.md
|
||||||
@@ -53,17 +53,21 @@ release:
|
|||||||
draft: false
|
draft: false
|
||||||
prerelease: auto
|
prerelease: auto
|
||||||
|
|
||||||
brews:
|
homebrew_casks:
|
||||||
- repository:
|
- repository:
|
||||||
owner: lukaszraczylo
|
owner: lukaszraczylo
|
||||||
name: brew-taps
|
name: homebrew-taps
|
||||||
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
|
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
|
||||||
directory: Formula
|
directory: Casks
|
||||||
homepage: https://lukaszraczylo.github.io/kportal
|
homepage: https://lukaszraczylo.github.io/kportal
|
||||||
description: "Modern Kubernetes port-forward manager with interactive TUI"
|
description: "Modern Kubernetes port-forward manager with interactive TUI"
|
||||||
license: MIT
|
license: MIT
|
||||||
test: |
|
url:
|
||||||
system "#{bin}/kportal", "--version"
|
verified: github.com/lukaszraczylo/kportal
|
||||||
dependencies:
|
hooks:
|
||||||
- name: kubernetes-cli
|
post:
|
||||||
type: optional
|
install: |
|
||||||
|
if OS.mac?
|
||||||
|
system_command "/usr/bin/xattr",
|
||||||
|
args: ["-dr", "com.apple.quarantine", "#{staged_path}/kportal"]
|
||||||
|
end
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ GOFMT=$(GOCMD) fmt
|
|||||||
|
|
||||||
# Build flags
|
# Build flags
|
||||||
BUILD_FLAGS=-buildvcs=false
|
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
|
all: fmt vet staticcheck test build
|
||||||
|
|
||||||
|
|||||||
@@ -9,44 +9,63 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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>
|
</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
|
## ✨ Features
|
||||||
|
|
||||||
- 🎯 **Interactive TUI** - Beautiful terminal interface with keyboard navigation (↑↓/jk, Space to toggle, q to quit)
|
- **Interactive TUI** - Terminal interface with keyboard navigation
|
||||||
- ➕ **Live Add** - Add new port-forwards on-the-fly without editing config files or restarting
|
- **Live management** - Add, edit, and delete port-forwards without restarting
|
||||||
- ✏️ **Live Edit** - Modify existing port-forwards (ports, resources, aliases) in real-time
|
- **Auto-reconnect** - Exponential backoff retry on connection failures
|
||||||
- 🗑️ **Live Delete** - Remove port-forwards instantly from the running session
|
- **Hot-reload** - Configuration changes applied automatically
|
||||||
- 🔄 **Auto-Reconnect** - Automatic retry with exponential backoff on connection failures (max 10s)
|
- **Health monitoring** - Multiple check methods with stale connection detection
|
||||||
- ⚡ **Hot-Reload** - Update configuration without restarting - changes applied automatically
|
- **Multi-context** - Support for multiple Kubernetes contexts and namespaces
|
||||||
- 🏥 **Advanced Health Checks** - Multiple check methods (tcp-dial, data-transfer) with stale connection detection
|
- **Pod restart handling** - Automatic reconnection when pods restart
|
||||||
- 🛡️ **Goroutine Watchdog** - Detects and recovers from completely hung workers
|
- **Label selectors** - Dynamic pod targeting using label selectors
|
||||||
- 🎨 **Multi-Context** - Support for multiple Kubernetes contexts and namespaces
|
- **Port conflict detection** - Validates port availability with PID information
|
||||||
- 📦 **Batch Management** - Manage all port-forwards from a single configuration file
|
- **mDNS hostnames** - Access forwards via `.local` hostnames
|
||||||
- 🔌 **Toggle Forwards** - Enable/disable individual port-forwards on the fly with Space key
|
- **HTTP traffic logging** - Real-time HTTP request/response logging for debugging
|
||||||
- 🚀 **Grace Period** - Smart 10-second grace period to avoid false "Error" status on startup
|
- **Connection benchmarking** - Built-in HTTP benchmarking with latency statistics
|
||||||
- 📊 **Status Display** - Clear visual indicators: Active (●), Starting (○), Reconnecting (◐), Error (✗)
|
- **Headless mode** - Background operation for scripting and automation
|
||||||
- 🔍 **Error Reporting** - Detailed error messages displayed below the table
|
|
||||||
- 🔄 **Pod Restart Handling** - Detects and reconnects to pods when they restart
|
## 🔄 Comparison with Other Tools
|
||||||
- 🏷️ **Label Selector Support** - Dynamically target pods using label selectors
|
|
||||||
- 📋 **Prefix Matching** - Automatically find and reconnect to pods with name prefixes
|
| Feature | kportal | [k9s](https://k9scli.io/) | [Kube Forwarder](https://kube-forwarder.pixelpoint.io/) | [kftray](https://kftray.app/) |
|
||||||
- 🚫 **Port Conflict Detection** - Validates port availability before starting with detailed PID info
|
|---------|---------|------|----------------|--------|
|
||||||
- 🎭 **Alias Support** - Cleaner, more readable display names for your forwards
|
| **Interface** | Terminal TUI | Terminal TUI | Desktop GUI (Electron) | Desktop GUI + TUI |
|
||||||
|
| **Persistent Config** | ✅ YAML file | ❌ Session only | ✅ JSON bookmarks | ✅ JSON + Git sync |
|
||||||
|
| **Auto-reconnect** | ✅ Exponential backoff | ❌ Manual | ✅ Basic | ✅ Watch API |
|
||||||
|
| **Hot-reload Config** | ✅ File watch + SIGHUP | ❌ | ❌ | ❌ |
|
||||||
|
| **Health Checks** | ✅ TCP + data-transfer | ❌ | ❌ | ❌ |
|
||||||
|
| **Stale Connection Detection** | ✅ Age + idle tracking | ❌ | ❌ | ❌ |
|
||||||
|
| **HTTP Traffic Logging** | ✅ Built-in viewer | ❌ | ❌ | ✅ |
|
||||||
|
| **Connection Benchmarking** | ✅ Built-in | ✅ Via Hey | ❌ | ❌ |
|
||||||
|
| **mDNS Hostnames** | ✅ `.local` domains | ❌ | ❌ | ❌ |
|
||||||
|
| **Label Selectors** | ✅ | ✅ | ❌ | ✅ |
|
||||||
|
| **Multi-context** | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| **Headless Mode** | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
| **System Tray** | ❌ | ❌ | ❌ | ✅ |
|
||||||
|
| **UDP Support** | ❌ | ❌ | ❌ | ✅ Proxy relay |
|
||||||
|
| **Dependencies** | Single binary | Single binary | Electron | Tauri + kubectl |
|
||||||
|
|
||||||
## 📦 Installation
|
## 📦 Installation
|
||||||
|
|
||||||
### Homebrew (macOS/Linux)
|
### Homebrew (macOS)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install lukaszraczylo/brew-taps/kportal
|
brew install --cask lukaszraczylo/taps/kportal
|
||||||
```
|
```
|
||||||
|
|
||||||
### Quick Install Script
|
> **Note**: If you previously installed via `brew install lukaszraczylo/taps/kportal` (formula), uninstall first:
|
||||||
|
> ```bash
|
||||||
|
> brew uninstall kportal
|
||||||
|
> ```
|
||||||
|
|
||||||
|
### Quick Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.sh | bash
|
curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.sh | bash
|
||||||
@@ -54,24 +73,19 @@ curl -fsSL https://raw.githubusercontent.com/lukaszraczylo/kportal/main/install.
|
|||||||
|
|
||||||
### Manual Download
|
### Manual Download
|
||||||
|
|
||||||
Download the latest binary for your platform from the [releases page](https://github.com/lukaszraczylo/kportal/releases):
|
Download binaries 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`
|
|
||||||
|
|
||||||
### Build from Source
|
### Build from Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/lukaszraczylo/kportal.git
|
git clone https://github.com/lukaszraczylo/kportal.git
|
||||||
cd kportal
|
cd kportal
|
||||||
make build
|
make build && make install
|
||||||
make install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
1. **Create a configuration file** (`.kportal.yaml`):
|
Create `.kportal.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
contexts:
|
contexts:
|
||||||
@@ -85,32 +99,36 @@ contexts:
|
|||||||
localPort: 5432
|
localPort: 5432
|
||||||
alias: prod-db
|
alias: prod-db
|
||||||
|
|
||||||
- name: frontend
|
- resource: service/api
|
||||||
forwards:
|
|
||||||
- resource: service/redis
|
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
port: 6379
|
port: 8080
|
||||||
localPort: 6380
|
localPort: 8080
|
||||||
alias: prod-redis
|
alias: api
|
||||||
|
httpLog: true # Enable HTTP traffic logging
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Run kportal**:
|
Run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kportal
|
kportal
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Navigate the interface**:
|
### Keyboard Controls
|
||||||
- `↑↓` or `j/k` - Navigate through forwards
|
|
||||||
- `Space` or `Enter` - Toggle forward on/off
|
| Key | Action |
|
||||||
- `a` - Add new port-forward interactively
|
|-----|--------|
|
||||||
- `e` - Edit selected port-forward
|
| `↑↓` / `j/k` | Navigate |
|
||||||
- `d` - Delete selected port-forward
|
| `Space` / `Enter` | Toggle forward |
|
||||||
- `q` - Quit application
|
| `n` | Add new forward |
|
||||||
|
| `e` | Edit forward |
|
||||||
|
| `d` | Delete forward |
|
||||||
|
| `b` | Benchmark connection |
|
||||||
|
| `l` | View HTTP logs |
|
||||||
|
| `q` | Quit |
|
||||||
|
|
||||||
## 📖 Configuration
|
## 📖 Configuration
|
||||||
|
|
||||||
### Simple Configuration
|
### Basic Structure
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
contexts:
|
contexts:
|
||||||
@@ -118,149 +136,115 @@ contexts:
|
|||||||
namespaces:
|
namespaces:
|
||||||
- name: <namespace-name>
|
- name: <namespace-name>
|
||||||
forwards:
|
forwards:
|
||||||
- resource: <resource-type>/<resource-name>
|
- resource: <type>/<name>
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
port: <remote-port>
|
port: <remote-port>
|
||||||
localPort: <local-port>
|
localPort: <local-port>
|
||||||
alias: <friendly-name> # Optional
|
alias: <display-name> # optional
|
||||||
|
selector: <label-selector> # optional
|
||||||
|
httpLog: true # optional - enable HTTP logging
|
||||||
```
|
```
|
||||||
|
|
||||||
### Advanced Configuration
|
### Forward Options
|
||||||
|
|
||||||
```yaml
|
| Field | Required | Description |
|
||||||
contexts:
|
|-------|----------|-------------|
|
||||||
# Production cluster
|
| `resource` | Yes | Resource type and name (e.g., `service/postgres`, `pod/my-app`) |
|
||||||
- name: prod-us-west
|
| `protocol` | Yes | Protocol (`tcp`) |
|
||||||
namespaces:
|
| `port` | Yes | Remote port |
|
||||||
- name: databases
|
| `localPort` | Yes | Local port |
|
||||||
forwards:
|
| `alias` | No | Display name and mDNS hostname |
|
||||||
# Direct pod connection with prefix matching
|
| `selector` | No | Label selector for pod resolution |
|
||||||
- resource: pod/postgres-primary
|
| `httpLog` | No | Enable HTTP traffic logging (`true`/`false`) |
|
||||||
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`) |
|
|
||||||
|
|
||||||
### Resource Formats
|
### Resource Formats
|
||||||
|
|
||||||
- **Pod by name**: `pod/pod-name` or just `pod-name`
|
| Format | Description |
|
||||||
- **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/name` | Service forwarding |
|
||||||
- **Service**: `service/service-name` or `svc/service-name`
|
| `pod/name` | Direct pod by name |
|
||||||
- **Deployment**: `deployment/deployment-name` or `deploy/deployment-name`
|
| `pod/prefix` | Pod by prefix (matches `prefix-*`) |
|
||||||
|
| `pod` + `selector` | Pod by label selector |
|
||||||
|
| `deployment/name` | Deployment |
|
||||||
|
|
||||||
### Health Check & Reliability (Advanced)
|
### Health Check Configuration
|
||||||
|
|
||||||
kportal includes advanced health checking to prevent stale connections during long-running operations like database dumps:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
healthCheck:
|
healthCheck:
|
||||||
interval: "3s" # Health check frequency (default: 3s)
|
interval: "3s" # Check frequency
|
||||||
timeout: "2s" # Health check timeout (default: 2s)
|
timeout: "2s" # Check timeout
|
||||||
method: "data-transfer" # Check method: "tcp-dial" or "data-transfer" (default: data-transfer)
|
method: "data-transfer" # tcp-dial or data-transfer
|
||||||
maxConnectionAge: "25m" # Proactive reconnect before k8s timeout (default: 25m)
|
maxConnectionAge: "25m" # Reconnect before k8s timeout
|
||||||
maxIdleTime: "10m" # Detect hung connections (default: 10m)
|
maxIdleTime: "10m" # Detect idle connections
|
||||||
|
|
||||||
reliability:
|
|
||||||
tcpKeepalive: "30s" # TCP keepalive interval (default: 30s)
|
|
||||||
dialTimeout: "30s" # Connection dial timeout (default: 30s)
|
|
||||||
retryOnStale: true # Auto-reconnect stale connections (default: true)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Health Check Methods:**
|
|
||||||
- **`tcp-dial`**: Fast TCP connection test - verifies local port is listening
|
|
||||||
- **`data-transfer`**: More reliable - attempts to read data to verify tunnel is functional
|
|
||||||
|
|
||||||
**Stale Detection:**
|
|
||||||
- **Max Connection Age**: Kubernetes API typically has 30-minute timeout. kportal reconnects at 25 minutes by default to avoid hitting this limit. **Important**: Age-based reconnection only occurs when the connection is ALSO idle - active transfers (like database dumps) are never interrupted.
|
|
||||||
- **Max Idle Time**: Detects connections with no data transfer, common when intermediate firewalls drop idle TCP connections
|
|
||||||
|
|
||||||
**Use Case Example - Database Dumps:**
|
|
||||||
```yaml
|
|
||||||
# Optimized for long-running pg_dump
|
|
||||||
healthCheck:
|
|
||||||
method: "data-transfer"
|
|
||||||
maxConnectionAge: "20m" # Only applies when idle - won't interrupt active dumps
|
|
||||||
maxIdleTime: "5m" # Detects truly stale connections
|
|
||||||
|
|
||||||
reliability:
|
reliability:
|
||||||
tcpKeepalive: "30s"
|
tcpKeepalive: "30s"
|
||||||
|
dialTimeout: "30s"
|
||||||
retryOnStale: true
|
retryOnStale: true
|
||||||
```
|
```
|
||||||
|
|
||||||
This configuration ensures multi-hour database dumps complete without interruption. The `maxConnectionAge` will only trigger reconnection if the connection has been idle for more than `maxIdleTime`, preventing interruption of active data transfers.
|
Health check methods:
|
||||||
|
- `tcp-dial` - Fast TCP connection test
|
||||||
|
- `data-transfer` - Verifies tunnel functionality by attempting data read
|
||||||
|
|
||||||
## 🎮 Usage
|
Connection age reconnection only triggers when the connection is also idle, preventing interruption of active transfers like database dumps.
|
||||||
|
|
||||||
### Interactive Mode (Default)
|
### 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
|
```bash
|
||||||
kportal
|
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
|
### Verbose Mode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kportal -v
|
kportal -v
|
||||||
```
|
```
|
||||||
|
|
||||||
Runs in verbose mode with:
|
### Headless Mode
|
||||||
- Detailed logging to stdout
|
|
||||||
- Periodic status table updates every 2 seconds
|
Run without TUI for scripting and automation:
|
||||||
- Full error traces
|
|
||||||
- No interactive controls (for automation/debugging)
|
```bash
|
||||||
|
kportal -headless
|
||||||
|
```
|
||||||
|
|
||||||
|
Combines well with verbose mode for background operation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kportal -headless -v &
|
||||||
|
```
|
||||||
|
|
||||||
### Validate Configuration
|
### Validate Configuration
|
||||||
|
|
||||||
@@ -268,442 +252,173 @@ Runs in verbose mode with:
|
|||||||
kportal --check
|
kportal --check
|
||||||
```
|
```
|
||||||
|
|
||||||
Validates your configuration file without starting any forwards:
|
### Custom Config File
|
||||||
- Checks YAML syntax
|
|
||||||
- Validates all required fields
|
|
||||||
- Detects duplicate local ports
|
|
||||||
- Shows validation errors with line numbers
|
|
||||||
|
|
||||||
### Custom Configuration File
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kportal -c /path/to/config.yaml
|
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
|
||||||
|
|
||||||
|
### HTTP Traffic Logging
|
||||||
|
|
||||||
|
Press `l` in the TUI to view real-time HTTP traffic for a selected forward. The log viewer shows:
|
||||||
|
|
||||||
|
| Column | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| TIME | Request timestamp |
|
||||||
|
| METHOD | HTTP method (GET, POST, etc.) |
|
||||||
|
| STATUS | Response status code |
|
||||||
|
| LATENCY | Request duration |
|
||||||
|
| PATH | Request path |
|
||||||
|
|
||||||
|
**List view shortcuts:**
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `↑/↓` | Navigate entries |
|
||||||
|
| `Enter` | View request details |
|
||||||
|
| `g/G` | Jump to top/bottom |
|
||||||
|
| `a` | Toggle auto-scroll |
|
||||||
|
| `f` | Cycle filter mode (All → Non-2xx → Errors) |
|
||||||
|
| `/` | Search by path or method |
|
||||||
|
| `c` | Clear all filters |
|
||||||
|
| `q` | Close log viewer |
|
||||||
|
|
||||||
|
**Detail view:**
|
||||||
|
|
||||||
|
Press `Enter` on any entry to see full request/response details including:
|
||||||
|
- Request and response headers (alphabetically sorted)
|
||||||
|
- Request and response bodies
|
||||||
|
- Timing information and status codes
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `↑/↓` | Scroll content |
|
||||||
|
| `PgUp/PgDn` | Scroll by page |
|
||||||
|
| `g` | Jump to top |
|
||||||
|
| `c` | Copy response body to clipboard |
|
||||||
|
| `Esc/q` | Return to list |
|
||||||
|
|
||||||
|
**Body display features:**
|
||||||
|
- **JSON formatting** - JSON bodies are pretty-printed with syntax highlighting
|
||||||
|
- **Compression handling** - gzip/deflate content is automatically decompressed
|
||||||
|
- **Binary detection** - Binary content shows a placeholder instead of garbled data
|
||||||
|
|
||||||
|
**Filter modes:**
|
||||||
|
- **All** - Show all entries
|
||||||
|
- **Non-2xx** - Hide successful (2xx) responses
|
||||||
|
- **Errors** - Show only 4xx and 5xx responses
|
||||||
|
|
||||||
|
### Connection Benchmarking
|
||||||
|
|
||||||
|
Press `b` in the TUI to benchmark a selected forward. Configure:
|
||||||
|
|
||||||
|
- **URL Path** - Target endpoint (default: `/`)
|
||||||
|
- **Method** - HTTP method (GET, POST, etc.)
|
||||||
|
- **Concurrency** - Number of parallel workers
|
||||||
|
- **Requests** - Total number of requests
|
||||||
|
|
||||||
|
Results include:
|
||||||
|
- Success/failure counts
|
||||||
|
- Min/Max/Avg latency
|
||||||
|
- P50/P95/P99 percentiles
|
||||||
|
- Throughput (requests/sec)
|
||||||
|
- Status code distribution
|
||||||
|
|
||||||
|
### Hot-Reload
|
||||||
|
|
||||||
|
Configuration changes are applied automatically. Manual reload:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kportal --version
|
kill -HUP $(pgrep kportal)
|
||||||
# Output: kportal version 0.1.5
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔄 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
|
```bash
|
||||||
kportal --convert configs.json --convert-output .kportal.yaml
|
kportal --convert configs.json --convert-output .kportal.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
**Example conversion:**
|
## Signal Handling
|
||||||
|
|
||||||
kftray JSON:
|
- `Ctrl+C` / `SIGTERM` - Graceful shutdown
|
||||||
```json
|
- `SIGHUP` - Reload configuration
|
||||||
[
|
|
||||||
{
|
|
||||||
"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
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
### Port Already in Use
|
### Port Already in Use
|
||||||
|
|
||||||
**Problem**: `Port 8080: already in use by PID 1234 (chrome)`
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
```bash
|
```bash
|
||||||
# Find the process
|
lsof -i :<port>
|
||||||
lsof -i :8080
|
kill <pid>
|
||||||
|
|
||||||
# Kill the process
|
|
||||||
kill 1234
|
|
||||||
|
|
||||||
# Or use a different local port in config
|
|
||||||
local_port: 8081
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Connection Refused
|
### Connection Refused
|
||||||
|
|
||||||
**Problem**: `dial tcp 127.0.0.1:8080: connect: connection refused`
|
1. Verify pod is running: `kubectl get pods -n <namespace>`
|
||||||
|
2. Verify port is correct: `kubectl describe pod <pod>`
|
||||||
**Common causes**:
|
3. Check service endpoints: `kubectl get endpoints <service>`
|
||||||
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>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Context Not Found
|
### Context Not Found
|
||||||
|
|
||||||
**Problem**: `context "prod" not found in kubeconfig`
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
```bash
|
```bash
|
||||||
# List available contexts
|
|
||||||
kubectl config get-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:
|
- Go 1.23+
|
||||||
- Pod is running: `kubectl get pods`
|
- Kubernetes cluster access
|
||||||
- Port is correct in config
|
- kubectl configured
|
||||||
- Network connectivity to cluster
|
|
||||||
|
|
||||||
### 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
|
```bash
|
||||||
git commit -m "feat: add health check grace period" # Bumps minor version
|
make build # Build binary
|
||||||
git commit -m "fix: resolve port conflict detection" # Bumps patch version
|
make test # Run tests
|
||||||
git commit -m "breaking: change config file format" # Bumps major version
|
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
|
MIT License - see [LICENSE](LICENSE).
|
||||||
- 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)
|
|
||||||
|
|
||||||
## 📚 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)
|
- [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)
|
- [Releases](https://github.com/lukaszraczylo/kportal/releases)
|
||||||
- [Changelog](CHANGELOG.md)
|
- [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:
|
| Key | Action |
|
||||||
```bash
|
|-----|--------|
|
||||||
./kportal
|
| `a` | Add new forward |
|
||||||
```
|
| `d` | Delete forwards |
|
||||||
|
|
||||||
From the main view:
|
## ➕ Add Forward Wizard
|
||||||
- Press **`n`** to add a new port forward
|
|
||||||
- Press **`d`** to delete existing port forwards
|
|
||||||
|
|
||||||
## 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
|
1. **Context** - Select Kubernetes context
|
||||||
Choose from available Kubernetes contexts in your kubeconfig.
|
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
|
### Navigation
|
||||||
Pick the namespace where your resource lives.
|
|
||||||
|
|
||||||
### Step 3: Select Resource Type
|
| Key | Action |
|
||||||
Three options:
|
|-----|--------|
|
||||||
- **Pod (by name prefix)** - Forward to a specific pod by prefix matching
|
| `↑↓` / `j/k` | Navigate options |
|
||||||
- **Pod (by label selector)** - Forward to pods matching labels (survives restarts)
|
| `Enter` | Confirm and proceed |
|
||||||
- **Service** - Most stable, load-balanced option
|
| `Esc` | Go back / Cancel |
|
||||||
|
| `Ctrl+C` | Cancel immediately |
|
||||||
|
|
||||||
### Step 4: Enter Resource
|
## 🗑️ Delete Forward Wizard
|
||||||
- **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
|
|
||||||
|
|
||||||
The wizard shows real-time validation and matching resources!
|
Press `d` from the main view.
|
||||||
|
|
||||||
### Step 5: Remote Port
|
### Navigation
|
||||||
Enter the port number on the remote resource. The wizard displays detected ports from running containers.
|
|
||||||
|
|
||||||
### Step 6: Local Port
|
| Key | Action |
|
||||||
Enter the local port to bind to. The wizard checks availability in real-time.
|
|-----|--------|
|
||||||
|
| `↑↓` / `j/k` | Navigate |
|
||||||
|
| `Space` | Toggle selection |
|
||||||
|
| `a` | Select all |
|
||||||
|
| `n` | Deselect all |
|
||||||
|
| `Enter` | Confirm deletion |
|
||||||
|
| `Esc` | Cancel |
|
||||||
|
|
||||||
### Step 7: Confirmation
|
## 🎯 Resource Selection
|
||||||
Review your configuration and optionally add an alias (friendly name). Confirm to save!
|
|
||||||
|
|
||||||
### Navigation Keys
|
### Pod by Prefix
|
||||||
|
|
||||||
- **`↑`/`↓`** or **`j`/`k`** - Navigate options
|
Enter app name prefix to match pods:
|
||||||
- **`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:
|
|
||||||
- `nginx` matches `nginx-deployment-abc123`
|
- `nginx` matches `nginx-deployment-abc123`
|
||||||
- `postgres` matches `postgres-statefulset-0`
|
- `postgres` matches `postgres-statefulset-0`
|
||||||
|
|
||||||
### Label Selectors
|
### Pod by Selector
|
||||||
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!
|
|
||||||
|
|
||||||
### Aliases
|
Use Kubernetes label syntax:
|
||||||
Use aliases for cleaner UI display:
|
- `app=nginx`
|
||||||
- Instead of: `production/default/pod/nginx-deployment-abc123:80→8080`
|
- `app=nginx,env=prod`
|
||||||
- Shows as: `my-nginx:80→8080`
|
|
||||||
|
|
||||||
### Quick Selection
|
Matching pods are shown in real-time.
|
||||||
In list views, you can use `j`/`k` (Vim-style) or arrow keys for navigation.
|
|
||||||
|
|
||||||
## Example Workflow
|
### Service
|
||||||
|
|
||||||
Adding a forward for a PostgreSQL database:
|
Select from discovered services in the namespace.
|
||||||
|
|
||||||
1. Press `n` in main view
|
## 🔄 Auto Hot-Reload
|
||||||
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)
|
|
||||||
|
|
||||||
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:
|
The wizards handle:
|
||||||
- **Config Mutator**: Safe, atomic YAML writes (temp file + rename)
|
- Cluster unreachable - allows manual entry
|
||||||
- **K8s Discovery**: Lists contexts, namespaces, pods, services
|
- Port conflicts - shows which process is using the port
|
||||||
- **Modal Overlays**: Wizards appear centered over the main view
|
- Invalid selectors - real-time validation
|
||||||
- **Async Validation**: Port checks and selector validation run in background
|
- Duplicate ports - prevents conflicts
|
||||||
- **Hot-Reload Integration**: File watcher picks up changes automatically
|
|
||||||
|
|
||||||
## Troubleshooting
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
### Wizards not appearing?
|
### Wizard not appearing
|
||||||
Check that kportal can connect to your Kubernetes cluster:
|
|
||||||
|
Verify cluster connectivity:
|
||||||
```bash
|
```bash
|
||||||
kubectl cluster-info
|
kubectl cluster-info
|
||||||
```
|
```
|
||||||
|
|
||||||
### Port check showing wrong status?
|
### Port validation delayed
|
||||||
The port check happens asynchronously. Wait a moment after typing for validation.
|
|
||||||
|
|
||||||
### Changes not appearing?
|
Port checks run asynchronously. Wait briefly after typing.
|
||||||
The file watcher triggers within 100ms. If changes aren't visible, check:
|
|
||||||
|
### Changes not visible
|
||||||
|
|
||||||
|
Check:
|
||||||
1. `.kportal.yaml` was written correctly
|
1. `.kportal.yaml` was written correctly
|
||||||
2. No validation errors in the file
|
2. No validation errors in file
|
||||||
3. kportal process is still running
|
3. kportal process is 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)
|
|
||||||
|
|||||||
+300
-26
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -16,9 +18,12 @@ import (
|
|||||||
"github.com/nvm/kportal/internal/config"
|
"github.com/nvm/kportal/internal/config"
|
||||||
"github.com/nvm/kportal/internal/converter"
|
"github.com/nvm/kportal/internal/converter"
|
||||||
"github.com/nvm/kportal/internal/forward"
|
"github.com/nvm/kportal/internal/forward"
|
||||||
|
"github.com/nvm/kportal/internal/httplog"
|
||||||
"github.com/nvm/kportal/internal/k8s"
|
"github.com/nvm/kportal/internal/k8s"
|
||||||
"github.com/nvm/kportal/internal/logger"
|
"github.com/nvm/kportal/internal/logger"
|
||||||
|
"github.com/nvm/kportal/internal/mdns"
|
||||||
"github.com/nvm/kportal/internal/ui"
|
"github.com/nvm/kportal/internal/ui"
|
||||||
|
"github.com/nvm/kportal/internal/version"
|
||||||
"k8s.io/klog/v2"
|
"k8s.io/klog/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,24 +31,52 @@ const (
|
|||||||
defaultConfigFile = ".kportal.yaml"
|
defaultConfigFile = ".kportal.yaml"
|
||||||
initialForwardSettleTime = 100 * time.Millisecond
|
initialForwardSettleTime = 100 * time.Millisecond
|
||||||
tableUpdateInterval = 2 * time.Second
|
tableUpdateInterval = 2 * time.Second
|
||||||
|
|
||||||
|
// GitHub repository info for update checks
|
||||||
|
githubOwner = "lukaszraczylo"
|
||||||
|
githubRepo = "kportal"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
configFile = flag.String("c", defaultConfigFile, "Path to configuration file")
|
configFile = flag.String("c", defaultConfigFile, "Path to configuration file")
|
||||||
verbose = flag.Bool("v", false, "Enable verbose logging")
|
verbose = flag.Bool("v", false, "Enable verbose logging")
|
||||||
|
headless = flag.Bool("headless", false, "Run in headless mode (no UI, for background/daemon use)")
|
||||||
logFormat = flag.String("log-format", "text", "Log format: text or json")
|
logFormat = flag.String("log-format", "text", "Log format: text or json")
|
||||||
check = flag.Bool("check", false, "Validate configuration and exit")
|
check = flag.Bool("check", false, "Validate configuration and exit")
|
||||||
showVersion = flag.Bool("version", false, "Show version 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)")
|
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")
|
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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// promptCreateConfig asks the user if they want to create a new config file.
|
||||||
|
// Returns true if the user answers yes, false otherwise.
|
||||||
|
func promptCreateConfig(path string) bool {
|
||||||
|
fmt.Printf("Configuration file not found: %s\n", path)
|
||||||
|
fmt.Print("Would you like to create an empty configuration? [Y/n] ")
|
||||||
|
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
response, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
response = strings.TrimSpace(strings.ToLower(response))
|
||||||
|
// Empty response (just Enter) defaults to yes
|
||||||
|
return response == "" || response == "y" || response == "yes"
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if *showVersion {
|
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)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +111,7 @@ func main() {
|
|||||||
logOutput = os.Stderr
|
logOutput = os.Stderr
|
||||||
} else {
|
} else {
|
||||||
logLevel = logger.LevelInfo
|
logLevel = logger.LevelInfo
|
||||||
logOutput = io.Discard // Silence logger in non-verbose mode to prevent UI corruption
|
logOutput = io.Discard // Silence logger in non-verbose/headless mode to prevent UI corruption
|
||||||
}
|
}
|
||||||
|
|
||||||
switch *logFormat {
|
switch *logFormat {
|
||||||
@@ -158,14 +191,38 @@ func main() {
|
|||||||
|
|
||||||
// Load configuration
|
// Load configuration
|
||||||
cfg, err := config.LoadConfig(*configFile)
|
cfg, err := config.LoadConfig(*configFile)
|
||||||
|
configIsNew := false
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
if err == config.ErrConfigNotFound {
|
||||||
os.Exit(1)
|
// Config file doesn't exist - offer to create it
|
||||||
|
if !promptCreateConfig(*configFile) {
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
// Create empty config file
|
||||||
|
if err := config.CreateEmptyConfigFile(*configFile); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error creating config file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Created %s\n", *configFile)
|
||||||
|
fmt.Println("Use 'n' in the UI to add port forwards, or edit the file manually.")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Load the newly created config
|
||||||
|
cfg, err = config.LoadConfig(*configFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
configIsNew = true
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate configuration
|
// Validate configuration (allow empty configs for newly created files)
|
||||||
validator := config.NewValidator()
|
validator := config.NewValidator()
|
||||||
if errs := validator.ValidateConfig(cfg); len(errs) > 0 {
|
if errs := validator.ValidateConfigWithOptions(cfg, configIsNew || cfg.IsEmpty()); len(errs) > 0 {
|
||||||
fmt.Fprint(os.Stderr, config.FormatValidationErrors(errs))
|
fmt.Fprint(os.Stderr, config.FormatValidationErrors(errs))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -177,7 +234,7 @@ func main() {
|
|||||||
|
|
||||||
// Only log startup messages in verbose mode
|
// Only log startup messages in verbose mode
|
||||||
if *verbose {
|
if *verbose {
|
||||||
log.Printf("kportal v%s", version)
|
log.Printf("kportal v%s", appVersion)
|
||||||
log.Printf("Loading configuration from: %s", *configFile)
|
log.Printf("Loading configuration from: %s", *configFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,29 +254,118 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create UI (bubbletea for interactive, simple table for verbose)
|
// 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 based on mode:
|
||||||
|
// - headless: no UI at all (background daemon)
|
||||||
|
// - verbose: simple table UI with logging
|
||||||
|
// - default: interactive bubbletea TUI
|
||||||
var bubbleTeaUI *ui.BubbleTeaUI
|
var bubbleTeaUI *ui.BubbleTeaUI
|
||||||
var tableUI *ui.TableUI
|
var tableUI *ui.TableUI
|
||||||
|
|
||||||
if !*verbose {
|
if *headless {
|
||||||
|
// Headless mode - no UI, just run forwards in background
|
||||||
|
// StatusUI remains nil, manager will handle this gracefully
|
||||||
|
if *verbose {
|
||||||
|
log.Printf("Running in headless mode with verbose logging")
|
||||||
|
}
|
||||||
|
} else if *verbose {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
// Interactive mode with bubbletea
|
// Interactive mode with bubbletea
|
||||||
bubbleTeaUI = ui.NewBubbleTeaUI(func(id string, enable bool) {
|
bubbleTeaUI = ui.NewBubbleTeaUI(func(id string, enable bool) {
|
||||||
if enable {
|
if enable {
|
||||||
manager.EnableForward(id)
|
_ = manager.EnableForward(id)
|
||||||
} else {
|
} else {
|
||||||
manager.DisableForward(id)
|
_ = manager.DisableForward(id)
|
||||||
}
|
}
|
||||||
}, version)
|
}, appVersion)
|
||||||
|
|
||||||
// Set wizard dependencies
|
// Set wizard dependencies
|
||||||
// Note: mutator is always available (for delete/edit), discovery requires valid kubeconfig (for add)
|
// Note: mutator is always available (for delete/edit), discovery requires valid kubeconfig (for add)
|
||||||
bubbleTeaUI.SetWizardDependencies(discovery, mutator, *configFile)
|
bubbleTeaUI.SetWizardDependencies(discovery, mutator, *configFile)
|
||||||
|
|
||||||
|
// Set HTTP log subscriber to enable live log viewing
|
||||||
|
bubbleTeaUI.SetHTTPLogSubscriber(func(forwardID string, callback func(entry ui.HTTPLogEntry)) func() {
|
||||||
|
worker := manager.GetWorker(forwardID)
|
||||||
|
if worker == nil {
|
||||||
|
return func() {} // No-op cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := worker.GetHTTPProxy()
|
||||||
|
if proxy == nil {
|
||||||
|
return func() {} // HTTP logging not enabled for this forward
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyLogger := proxy.GetLogger()
|
||||||
|
if proxyLogger == nil {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to log entries
|
||||||
|
proxyLogger.AddCallback(func(entry httplog.Entry) {
|
||||||
|
uiEntry := ui.HTTPLogEntry{
|
||||||
|
RequestID: entry.RequestID,
|
||||||
|
Timestamp: entry.Timestamp.Format("15:04:05"),
|
||||||
|
Direction: entry.Direction,
|
||||||
|
Method: entry.Method,
|
||||||
|
Path: entry.Path,
|
||||||
|
StatusCode: entry.StatusCode,
|
||||||
|
LatencyMs: entry.LatencyMs,
|
||||||
|
BodySize: entry.BodySize,
|
||||||
|
Error: entry.Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate headers based on direction
|
||||||
|
if entry.Direction == "request" {
|
||||||
|
uiEntry.RequestHeaders = entry.Headers
|
||||||
|
uiEntry.RequestBody = entry.Body
|
||||||
|
} else if entry.Direction == "response" {
|
||||||
|
uiEntry.ResponseHeaders = entry.Headers
|
||||||
|
uiEntry.ResponseBody = entry.Body
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(uiEntry)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return func() {
|
||||||
|
proxyLogger.ClearCallbacks()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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)
|
manager.SetStatusUI(bubbleTeaUI)
|
||||||
} else {
|
|
||||||
// Verbose mode with simple table
|
|
||||||
tableUI = ui.NewTableUI(*verbose)
|
|
||||||
manager.SetStatusUI(tableUI)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start forwards
|
// Start forwards
|
||||||
@@ -228,7 +374,90 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if *verbose {
|
if *headless {
|
||||||
|
// Headless mode - no UI, run as background daemon
|
||||||
|
// Setup signal handling
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
|
||||||
|
|
||||||
|
// Setup config watcher for hot-reload
|
||||||
|
watcher, err := config.NewWatcher(*configFile, func(newCfg *config.Config) error {
|
||||||
|
return manager.Reload(newCfg)
|
||||||
|
}, *verbose)
|
||||||
|
if err != nil {
|
||||||
|
if *verbose {
|
||||||
|
log.Printf("Warning: Failed to setup config watcher: %v", err)
|
||||||
|
log.Printf("Hot-reload will not be available")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
watcher.Start()
|
||||||
|
defer watcher.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
if *verbose {
|
||||||
|
log.Printf("Headless mode started. Press Ctrl+C to stop")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for signals
|
||||||
|
for {
|
||||||
|
sig := <-sigChan
|
||||||
|
switch sig {
|
||||||
|
case syscall.SIGHUP:
|
||||||
|
if *verbose {
|
||||||
|
log.Printf("Received SIGHUP, reloading configuration...")
|
||||||
|
}
|
||||||
|
newCfg, err := config.LoadConfig(*configFile)
|
||||||
|
if err != nil {
|
||||||
|
if *verbose {
|
||||||
|
log.Printf("Failed to reload config: %v", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if errs := validator.ValidateConfig(newCfg); len(errs) > 0 {
|
||||||
|
if *verbose {
|
||||||
|
log.Printf("Config validation failed:")
|
||||||
|
log.Print(config.FormatValidationErrors(errs))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := manager.Reload(newCfg); err != nil {
|
||||||
|
if *verbose {
|
||||||
|
log.Printf("Failed to reload: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case os.Interrupt, syscall.SIGTERM:
|
||||||
|
if *verbose {
|
||||||
|
log.Printf("Received shutdown signal, stopping...")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown with timeout
|
||||||
|
shutdownDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
manager.Stop()
|
||||||
|
close(shutdownDone)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-shutdownDone:
|
||||||
|
if *verbose {
|
||||||
|
log.Printf("Graceful shutdown complete")
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
if *verbose {
|
||||||
|
log.Printf("Shutdown timed out, forcing exit...")
|
||||||
|
}
|
||||||
|
case sig := <-sigChan:
|
||||||
|
if *verbose {
|
||||||
|
log.Printf("Received second signal (%v), forcing exit...", sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if *verbose {
|
||||||
// Verbose mode - use simple table with periodic updates
|
// Verbose mode - use simple table with periodic updates
|
||||||
tableUI.RenderInitial()
|
tableUI.RenderInitial()
|
||||||
|
|
||||||
@@ -283,19 +512,44 @@ func main() {
|
|||||||
|
|
||||||
case os.Interrupt, syscall.SIGTERM:
|
case os.Interrupt, syscall.SIGTERM:
|
||||||
log.Printf("Received shutdown signal, stopping...")
|
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)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Interactive mode with bubbletea
|
// Interactive mode with bubbletea
|
||||||
// Setup config watcher in background
|
// Setup config watcher in background
|
||||||
watcher, err := config.NewWatcher(*configFile, func(newCfg *config.Config) error {
|
var watcher *config.Watcher
|
||||||
|
watcher, err = config.NewWatcher(*configFile, func(newCfg *config.Config) error {
|
||||||
return manager.Reload(newCfg)
|
return manager.Reload(newCfg)
|
||||||
}, *verbose)
|
}, *verbose)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
watcher.Start()
|
watcher.Start()
|
||||||
defer watcher.Stop()
|
}
|
||||||
|
|
||||||
|
// Cleanup function to ensure all resources are released
|
||||||
|
cleanup := func() {
|
||||||
|
bubbleTeaUI.Stop()
|
||||||
|
manager.Stop()
|
||||||
|
if watcher != nil {
|
||||||
|
watcher.Stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup signal handler for clean shutdown
|
// Setup signal handler for clean shutdown
|
||||||
@@ -303,8 +557,7 @@ func main() {
|
|||||||
sigChan := make(chan os.Signal, 1)
|
sigChan := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||||
<-sigChan
|
<-sigChan
|
||||||
bubbleTeaUI.Stop()
|
cleanup()
|
||||||
manager.Stop()
|
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -314,11 +567,32 @@ func main() {
|
|||||||
// Start the bubbletea app (blocks until quit)
|
// Start the bubbletea app (blocks until quit)
|
||||||
if err := bubbleTeaUI.Start(); err != nil {
|
if err := bubbleTeaUI.Start(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Failed to start UI: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Failed to start UI: %v\n", err)
|
||||||
manager.Stop()
|
cleanup()
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean shutdown
|
// Clean shutdown (normal exit via UI quit)
|
||||||
manager.Stop()
|
cleanup()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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').")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
kportal.raczylo.com
|
||||||
+753
-676
File diff suppressed because it is too large
Load Diff
@@ -6,52 +6,55 @@ require (
|
|||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/fsnotify/fsnotify v1.9.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
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/term v0.37.0
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
k8s.io/api v0.34.2
|
k8s.io/api v0.34.3
|
||||||
k8s.io/apimachinery v0.34.2
|
k8s.io/apimachinery v0.34.3
|
||||||
k8s.io/client-go v0.34.2
|
k8s.io/client-go v0.34.3
|
||||||
k8s.io/klog/v2 v2.130.1
|
k8s.io/klog/v2 v2.130.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.3.3 // indirect
|
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.1 // indirect
|
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.3 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.6.0 // indirect
|
github.com/clipperhouse/displaywidth v0.6.1 // indirect
|
||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0 // 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.4 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
github.com/go-openapi/jsonreference v0.21.4 // indirect
|
||||||
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
github.com/go-openapi/swag v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag v0.25.3 // indirect
|
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/cmdutils v0.25.3 // indirect
|
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/conv v0.25.3 // indirect
|
github.com/go-openapi/swag/fileutils v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/fileutils v0.25.3 // indirect
|
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/jsonname v0.25.3 // indirect
|
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/jsonutils v0.25.3 // indirect
|
github.com/go-openapi/swag/loading v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/loading v0.25.3 // indirect
|
github.com/go-openapi/swag/mangling v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/mangling v0.25.3 // indirect
|
github.com/go-openapi/swag/netutils v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/netutils v0.25.3 // indirect
|
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/stringutils v0.25.3 // indirect
|
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/typeutils v0.25.3 // indirect
|
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.3 // indirect
|
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/google/gnostic-models v0.7.1 // indirect
|
github.com/google/gnostic-models v0.7.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
|
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
|
github.com/miekg/dns v1.1.69 // indirect
|
||||||
github.com/moby/spdystream v0.5.0 // indirect
|
github.com/moby/spdystream v0.5.0 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||||
@@ -67,15 +70,19 @@ require (
|
|||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/mod v0.31.0 // indirect
|
||||||
golang.org/x/oauth2 v0.33.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
|
golang.org/x/term v0.38.0 // indirect
|
||||||
|
golang.org/x/text v0.32.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
|
golang.org/x/tools v0.40.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||||
gopkg.in/inf.v0 v0.9.1 // 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
|
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
|
||||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||||
|
|||||||
@@ -2,24 +2,31 @@ 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/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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||||
|
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||||
|
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 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
|
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||||
github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
|
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
github.com/charmbracelet/x/ansi v0.11.1 h1:iXAC8SyMQDJgtcz9Jnw+HU8WMEctHzoTAETIeA3JXMk=
|
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
|
||||||
github.com/charmbracelet/x/ansi v0.11.1/go.mod h1:M49wjzpIujwPceJ+t5w3qh2i87+HRtHohgb5iTyepL0=
|
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
|
||||||
|
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
|
github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY=
|
||||||
github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
github.com/clipperhouse/displaywidth v0.6.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
||||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -33,36 +40,36 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa
|
|||||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
|
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||||
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
|
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
||||||
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
|
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
|
||||||
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
|
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
|
||||||
github.com/go-openapi/swag v0.25.3 h1:FAa5wJXyDtI7yUztKDfZxDrSx+8WTg31MfCQ9s3PV+s=
|
github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
|
||||||
github.com/go-openapi/swag v0.25.3/go.mod h1:tX9vI8Mj8Ny+uCEk39I1QADvIPI7lkndX4qCsEqhkS8=
|
github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
|
||||||
github.com/go-openapi/swag/cmdutils v0.25.3 h1:EIwGxN143JCThNHnqfqs85R8lJcJG06qjJRZp3VvjLI=
|
github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=
|
||||||
github.com/go-openapi/swag/cmdutils v0.25.3/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
|
github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
|
||||||
github.com/go-openapi/swag/conv v0.25.3 h1:PcB18wwfba7MN5BVlBIV+VxvUUeC2kEuCEyJ2/t2X7E=
|
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
|
||||||
github.com/go-openapi/swag/conv v0.25.3/go.mod h1:n4Ibfwhn8NJnPXNRhBO5Cqb9ez7alBR40JS4rbASUPU=
|
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
||||||
github.com/go-openapi/swag/fileutils v0.25.3 h1:P52Uhd7GShkeU/a1cBOuqIcHMHBrA54Z2t5fLlE85SQ=
|
github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y=
|
||||||
github.com/go-openapi/swag/fileutils v0.25.3/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk=
|
github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk=
|
||||||
github.com/go-openapi/swag/jsonname v0.25.3 h1:U20VKDS74HiPaLV7UZkztpyVOw3JNVsit+w+gTXRj0A=
|
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||||
github.com/go-openapi/swag/jsonname v0.25.3/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||||
github.com/go-openapi/swag/jsonutils v0.25.3 h1:kV7wer79KXUM4Ea4tBdAVTU842Rg6tWstX3QbM4fGdw=
|
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
||||||
github.com/go-openapi/swag/jsonutils v0.25.3/go.mod h1:ILcKqe4HC1VEZmJx51cVuZQ6MF8QvdfXsQfiaCs0z9o=
|
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
||||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3 h1:/i3E9hBujtXfHy91rjtwJ7Fgv5TuDHgnSrYjhFxwxOw=
|
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
|
||||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3/go.mod h1:8kYfCR2rHyOj25HVvxL5Nm8wkfzggddgjZm6RgjT8Ao=
|
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
|
||||||
github.com/go-openapi/swag/loading v0.25.3 h1:Nn65Zlzf4854MY6Ft0JdNrtnHh2bdcS/tXckpSnOb2Y=
|
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
||||||
github.com/go-openapi/swag/loading v0.25.3/go.mod h1:xajJ5P4Ang+cwM5gKFrHBgkEDWfLcsAKepIuzTmOb/c=
|
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
||||||
github.com/go-openapi/swag/mangling v0.25.3 h1:rGIrEzXaYWuUW1MkFmG3pcH+EIA0/CoUkQnIyB6TUyo=
|
github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48=
|
||||||
github.com/go-openapi/swag/mangling v0.25.3/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg=
|
github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg=
|
||||||
github.com/go-openapi/swag/netutils v0.25.3 h1:XWXHZfL/65ABiv8rvGp9dtE0C6QHTYkCrNV77jTl358=
|
github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0=
|
||||||
github.com/go-openapi/swag/netutils v0.25.3/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=
|
github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=
|
||||||
github.com/go-openapi/swag/stringutils v0.25.3 h1:nAmWq1fUTWl/XiaEPwALjp/8BPZJun70iDHRNq/sH6w=
|
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
||||||
github.com/go-openapi/swag/stringutils v0.25.3/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||||
github.com/go-openapi/swag/typeutils v0.25.3 h1:2w4mEEo7DQt3V4veWMZw0yTPQibiL3ri2fdDV4t2TQc=
|
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
||||||
github.com/go-openapi/swag/typeutils v0.25.3/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.3 h1:LKTJjCn/W1ZfMec0XDL4Vxh8kyAnv1orH5F2OREDUrg=
|
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
||||||
github.com/go-openapi/swag/yamlutils v0.25.3/go.mod h1:Y7QN6Wc5DOBXK14/xeo1cQlq0EA0wvLoSv13gDQoCao=
|
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
||||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
||||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
||||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||||
@@ -82,6 +89,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/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 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
@@ -98,6 +107,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-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 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
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.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc=
|
||||||
|
github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g=
|
||||||
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
|
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/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=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -120,12 +132,14 @@ 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/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 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
||||||
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
@@ -149,40 +163,49 @@ 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/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 h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
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.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||||
|
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
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-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-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.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||||
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
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-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.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.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-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-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-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.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
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/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-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-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-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.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
@@ -198,16 +221,16 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
|||||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY=
|
k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4=
|
||||||
k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw=
|
k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk=
|
||||||
k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4=
|
k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE=
|
||||||
k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
|
k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
|
||||||
k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M=
|
k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A=
|
||||||
k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE=
|
k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM=
|
||||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
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-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ=
|
||||||
k8s.io/kube-openapi v0.0.0-20251121143641-b6aabc6c6745/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
|
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 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
|
||||||
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package benchmark
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Results holds the aggregated results of a benchmark run
|
||||||
|
type Results struct {
|
||||||
|
ForwardID string `json:"forward_id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
StartTime time.Time `json:"start_time"`
|
||||||
|
EndTime time.Time `json:"end_time"`
|
||||||
|
TotalRequests int `json:"total_requests"`
|
||||||
|
Successful int `json:"successful"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Latencies []time.Duration `json:"-"` // Raw latencies for percentile calculation
|
||||||
|
StatusCodes map[int]int `json:"status_codes"`
|
||||||
|
Errors map[string]int `json:"errors,omitempty"`
|
||||||
|
BytesRead int64 `json:"bytes_read"`
|
||||||
|
BytesWritten int64 `json:"bytes_written"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats holds calculated statistics
|
||||||
|
type Stats struct {
|
||||||
|
MinLatency time.Duration `json:"min_latency_ms"`
|
||||||
|
MaxLatency time.Duration `json:"max_latency_ms"`
|
||||||
|
AvgLatency time.Duration `json:"avg_latency_ms"`
|
||||||
|
P50Latency time.Duration `json:"p50_latency_ms"`
|
||||||
|
P95Latency time.Duration `json:"p95_latency_ms"`
|
||||||
|
P99Latency time.Duration `json:"p99_latency_ms"`
|
||||||
|
Throughput float64 `json:"throughput_rps"`
|
||||||
|
Duration time.Duration `json:"duration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewResults creates a new Results instance
|
||||||
|
func NewResults(forwardID, url, method string) *Results {
|
||||||
|
return &Results{
|
||||||
|
ForwardID: forwardID,
|
||||||
|
URL: url,
|
||||||
|
Method: method,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
Latencies: make([]time.Duration, 0),
|
||||||
|
StatusCodes: make(map[int]int),
|
||||||
|
Errors: make(map[string]int),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordSuccess records a successful HTTP request (transport succeeded)
|
||||||
|
// Note: only 2xx status codes are counted as successful for statistics
|
||||||
|
func (r *Results) RecordSuccess(statusCode int, latency time.Duration, bytesRead, bytesWritten int64) {
|
||||||
|
r.TotalRequests++
|
||||||
|
// Only count 2xx as successful
|
||||||
|
if statusCode >= 200 && statusCode < 300 {
|
||||||
|
r.Successful++
|
||||||
|
} else {
|
||||||
|
r.Failed++
|
||||||
|
}
|
||||||
|
r.Latencies = append(r.Latencies, latency)
|
||||||
|
r.StatusCodes[statusCode]++
|
||||||
|
r.BytesRead += bytesRead
|
||||||
|
r.BytesWritten += bytesWritten
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordFailure records a failed request
|
||||||
|
func (r *Results) RecordFailure(err error, latency time.Duration) {
|
||||||
|
r.TotalRequests++
|
||||||
|
r.Failed++
|
||||||
|
r.Latencies = append(r.Latencies, latency)
|
||||||
|
r.Errors[err.Error()]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize marks the benchmark as complete
|
||||||
|
func (r *Results) Finalize() {
|
||||||
|
r.EndTime = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateStats calculates statistics from the results
|
||||||
|
func (r *Results) CalculateStats() Stats {
|
||||||
|
stats := Stats{
|
||||||
|
Duration: r.EndTime.Sub(r.StartTime),
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(r.Latencies) == 0 {
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort latencies for percentile calculation
|
||||||
|
sorted := make([]time.Duration, len(r.Latencies))
|
||||||
|
copy(sorted, r.Latencies)
|
||||||
|
sort.Slice(sorted, func(i, j int) bool {
|
||||||
|
return sorted[i] < sorted[j]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate min, max, avg
|
||||||
|
var total time.Duration
|
||||||
|
stats.MinLatency = sorted[0]
|
||||||
|
stats.MaxLatency = sorted[len(sorted)-1]
|
||||||
|
|
||||||
|
for _, lat := range sorted {
|
||||||
|
total += lat
|
||||||
|
}
|
||||||
|
stats.AvgLatency = total / time.Duration(len(sorted))
|
||||||
|
|
||||||
|
// Calculate percentiles
|
||||||
|
stats.P50Latency = percentile(sorted, 50)
|
||||||
|
stats.P95Latency = percentile(sorted, 95)
|
||||||
|
stats.P99Latency = percentile(sorted, 99)
|
||||||
|
|
||||||
|
// Calculate throughput
|
||||||
|
if stats.Duration > 0 {
|
||||||
|
stats.Throughput = float64(r.TotalRequests) / stats.Duration.Seconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// percentile calculates the p-th percentile of sorted durations
|
||||||
|
func percentile(sorted []time.Duration, p int) time.Duration {
|
||||||
|
if len(sorted) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := (p * len(sorted)) / 100
|
||||||
|
if idx >= len(sorted) {
|
||||||
|
idx = len(sorted) - 1
|
||||||
|
}
|
||||||
|
return sorted[idx]
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package benchmark
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProgressCallback is called periodically with benchmark progress
|
||||||
|
type ProgressCallback func(completed, total int)
|
||||||
|
|
||||||
|
// Config holds the benchmark configuration
|
||||||
|
type Config struct {
|
||||||
|
URL string // Target URL
|
||||||
|
Method string // HTTP method
|
||||||
|
Headers map[string]string // Custom headers
|
||||||
|
Body []byte // Request body
|
||||||
|
Concurrency int // Number of concurrent workers
|
||||||
|
Requests int // Total number of requests (0 = use duration)
|
||||||
|
Duration time.Duration // Duration to run (0 = use requests)
|
||||||
|
Timeout time.Duration // Request timeout
|
||||||
|
ProgressCallback ProgressCallback // Optional callback for progress updates
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns a default benchmark configuration
|
||||||
|
func DefaultConfig() Config {
|
||||||
|
return Config{
|
||||||
|
Method: "GET",
|
||||||
|
Concurrency: 10,
|
||||||
|
Requests: 100,
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner executes HTTP benchmarks
|
||||||
|
type Runner struct {
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRunner creates a new benchmark runner
|
||||||
|
func NewRunner() *Runner {
|
||||||
|
return &Runner{
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
MaxIdleConnsPerHost: 100,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes the benchmark and returns results
|
||||||
|
func (r *Runner) Run(ctx context.Context, forwardID string, cfg Config) (*Results, error) {
|
||||||
|
if cfg.URL == "" {
|
||||||
|
return nil, fmt.Errorf("URL is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Concurrency < 1 {
|
||||||
|
cfg.Concurrency = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure concurrency doesn't exceed number of requests (for request-based mode)
|
||||||
|
if cfg.Duration == 0 && cfg.Requests > 0 && cfg.Concurrency > cfg.Requests {
|
||||||
|
cfg.Concurrency = cfg.Requests
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Timeout > 0 {
|
||||||
|
r.client.Timeout = cfg.Timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
results := NewResults(forwardID, cfg.URL, cfg.Method)
|
||||||
|
|
||||||
|
// Create work channel
|
||||||
|
workCh := make(chan struct{}, cfg.Concurrency*2)
|
||||||
|
|
||||||
|
// Create context for cancellation
|
||||||
|
runCtx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Start workers
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var completed int64
|
||||||
|
var resultsMu sync.Mutex // Shared mutex for results access
|
||||||
|
|
||||||
|
for i := 0; i < cfg.Concurrency; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
r.worker(runCtx, cfg, results, &resultsMu, workCh, &completed)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start progress reporter if callback is provided
|
||||||
|
if cfg.ProgressCallback != nil {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(100 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-runCtx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
cfg.ProgressCallback(int(atomic.LoadInt64(&completed)), cfg.Requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine how to dispatch work
|
||||||
|
if cfg.Duration > 0 {
|
||||||
|
// Duration-based: keep sending work until duration expires
|
||||||
|
timer := time.NewTimer(cfg.Duration)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
dispatchLoop:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
cancel()
|
||||||
|
break dispatchLoop
|
||||||
|
case <-ctx.Done():
|
||||||
|
cancel()
|
||||||
|
break dispatchLoop
|
||||||
|
case workCh <- struct{}{}:
|
||||||
|
// Work dispatched
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Request-based: send exactly N requests
|
||||||
|
requestLoop:
|
||||||
|
for i := 0; i < cfg.Requests; i++ {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
cancel()
|
||||||
|
break requestLoop
|
||||||
|
case workCh <- struct{}{}:
|
||||||
|
// Work dispatched
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close work channel and wait for workers
|
||||||
|
close(workCh)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
results.Finalize()
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// worker processes requests from the work channel
|
||||||
|
func (r *Runner) worker(ctx context.Context, cfg Config, results *Results, resultsMu *sync.Mutex, workCh <-chan struct{}, completed *int64) {
|
||||||
|
for range workCh {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
statusCode, bytesRead, bytesWritten, err := r.makeRequestSafe(ctx, cfg)
|
||||||
|
latency := time.Since(start)
|
||||||
|
|
||||||
|
resultsMu.Lock()
|
||||||
|
if err != nil {
|
||||||
|
results.RecordFailure(err, latency)
|
||||||
|
} else {
|
||||||
|
results.RecordSuccess(statusCode, latency, bytesRead, bytesWritten)
|
||||||
|
}
|
||||||
|
resultsMu.Unlock()
|
||||||
|
|
||||||
|
atomic.AddInt64(completed, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeRequestSafe wraps makeRequest with panic recovery
|
||||||
|
func (r *Runner) makeRequestSafe(ctx context.Context, cfg Config) (statusCode int, bytesRead, bytesWritten int64, err error) {
|
||||||
|
defer func() {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
err = fmt.Errorf("request panic: %v", rec)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return r.makeRequest(ctx, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeRequest makes a single HTTP request
|
||||||
|
func (r *Runner) makeRequest(ctx context.Context, cfg Config) (statusCode int, bytesRead, bytesWritten int64, err error) {
|
||||||
|
var body io.Reader
|
||||||
|
if len(cfg.Body) > 0 {
|
||||||
|
body = bytes.NewReader(cfg.Body)
|
||||||
|
bytesWritten = int64(len(cfg.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, cfg.Method, cfg.URL, body)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
for k, v := range cfg.Headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := r.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, bytesWritten, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Read response body to measure bytes
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return resp.StatusCode, 0, bytesWritten, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.StatusCode, int64(len(respBody)), bytesWritten, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package benchmark
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResults(t *testing.T) {
|
||||||
|
r := NewResults("test-forward", "http://localhost/test", "GET")
|
||||||
|
|
||||||
|
// Record some 2xx successes
|
||||||
|
r.RecordSuccess(200, 10*time.Millisecond, 100, 0)
|
||||||
|
r.RecordSuccess(200, 20*time.Millisecond, 150, 0)
|
||||||
|
r.RecordSuccess(201, 15*time.Millisecond, 120, 0)
|
||||||
|
|
||||||
|
// Record a transport failure
|
||||||
|
r.RecordFailure(assert.AnError, 5*time.Millisecond)
|
||||||
|
|
||||||
|
r.Finalize()
|
||||||
|
|
||||||
|
assert.Equal(t, 4, r.TotalRequests)
|
||||||
|
assert.Equal(t, 3, r.Successful)
|
||||||
|
assert.Equal(t, 1, r.Failed)
|
||||||
|
assert.Equal(t, int64(370), r.BytesRead)
|
||||||
|
assert.Equal(t, 2, r.StatusCodes[200])
|
||||||
|
assert.Equal(t, 1, r.StatusCodes[201])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResultsNon2xxCountsAsFailure(t *testing.T) {
|
||||||
|
r := NewResults("test-forward", "http://localhost/test", "GET")
|
||||||
|
|
||||||
|
// Record a 200 success
|
||||||
|
r.RecordSuccess(200, 10*time.Millisecond, 100, 0)
|
||||||
|
|
||||||
|
// Record 4xx and 5xx - these should count as failures
|
||||||
|
r.RecordSuccess(404, 10*time.Millisecond, 50, 0)
|
||||||
|
r.RecordSuccess(500, 10*time.Millisecond, 30, 0)
|
||||||
|
|
||||||
|
r.Finalize()
|
||||||
|
|
||||||
|
assert.Equal(t, 3, r.TotalRequests)
|
||||||
|
assert.Equal(t, 1, r.Successful, "Only 2xx should count as successful")
|
||||||
|
assert.Equal(t, 2, r.Failed, "4xx and 5xx should count as failed")
|
||||||
|
assert.Equal(t, 1, r.StatusCodes[200])
|
||||||
|
assert.Equal(t, 1, r.StatusCodes[404])
|
||||||
|
assert.Equal(t, 1, r.StatusCodes[500])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResultsStats(t *testing.T) {
|
||||||
|
r := NewResults("test", "http://localhost", "GET")
|
||||||
|
|
||||||
|
// Add latencies
|
||||||
|
latencies := []time.Duration{
|
||||||
|
10 * time.Millisecond,
|
||||||
|
20 * time.Millisecond,
|
||||||
|
30 * time.Millisecond,
|
||||||
|
40 * time.Millisecond,
|
||||||
|
50 * time.Millisecond,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, lat := range latencies {
|
||||||
|
r.RecordSuccess(200, lat, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.EndTime = r.StartTime.Add(1 * time.Second)
|
||||||
|
|
||||||
|
stats := r.CalculateStats()
|
||||||
|
|
||||||
|
assert.Equal(t, 10*time.Millisecond, stats.MinLatency)
|
||||||
|
assert.Equal(t, 50*time.Millisecond, stats.MaxLatency)
|
||||||
|
assert.Equal(t, 30*time.Millisecond, stats.AvgLatency)
|
||||||
|
assert.Equal(t, float64(5), stats.Throughput)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPercentile(t *testing.T) {
|
||||||
|
sorted := []time.Duration{
|
||||||
|
1 * time.Millisecond,
|
||||||
|
2 * time.Millisecond,
|
||||||
|
3 * time.Millisecond,
|
||||||
|
4 * time.Millisecond,
|
||||||
|
5 * time.Millisecond,
|
||||||
|
6 * time.Millisecond,
|
||||||
|
7 * time.Millisecond,
|
||||||
|
8 * time.Millisecond,
|
||||||
|
9 * time.Millisecond,
|
||||||
|
10 * time.Millisecond,
|
||||||
|
}
|
||||||
|
|
||||||
|
// P50 = index 5 (50*10/100 = 5) = 6ms
|
||||||
|
assert.Equal(t, 6*time.Millisecond, percentile(sorted, 50))
|
||||||
|
// P95 = index 9 (95*10/100 = 9) = 10ms
|
||||||
|
assert.Equal(t, 10*time.Millisecond, percentile(sorted, 95))
|
||||||
|
// P99 = index 9 (99*10/100 = 9) = 10ms
|
||||||
|
assert.Equal(t, 10*time.Millisecond, percentile(sorted, 99))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunner(t *testing.T) {
|
||||||
|
// Create a test server
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
time.Sleep(5 * time.Millisecond) // Simulate some latency
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"status":"ok"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
runner := NewRunner()
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
URL: server.URL,
|
||||||
|
Method: "GET",
|
||||||
|
Concurrency: 2,
|
||||||
|
Requests: 10,
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := runner.Run(context.Background(), "test-forward", cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 10, results.TotalRequests)
|
||||||
|
assert.Equal(t, 10, results.Successful)
|
||||||
|
assert.Equal(t, 0, results.Failed)
|
||||||
|
assert.Equal(t, 10, results.StatusCodes[200])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerWithDuration(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`ok`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
runner := NewRunner()
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
URL: server.URL,
|
||||||
|
Method: "GET",
|
||||||
|
Concurrency: 2,
|
||||||
|
Duration: 100 * time.Millisecond,
|
||||||
|
Timeout: 1 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := runner.Run(context.Background(), "test-forward", cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Should have made some requests in 100ms
|
||||||
|
assert.Greater(t, results.TotalRequests, 0)
|
||||||
|
assert.Equal(t, results.Successful, results.StatusCodes[200])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerWithHeaders(t *testing.T) {
|
||||||
|
var receivedHeader string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
receivedHeader = r.Header.Get("X-Custom-Header")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
runner := NewRunner()
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
URL: server.URL,
|
||||||
|
Method: "GET",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"X-Custom-Header": "test-value",
|
||||||
|
},
|
||||||
|
Concurrency: 1,
|
||||||
|
Requests: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := runner.Run(context.Background(), "test", cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "test-value", receivedHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerWithBody(t *testing.T) {
|
||||||
|
var receivedBody string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := http.MaxBytesReader(w, r.Body, 1024).Read(make([]byte, 1024))
|
||||||
|
_ = body
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
runner := NewRunner()
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
URL: server.URL,
|
||||||
|
Method: "POST",
|
||||||
|
Body: []byte(`{"test":"data"}`),
|
||||||
|
Concurrency: 1,
|
||||||
|
Requests: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := runner.Run(context.Background(), "test", cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_ = receivedBody // Used for debugging
|
||||||
|
assert.Equal(t, int64(15), results.BytesWritten)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultConfig(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
|
assert.Equal(t, "GET", cfg.Method)
|
||||||
|
assert.Equal(t, 10, cfg.Concurrency)
|
||||||
|
assert.Equal(t, 100, cfg.Requests)
|
||||||
|
assert.Equal(t, 30*time.Second, cfg.Timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerWithProgressCallback(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
time.Sleep(10 * time.Millisecond) // Add small delay so progress ticker can fire
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`ok`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
runner := NewRunner()
|
||||||
|
|
||||||
|
var progressUpdates []int
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
URL: server.URL,
|
||||||
|
Method: "GET",
|
||||||
|
Concurrency: 5,
|
||||||
|
Requests: 50, // More requests to ensure progress callbacks fire
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
ProgressCallback: func(completed, total int) {
|
||||||
|
mu.Lock()
|
||||||
|
progressUpdates = append(progressUpdates, completed)
|
||||||
|
mu.Unlock()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := runner.Run(context.Background(), "test-forward", cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 50, results.TotalRequests)
|
||||||
|
|
||||||
|
// Should have received some progress updates (ticker fires every 100ms)
|
||||||
|
mu.Lock()
|
||||||
|
updates := len(progressUpdates)
|
||||||
|
mu.Unlock()
|
||||||
|
assert.Greater(t, updates, 0, "Should have received progress updates")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerConcurrencyCappedAtRequests(t *testing.T) {
|
||||||
|
requestCount := 0
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
requestCount++
|
||||||
|
mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
runner := NewRunner()
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
URL: server.URL,
|
||||||
|
Method: "GET",
|
||||||
|
Concurrency: 100, // Higher than requests
|
||||||
|
Requests: 5,
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := runner.Run(context.Background(), "test", cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 5, results.TotalRequests)
|
||||||
|
}
|
||||||
+202
-44
@@ -1,15 +1,37 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrConfigNotFound is returned when the configuration file does not exist
|
||||||
|
var ErrConfigNotFound = fmt.Errorf("config file not found")
|
||||||
|
|
||||||
const (
|
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
|
||||||
|
|
||||||
|
// Default HTTP logging settings
|
||||||
|
DefaultHTTPLogMaxBodySize = 1024 * 1024 // 1MB max body size for logging
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config represents the root configuration structure from .kportal.yaml
|
// Config represents the root configuration structure from .kportal.yaml
|
||||||
@@ -17,6 +39,13 @@ type Config struct {
|
|||||||
Contexts []Context `yaml:"contexts"`
|
Contexts []Context `yaml:"contexts"`
|
||||||
HealthCheck *HealthCheckSpec `yaml:"healthCheck,omitempty"`
|
HealthCheck *HealthCheckSpec `yaml:"healthCheck,omitempty"`
|
||||||
Reliability *ReliabilitySpec `yaml:"reliability,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
|
// HealthCheckSpec configures health check behavior
|
||||||
@@ -36,24 +65,31 @@ type ReliabilitySpec struct {
|
|||||||
WatchdogPeriod string `yaml:"watchdogPeriod,omitempty"` // e.g., "30s" - goroutine watchdog interval
|
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
|
// GetHealthCheckIntervalOrDefault returns the health check interval or default value
|
||||||
func (c *Config) GetHealthCheckIntervalOrDefault() time.Duration {
|
func (c *Config) GetHealthCheckIntervalOrDefault() time.Duration {
|
||||||
if c.HealthCheck != nil && c.HealthCheck.Interval != "" {
|
if c.HealthCheck == nil {
|
||||||
if d, err := time.ParseDuration(c.HealthCheck.Interval); err == nil {
|
return DefaultHealthCheckInterval
|
||||||
return d
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return 3 * time.Second // Default: check every 3 seconds
|
return parseDurationOrDefault(c.HealthCheck.Interval, DefaultHealthCheckInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHealthCheckTimeoutOrDefault returns the health check timeout or default value
|
// GetHealthCheckTimeoutOrDefault returns the health check timeout or default value
|
||||||
func (c *Config) GetHealthCheckTimeoutOrDefault() time.Duration {
|
func (c *Config) GetHealthCheckTimeoutOrDefault() time.Duration {
|
||||||
if c.HealthCheck != nil && c.HealthCheck.Timeout != "" {
|
if c.HealthCheck == nil {
|
||||||
if d, err := time.ParseDuration(c.HealthCheck.Timeout); err == nil {
|
return DefaultHealthCheckTimeout
|
||||||
return d
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return 2 * time.Second // Default: 2 second timeout
|
return parseDurationOrDefault(c.HealthCheck.Timeout, DefaultHealthCheckTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHealthCheckMethod returns the health check method or default
|
// GetHealthCheckMethod returns the health check method or default
|
||||||
@@ -61,37 +97,31 @@ func (c *Config) GetHealthCheckMethod() string {
|
|||||||
if c.HealthCheck != nil && c.HealthCheck.Method != "" {
|
if c.HealthCheck != nil && c.HealthCheck.Method != "" {
|
||||||
return c.HealthCheck.Method
|
return c.HealthCheck.Method
|
||||||
}
|
}
|
||||||
return "data-transfer" // Default: more reliable data transfer test
|
return DefaultHealthCheckMethod
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMaxConnectionAge returns the max connection age or default
|
// GetMaxConnectionAge returns the max connection age or default
|
||||||
func (c *Config) GetMaxConnectionAge() time.Duration {
|
func (c *Config) GetMaxConnectionAge() time.Duration {
|
||||||
if c.HealthCheck != nil && c.HealthCheck.MaxConnectionAge != "" {
|
if c.HealthCheck == nil {
|
||||||
if d, err := time.ParseDuration(c.HealthCheck.MaxConnectionAge); err == nil {
|
return DefaultMaxConnectionAge
|
||||||
return d
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return 25 * time.Minute // Default: 25 minutes (before typical 30min k8s timeout)
|
return parseDurationOrDefault(c.HealthCheck.MaxConnectionAge, DefaultMaxConnectionAge)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMaxIdleTime returns the max idle time or default
|
// GetMaxIdleTime returns the max idle time or default
|
||||||
func (c *Config) GetMaxIdleTime() time.Duration {
|
func (c *Config) GetMaxIdleTime() time.Duration {
|
||||||
if c.HealthCheck != nil && c.HealthCheck.MaxIdleTime != "" {
|
if c.HealthCheck == nil {
|
||||||
if d, err := time.ParseDuration(c.HealthCheck.MaxIdleTime); err == nil {
|
return DefaultMaxIdleTime
|
||||||
return d
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return 10 * time.Minute // Default: 10 minutes idle before reconnect
|
return parseDurationOrDefault(c.HealthCheck.MaxIdleTime, DefaultMaxIdleTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTCPKeepalive returns the TCP keepalive duration or default
|
// GetTCPKeepalive returns the TCP keepalive duration or default
|
||||||
func (c *Config) GetTCPKeepalive() time.Duration {
|
func (c *Config) GetTCPKeepalive() time.Duration {
|
||||||
if c.Reliability != nil && c.Reliability.TCPKeepalive != "" {
|
if c.Reliability == nil {
|
||||||
if d, err := time.ParseDuration(c.Reliability.TCPKeepalive); err == nil {
|
return DefaultTCPKeepalive
|
||||||
return d
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return 30 * time.Second // Default: 30 second keepalive
|
return parseDurationOrDefault(c.Reliability.TCPKeepalive, DefaultTCPKeepalive)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRetryOnStale returns whether to retry on stale connections
|
// GetRetryOnStale returns whether to retry on stale connections
|
||||||
@@ -104,22 +134,23 @@ func (c *Config) GetRetryOnStale() bool {
|
|||||||
|
|
||||||
// GetWatchdogPeriod returns the goroutine watchdog check period or default
|
// GetWatchdogPeriod returns the goroutine watchdog check period or default
|
||||||
func (c *Config) GetWatchdogPeriod() time.Duration {
|
func (c *Config) GetWatchdogPeriod() time.Duration {
|
||||||
if c.Reliability != nil && c.Reliability.WatchdogPeriod != "" {
|
if c.Reliability == nil {
|
||||||
if d, err := time.ParseDuration(c.Reliability.WatchdogPeriod); err == nil {
|
return DefaultWatchdogPeriod
|
||||||
return d
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return 30 * time.Second // Default: check every 30 seconds
|
return parseDurationOrDefault(c.Reliability.WatchdogPeriod, DefaultWatchdogPeriod)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDialTimeout returns the connection dial timeout or default
|
// GetDialTimeout returns the connection dial timeout or default
|
||||||
func (c *Config) GetDialTimeout() time.Duration {
|
func (c *Config) GetDialTimeout() time.Duration {
|
||||||
if c.Reliability != nil && c.Reliability.DialTimeout != "" {
|
if c.Reliability == nil {
|
||||||
if d, err := time.ParseDuration(c.Reliability.DialTimeout); err == nil {
|
return DefaultDialTimeout
|
||||||
return d
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return 30 * time.Second // Default: 30 second dial timeout
|
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
|
// Context represents a Kubernetes context with its namespaces
|
||||||
@@ -134,14 +165,44 @@ type Namespace struct {
|
|||||||
Forwards []Forward `yaml:"forwards"`
|
Forwards []Forward `yaml:"forwards"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTTPLogSpec configures HTTP traffic logging for a forward
|
||||||
|
type HTTPLogSpec struct {
|
||||||
|
Enabled bool `yaml:"enabled"` // Enable HTTP logging
|
||||||
|
LogFile string `yaml:"logFile,omitempty"` // Output file (empty = stdout)
|
||||||
|
MaxBodySize int `yaml:"maxBodySize,omitempty"` // Max body size to log (default 1MB)
|
||||||
|
IncludeHeaders bool `yaml:"includeHeaders,omitempty"` // Include headers in log
|
||||||
|
FilterPath string `yaml:"filterPath,omitempty"` // Optional glob filter for paths
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalYAML implements custom unmarshaling to support both bool and struct formats
|
||||||
|
// Allows: httpLog: true OR httpLog: { enabled: true, ... }
|
||||||
|
func (h *HTTPLogSpec) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
||||||
|
// First try to unmarshal as a boolean
|
||||||
|
var boolVal bool
|
||||||
|
if err := unmarshal(&boolVal); err == nil {
|
||||||
|
h.Enabled = boolVal
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise try to unmarshal as a struct
|
||||||
|
type httpLogSpecAlias HTTPLogSpec // Use alias to avoid infinite recursion
|
||||||
|
var spec httpLogSpecAlias
|
||||||
|
if err := unmarshal(&spec); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*h = HTTPLogSpec(spec)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Forward represents a single port-forward configuration
|
// Forward represents a single port-forward configuration
|
||||||
type Forward struct {
|
type Forward struct {
|
||||||
Resource string `yaml:"resource"` // e.g., "pod/my-app", "service/postgres", "pod"
|
Resource string `yaml:"resource"` // e.g., "pod/my-app", "service/postgres", "pod"
|
||||||
Selector string `yaml:"selector"` // Label selector for pod resolution (e.g., "app=nginx,env=prod")
|
Selector string `yaml:"selector"` // Label selector for pod resolution (e.g., "app=nginx,env=prod")
|
||||||
Protocol string `yaml:"protocol"` // tcp or udp
|
Protocol string `yaml:"protocol"` // tcp or udp
|
||||||
Port int `yaml:"port"` // Remote port
|
Port int `yaml:"port"` // Remote port
|
||||||
LocalPort int `yaml:"localPort"` // Local port
|
LocalPort int `yaml:"localPort"` // Local port
|
||||||
Alias string `yaml:"alias,omitempty"` // Optional human-readable alias for this forward
|
Alias string `yaml:"alias,omitempty"` // Optional human-readable alias for this forward
|
||||||
|
HTTPLog *HTTPLogSpec `yaml:"httpLog,omitempty"` // Optional HTTP traffic logging
|
||||||
|
|
||||||
// Runtime fields (not in YAML)
|
// Runtime fields (not in YAML)
|
||||||
contextName string
|
contextName string
|
||||||
@@ -188,11 +249,46 @@ func (f *Forward) GetNamespace() string {
|
|||||||
return f.namespaceName
|
return f.namespaceName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsHTTPLogEnabled returns true if HTTP logging is enabled for this forward
|
||||||
|
func (f *Forward) IsHTTPLogEnabled() bool {
|
||||||
|
return f.HTTPLog != nil && f.HTTPLog.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHTTPLogMaxBodySize returns the max body size for HTTP logging
|
||||||
|
func (f *Forward) GetHTTPLogMaxBodySize() int {
|
||||||
|
if f.HTTPLog == nil || f.HTTPLog.MaxBodySize <= 0 {
|
||||||
|
return DefaultHTTPLogMaxBodySize
|
||||||
|
}
|
||||||
|
return f.HTTPLog.MaxBodySize
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
// LoadConfig loads and parses the configuration file from the given path.
|
||||||
func LoadConfig(path string) (*Config, error) {
|
func LoadConfig(path string) (*Config, error) {
|
||||||
// Validate file size before reading
|
// Validate file size before reading
|
||||||
fileInfo, err := os.Stat(path)
|
fileInfo, err := os.Stat(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, ErrConfigNotFound
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("failed to stat config file: %w", err)
|
return nil, fmt.Errorf("failed to stat config file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,6 +296,7 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
return nil, fmt.Errorf("config file too large: %d bytes (max %d)", fileInfo.Size(), maxConfigSize)
|
return nil, fmt.Errorf("config file too large: %d bytes (max %d)", fileInfo.Size(), maxConfigSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #nosec G304 -- path is validated in main.go (no system dirs, absolute path)
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||||
@@ -209,9 +306,15 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ParseConfig parses YAML configuration data into a Config struct.
|
// 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) {
|
func ParseConfig(data []byte) (*Config, error) {
|
||||||
var cfg Config
|
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)
|
return nil, fmt.Errorf("failed to parse YAML: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,3 +345,58 @@ func (c *Config) GetAllForwards() []Forward {
|
|||||||
|
|
||||||
return forwards
|
return forwards
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewEmptyConfig returns a minimal empty configuration with no forwards.
|
||||||
|
// This is used when creating a new config file for the first time.
|
||||||
|
func NewEmptyConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
Contexts: []Context{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmpty returns true if the configuration has no forwards defined.
|
||||||
|
func (c *Config) IsEmpty() bool {
|
||||||
|
return len(c.Contexts) == 0 || len(c.GetAllForwards()) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateEmptyConfigFile creates a new empty configuration file at the given path.
|
||||||
|
// Returns an error if the file already exists or cannot be created.
|
||||||
|
func CreateEmptyConfigFile(path string) error {
|
||||||
|
// Check if file already exists
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
return fmt.Errorf("config file already exists: %s", path)
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("failed to check config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := NewEmptyConfig()
|
||||||
|
data, err := yaml.Marshal(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal empty config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a helpful comment header
|
||||||
|
header := `# kportal configuration file
|
||||||
|
# Add port forwards using the 'n' key in the TUI, or manually add them below.
|
||||||
|
#
|
||||||
|
# Example forward:
|
||||||
|
# contexts:
|
||||||
|
# - name: my-cluster
|
||||||
|
# namespaces:
|
||||||
|
# - name: default
|
||||||
|
# forwards:
|
||||||
|
# - resource: service/my-service
|
||||||
|
# protocol: tcp
|
||||||
|
# port: 8080
|
||||||
|
# localPort: 8080
|
||||||
|
#
|
||||||
|
`
|
||||||
|
content := header + string(data)
|
||||||
|
|
||||||
|
// Write with restrictive permissions (0600)
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,703 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestParseDurationOrDefault tests the duration parsing helper
|
||||||
|
func TestParseDurationOrDefault(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
defaultDur time.Duration
|
||||||
|
expected time.Duration
|
||||||
|
}{
|
||||||
|
{"empty string returns default", "", 5 * time.Second, 5 * time.Second},
|
||||||
|
{"valid duration seconds", "3s", 5 * time.Second, 3 * time.Second},
|
||||||
|
{"valid duration minutes", "25m", 5 * time.Second, 25 * time.Minute},
|
||||||
|
{"valid duration hours", "1h", 5 * time.Second, 1 * time.Hour},
|
||||||
|
{"valid duration milliseconds", "100ms", 5 * time.Second, 100 * time.Millisecond},
|
||||||
|
{"invalid duration returns default", "invalid", 5 * time.Second, 5 * time.Second},
|
||||||
|
{"missing unit returns default", "30", 5 * time.Second, 5 * time.Second},
|
||||||
|
{"negative duration", "-5s", 5 * time.Second, -5 * time.Second}, // time.ParseDuration accepts negative
|
||||||
|
{"complex duration", "1h30m", 5 * time.Second, 1*time.Hour + 30*time.Minute},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := parseDurationOrDefault(tt.value, tt.defaultDur)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfig_GetHealthCheckIntervalOrDefault tests health check interval getter
|
||||||
|
func TestConfig_GetHealthCheckIntervalOrDefault(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config *Config
|
||||||
|
expected time.Duration
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil health check returns default",
|
||||||
|
config: &Config{},
|
||||||
|
expected: DefaultHealthCheckInterval,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty interval returns default",
|
||||||
|
config: &Config{
|
||||||
|
HealthCheck: &HealthCheckSpec{},
|
||||||
|
},
|
||||||
|
expected: DefaultHealthCheckInterval,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid interval",
|
||||||
|
config: &Config{
|
||||||
|
HealthCheck: &HealthCheckSpec{Interval: "5s"},
|
||||||
|
},
|
||||||
|
expected: 5 * time.Second,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid interval returns default",
|
||||||
|
config: &Config{
|
||||||
|
HealthCheck: &HealthCheckSpec{Interval: "invalid"},
|
||||||
|
},
|
||||||
|
expected: DefaultHealthCheckInterval,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := tt.config.GetHealthCheckIntervalOrDefault()
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfig_GetHealthCheckTimeoutOrDefault tests health check timeout getter
|
||||||
|
func TestConfig_GetHealthCheckTimeoutOrDefault(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config *Config
|
||||||
|
expected time.Duration
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil health check returns default",
|
||||||
|
config: &Config{},
|
||||||
|
expected: DefaultHealthCheckTimeout,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty timeout returns default",
|
||||||
|
config: &Config{
|
||||||
|
HealthCheck: &HealthCheckSpec{},
|
||||||
|
},
|
||||||
|
expected: DefaultHealthCheckTimeout,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid timeout",
|
||||||
|
config: &Config{
|
||||||
|
HealthCheck: &HealthCheckSpec{Timeout: "1s"},
|
||||||
|
},
|
||||||
|
expected: 1 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := tt.config.GetHealthCheckTimeoutOrDefault()
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfig_GetHealthCheckMethod tests health check method getter
|
||||||
|
func TestConfig_GetHealthCheckMethod(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config *Config
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil health check returns default",
|
||||||
|
config: &Config{},
|
||||||
|
expected: DefaultHealthCheckMethod,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty method returns default",
|
||||||
|
config: &Config{
|
||||||
|
HealthCheck: &HealthCheckSpec{},
|
||||||
|
},
|
||||||
|
expected: DefaultHealthCheckMethod,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tcp-dial method",
|
||||||
|
config: &Config{
|
||||||
|
HealthCheck: &HealthCheckSpec{Method: "tcp-dial"},
|
||||||
|
},
|
||||||
|
expected: "tcp-dial",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "data-transfer method",
|
||||||
|
config: &Config{
|
||||||
|
HealthCheck: &HealthCheckSpec{Method: "data-transfer"},
|
||||||
|
},
|
||||||
|
expected: "data-transfer",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := tt.config.GetHealthCheckMethod()
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfig_GetMaxConnectionAge tests max connection age getter
|
||||||
|
func TestConfig_GetMaxConnectionAge(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config *Config
|
||||||
|
expected time.Duration
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil health check returns default",
|
||||||
|
config: &Config{},
|
||||||
|
expected: DefaultMaxConnectionAge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty max age returns default",
|
||||||
|
config: &Config{
|
||||||
|
HealthCheck: &HealthCheckSpec{},
|
||||||
|
},
|
||||||
|
expected: DefaultMaxConnectionAge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid max age",
|
||||||
|
config: &Config{
|
||||||
|
HealthCheck: &HealthCheckSpec{MaxConnectionAge: "20m"},
|
||||||
|
},
|
||||||
|
expected: 20 * time.Minute,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := tt.config.GetMaxConnectionAge()
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfig_GetMaxIdleTime tests max idle time getter
|
||||||
|
func TestConfig_GetMaxIdleTime(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config *Config
|
||||||
|
expected time.Duration
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil health check returns default",
|
||||||
|
config: &Config{},
|
||||||
|
expected: DefaultMaxIdleTime,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty max idle returns default",
|
||||||
|
config: &Config{
|
||||||
|
HealthCheck: &HealthCheckSpec{},
|
||||||
|
},
|
||||||
|
expected: DefaultMaxIdleTime,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid max idle",
|
||||||
|
config: &Config{
|
||||||
|
HealthCheck: &HealthCheckSpec{MaxIdleTime: "5m"},
|
||||||
|
},
|
||||||
|
expected: 5 * time.Minute,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := tt.config.GetMaxIdleTime()
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfig_GetTCPKeepalive tests TCP keepalive getter
|
||||||
|
func TestConfig_GetTCPKeepalive(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config *Config
|
||||||
|
expected time.Duration
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil reliability returns default",
|
||||||
|
config: &Config{},
|
||||||
|
expected: DefaultTCPKeepalive,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty keepalive returns default",
|
||||||
|
config: &Config{
|
||||||
|
Reliability: &ReliabilitySpec{},
|
||||||
|
},
|
||||||
|
expected: DefaultTCPKeepalive,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid keepalive",
|
||||||
|
config: &Config{
|
||||||
|
Reliability: &ReliabilitySpec{TCPKeepalive: "15s"},
|
||||||
|
},
|
||||||
|
expected: 15 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := tt.config.GetTCPKeepalive()
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfig_GetRetryOnStale tests retry on stale getter
|
||||||
|
func TestConfig_GetRetryOnStale(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config *Config
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil reliability returns default true",
|
||||||
|
config: &Config{},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit false",
|
||||||
|
config: &Config{
|
||||||
|
Reliability: &ReliabilitySpec{RetryOnStale: false},
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit true",
|
||||||
|
config: &Config{
|
||||||
|
Reliability: &ReliabilitySpec{RetryOnStale: true},
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := tt.config.GetRetryOnStale()
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfig_GetWatchdogPeriod tests watchdog period getter
|
||||||
|
func TestConfig_GetWatchdogPeriod(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config *Config
|
||||||
|
expected time.Duration
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil reliability returns default",
|
||||||
|
config: &Config{},
|
||||||
|
expected: DefaultWatchdogPeriod,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty period returns default",
|
||||||
|
config: &Config{
|
||||||
|
Reliability: &ReliabilitySpec{},
|
||||||
|
},
|
||||||
|
expected: DefaultWatchdogPeriod,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid period",
|
||||||
|
config: &Config{
|
||||||
|
Reliability: &ReliabilitySpec{WatchdogPeriod: "1m"},
|
||||||
|
},
|
||||||
|
expected: 1 * time.Minute,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := tt.config.GetWatchdogPeriod()
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfig_GetDialTimeout tests dial timeout getter
|
||||||
|
func TestConfig_GetDialTimeout(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config *Config
|
||||||
|
expected time.Duration
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil reliability returns default",
|
||||||
|
config: &Config{},
|
||||||
|
expected: DefaultDialTimeout,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty timeout returns default",
|
||||||
|
config: &Config{
|
||||||
|
Reliability: &ReliabilitySpec{},
|
||||||
|
},
|
||||||
|
expected: DefaultDialTimeout,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid timeout",
|
||||||
|
config: &Config{
|
||||||
|
Reliability: &ReliabilitySpec{DialTimeout: "10s"},
|
||||||
|
},
|
||||||
|
expected: 10 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := tt.config.GetDialTimeout()
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfig_IsMDNSEnabled tests mDNS enabled getter
|
||||||
|
func TestConfig_IsMDNSEnabled(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config *Config
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil MDNS returns false",
|
||||||
|
config: &Config{},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "MDNS disabled",
|
||||||
|
config: &Config{
|
||||||
|
MDNS: &MDNSSpec{Enabled: false},
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "MDNS enabled",
|
||||||
|
config: &Config{
|
||||||
|
MDNS: &MDNSSpec{Enabled: true},
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := tt.config.IsMDNSEnabled()
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForward_IsHTTPLogEnabled tests HTTP log enabled check
|
||||||
|
func TestForward_IsHTTPLogEnabled(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
forward Forward
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil HTTPLog",
|
||||||
|
forward: Forward{Resource: "pod/app", Port: 8080, LocalPort: 8080},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HTTPLog disabled",
|
||||||
|
forward: Forward{
|
||||||
|
Resource: "pod/app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
HTTPLog: &HTTPLogSpec{Enabled: false},
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HTTPLog enabled",
|
||||||
|
forward: Forward{
|
||||||
|
Resource: "pod/app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
HTTPLog: &HTTPLogSpec{Enabled: true},
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := tt.forward.IsHTTPLogEnabled()
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForward_GetHTTPLogMaxBodySize tests HTTP log max body size
|
||||||
|
func TestForward_GetHTTPLogMaxBodySize(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
forward Forward
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil HTTPLog returns default",
|
||||||
|
forward: Forward{Resource: "pod/app", Port: 8080, LocalPort: 8080},
|
||||||
|
expected: DefaultHTTPLogMaxBodySize,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero max body size returns default",
|
||||||
|
forward: Forward{
|
||||||
|
Resource: "pod/app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
HTTPLog: &HTTPLogSpec{MaxBodySize: 0},
|
||||||
|
},
|
||||||
|
expected: DefaultHTTPLogMaxBodySize,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative max body size returns default",
|
||||||
|
forward: Forward{
|
||||||
|
Resource: "pod/app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
HTTPLog: &HTTPLogSpec{MaxBodySize: -100},
|
||||||
|
},
|
||||||
|
expected: DefaultHTTPLogMaxBodySize,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom max body size",
|
||||||
|
forward: Forward{
|
||||||
|
Resource: "pod/app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
HTTPLog: &HTTPLogSpec{MaxBodySize: 2048},
|
||||||
|
},
|
||||||
|
expected: 2048,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := tt.forward.GetHTTPLogMaxBodySize()
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForward_GetMDNSAlias tests mDNS alias generation
|
||||||
|
func TestForward_GetMDNSAlias(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
forward Forward
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "explicit alias",
|
||||||
|
forward: Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
Alias: "my-custom-alias",
|
||||||
|
},
|
||||||
|
expected: "my-custom-alias",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pod with name - extracts name",
|
||||||
|
forward: Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
},
|
||||||
|
expected: "my-app",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "service with name - extracts name",
|
||||||
|
forward: Forward{
|
||||||
|
Resource: "service/postgres",
|
||||||
|
Port: 5432,
|
||||||
|
LocalPort: 5432,
|
||||||
|
},
|
||||||
|
expected: "postgres",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pod without name (selector-based) - returns empty",
|
||||||
|
forward: Forward{
|
||||||
|
Resource: "pod",
|
||||||
|
Selector: "app=nginx",
|
||||||
|
Port: 80,
|
||||||
|
LocalPort: 8080,
|
||||||
|
},
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty resource - returns empty",
|
||||||
|
forward: Forward{
|
||||||
|
Resource: "",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
},
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resource with empty name after slash",
|
||||||
|
forward: Forward{
|
||||||
|
Resource: "pod/",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
},
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := tt.forward.GetMDNSAlias()
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadConfig_FileTooLarge tests file size limit
|
||||||
|
func TestLoadConfig_FileTooLarge(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create a file larger than maxConfigSize (10MB)
|
||||||
|
// We'll use a smaller buffer to avoid memory issues
|
||||||
|
// Just verify the check happens by creating a file slightly over 10MB
|
||||||
|
largeData := make([]byte, 10*1024*1024+1) // 10MB + 1 byte
|
||||||
|
for i := range largeData {
|
||||||
|
largeData[i] = 'a'
|
||||||
|
}
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, largeData, 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, cfg)
|
||||||
|
assert.Contains(t, err.Error(), "config file too large")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadConfig_WithHealthCheckAndReliability tests parsing with all config sections
|
||||||
|
func TestLoadConfig_WithHealthCheckAndReliability(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
yaml := `contexts:
|
||||||
|
- name: dev
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
healthCheck:
|
||||||
|
interval: "5s"
|
||||||
|
timeout: "1s"
|
||||||
|
method: "tcp-dial"
|
||||||
|
maxConnectionAge: "20m"
|
||||||
|
maxIdleTime: "5m"
|
||||||
|
reliability:
|
||||||
|
tcpKeepalive: "15s"
|
||||||
|
dialTimeout: "10s"
|
||||||
|
retryOnStale: true
|
||||||
|
watchdogPeriod: "1m"
|
||||||
|
mdns:
|
||||||
|
enabled: true
|
||||||
|
`
|
||||||
|
|
||||||
|
err := os.WriteFile(configPath, []byte(yaml), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, cfg)
|
||||||
|
|
||||||
|
// Verify health check settings
|
||||||
|
assert.Equal(t, 5*time.Second, cfg.GetHealthCheckIntervalOrDefault())
|
||||||
|
assert.Equal(t, 1*time.Second, cfg.GetHealthCheckTimeoutOrDefault())
|
||||||
|
assert.Equal(t, "tcp-dial", cfg.GetHealthCheckMethod())
|
||||||
|
assert.Equal(t, 20*time.Minute, cfg.GetMaxConnectionAge())
|
||||||
|
assert.Equal(t, 5*time.Minute, cfg.GetMaxIdleTime())
|
||||||
|
|
||||||
|
// Verify reliability settings
|
||||||
|
assert.Equal(t, 15*time.Second, cfg.GetTCPKeepalive())
|
||||||
|
assert.Equal(t, 10*time.Second, cfg.GetDialTimeout())
|
||||||
|
assert.True(t, cfg.GetRetryOnStale())
|
||||||
|
assert.Equal(t, 1*time.Minute, cfg.GetWatchdogPeriod())
|
||||||
|
|
||||||
|
// Verify mDNS
|
||||||
|
assert.True(t, cfg.IsMDNSEnabled())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseConfig_RejectsUnknownKeys tests strict parsing
|
||||||
|
func TestParseConfig_RejectsUnknownKeys(t *testing.T) {
|
||||||
|
yaml := `contexts:
|
||||||
|
- name: dev
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
unknownKey: value
|
||||||
|
`
|
||||||
|
|
||||||
|
cfg, err := ParseConfig([]byte(yaml))
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, cfg)
|
||||||
|
assert.Contains(t, err.Error(), "failed to parse YAML")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTTPLogSpec_FullStruct tests full HTTPLogSpec parsing
|
||||||
|
func TestHTTPLogSpec_FullStruct(t *testing.T) {
|
||||||
|
yaml := `contexts:
|
||||||
|
- name: dev
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: service/api
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
httpLog:
|
||||||
|
enabled: true
|
||||||
|
logFile: "/tmp/http.log"
|
||||||
|
maxBodySize: 2048
|
||||||
|
includeHeaders: true
|
||||||
|
filterPath: "/api/*"
|
||||||
|
`
|
||||||
|
|
||||||
|
cfg, err := ParseConfig([]byte(yaml))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, cfg)
|
||||||
|
|
||||||
|
fwd := cfg.Contexts[0].Namespaces[0].Forwards[0]
|
||||||
|
require.NotNil(t, fwd.HTTPLog)
|
||||||
|
assert.True(t, fwd.HTTPLog.Enabled)
|
||||||
|
assert.Equal(t, "/tmp/http.log", fwd.HTTPLog.LogFile)
|
||||||
|
assert.Equal(t, 2048, fwd.HTTPLog.MaxBodySize)
|
||||||
|
assert.True(t, fwd.HTTPLog.IncludeHeaders)
|
||||||
|
assert.Equal(t, "/api/*", fwd.HTTPLog.FilterPath)
|
||||||
|
}
|
||||||
@@ -97,7 +97,7 @@ func TestLoadConfig_FileNotFound(t *testing.T) {
|
|||||||
cfg, err := LoadConfig("/non/existent/path/.kportal.yaml")
|
cfg, err := LoadConfig("/non/existent/path/.kportal.yaml")
|
||||||
assert.Error(t, err, "LoadConfig should fail with non-existent file")
|
assert.Error(t, err, "LoadConfig should fail with non-existent file")
|
||||||
assert.Nil(t, cfg, "config should be nil on error")
|
assert.Nil(t, cfg, "config should be nil on error")
|
||||||
assert.Contains(t, err.Error(), "failed to stat config file", "error should mention stat failure")
|
assert.Equal(t, ErrConfigNotFound, err, "should return ErrConfigNotFound")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestForward_ID(t *testing.T) {
|
func TestForward_ID(t *testing.T) {
|
||||||
@@ -313,3 +313,207 @@ func TestForward_SetContext(t *testing.T) {
|
|||||||
assert.Equal(t, "my-cluster", fwd.GetContext())
|
assert.Equal(t, "my-cluster", fwd.GetContext())
|
||||||
assert.Equal(t, "my-namespace", fwd.GetNamespace())
|
assert.Equal(t, "my-namespace", fwd.GetNamespace())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHTTPLogSpec_UnmarshalYAML(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
yaml string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "httpLog as boolean true",
|
||||||
|
yaml: `contexts:
|
||||||
|
- name: test
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: service/api
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
httpLog: true
|
||||||
|
`,
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "httpLog as boolean false",
|
||||||
|
yaml: `contexts:
|
||||||
|
- name: test
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: service/api
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
httpLog: false
|
||||||
|
`,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "httpLog as struct",
|
||||||
|
yaml: `contexts:
|
||||||
|
- name: test
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: service/api
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
httpLog:
|
||||||
|
enabled: true
|
||||||
|
includeHeaders: true
|
||||||
|
`,
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "httpLog not specified",
|
||||||
|
yaml: `contexts:
|
||||||
|
- name: test
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: service/api
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
`,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg, err := ParseConfig([]byte(tt.yaml))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, cfg)
|
||||||
|
|
||||||
|
fwd := cfg.Contexts[0].Namespaces[0].Forwards[0]
|
||||||
|
if tt.expected {
|
||||||
|
assert.NotNil(t, fwd.HTTPLog, "HTTPLog should not be nil")
|
||||||
|
assert.True(t, fwd.HTTPLog.Enabled, "HTTPLog.Enabled should be true")
|
||||||
|
} else {
|
||||||
|
if fwd.HTTPLog != nil {
|
||||||
|
assert.False(t, fwd.HTTPLog.Enabled, "HTTPLog.Enabled should be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewEmptyConfig(t *testing.T) {
|
||||||
|
cfg := NewEmptyConfig()
|
||||||
|
assert.NotNil(t, cfg, "NewEmptyConfig should return non-nil config")
|
||||||
|
assert.Empty(t, cfg.Contexts, "NewEmptyConfig should have empty contexts")
|
||||||
|
assert.True(t, cfg.IsEmpty(), "NewEmptyConfig should be considered empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfig_IsEmpty(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config *Config
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil contexts",
|
||||||
|
config: &Config{},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty contexts slice",
|
||||||
|
config: &Config{Contexts: []Context{}},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "context with empty namespaces",
|
||||||
|
config: &Config{
|
||||||
|
Contexts: []Context{
|
||||||
|
{Name: "test", Namespaces: []Namespace{}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "context with namespace but no forwards",
|
||||||
|
config: &Config{
|
||||||
|
Contexts: []Context{
|
||||||
|
{
|
||||||
|
Name: "test",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{Name: "default", Forwards: []Forward{}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "config with forward",
|
||||||
|
config: &Config{
|
||||||
|
Contexts: []Context{
|
||||||
|
{
|
||||||
|
Name: "test",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Forwards: []Forward{
|
||||||
|
{Resource: "pod/app", Port: 8080, LocalPort: 8080},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.expected, tt.config.IsEmpty())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateEmptyConfigFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create empty config file
|
||||||
|
err := CreateEmptyConfigFile(configPath)
|
||||||
|
assert.NoError(t, err, "CreateEmptyConfigFile should succeed")
|
||||||
|
|
||||||
|
// Verify file exists
|
||||||
|
_, err = os.Stat(configPath)
|
||||||
|
assert.NoError(t, err, "config file should exist")
|
||||||
|
|
||||||
|
// Verify file is readable and parseable
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
assert.NoError(t, err, "should be able to load created config")
|
||||||
|
assert.NotNil(t, cfg, "config should not be nil")
|
||||||
|
assert.True(t, cfg.IsEmpty(), "created config should be empty")
|
||||||
|
|
||||||
|
// Verify file permissions (0600)
|
||||||
|
info, _ := os.Stat(configPath)
|
||||||
|
assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "file should have 0600 permissions")
|
||||||
|
|
||||||
|
// Verify file contains helpful header
|
||||||
|
content, _ := os.ReadFile(configPath)
|
||||||
|
assert.Contains(t, string(content), "# kportal configuration file", "should contain header comment")
|
||||||
|
assert.Contains(t, string(content), "Example forward", "should contain example")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateEmptyConfigFile_AlreadyExists(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create existing file
|
||||||
|
err := os.WriteFile(configPath, []byte("existing content"), 0644)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Try to create config file - should fail
|
||||||
|
err = CreateEmptyConfigFile(configPath)
|
||||||
|
assert.Error(t, err, "CreateEmptyConfigFile should fail when file exists")
|
||||||
|
assert.Contains(t, err.Error(), "already exists")
|
||||||
|
|
||||||
|
// Verify original content is preserved
|
||||||
|
content, _ := os.ReadFile(configPath)
|
||||||
|
assert.Equal(t, "existing content", string(content))
|
||||||
|
}
|
||||||
|
|||||||
@@ -264,8 +264,8 @@ func (m *Mutator) writeAtomic(cfg *Config) error {
|
|||||||
|
|
||||||
// Atomic rename
|
// Atomic rename
|
||||||
if err := os.Rename(tmpFile, m.configPath); err != nil {
|
if err := os.Rename(tmpFile, m.configPath); err != nil {
|
||||||
// Clean up temp file on failure
|
// Clean up temp file on failure - error ignored as we're already handling the rename error
|
||||||
os.Remove(tmpFile)
|
_ = os.Remove(tmpFile)
|
||||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,664 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewMutator tests mutator creation
|
||||||
|
func TestNewMutator(t *testing.T) {
|
||||||
|
mutator := NewMutator("/path/to/config.yaml")
|
||||||
|
assert.NotNil(t, mutator)
|
||||||
|
assert.Equal(t, "/path/to/config.yaml", mutator.configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMutator_AddForward_NewFile tests adding a forward to a new file
|
||||||
|
// Note: Due to how LoadConfig wraps errors, os.IsNotExist check in AddForward
|
||||||
|
// doesn't work with wrapped errors. This documents the current behavior.
|
||||||
|
func TestMutator_AddForward_NewFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
mutator := NewMutator(configPath)
|
||||||
|
|
||||||
|
fwd := Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currently fails because LoadConfig wraps the error and os.IsNotExist doesn't match
|
||||||
|
err := mutator.AddForward("dev-cluster", "default", fwd)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "failed to load config")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMutator_AddForward_EmptyFile tests adding a forward to an empty file
|
||||||
|
func TestMutator_AddForward_EmptyFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create empty config file with minimal valid structure
|
||||||
|
initial := `contexts: []
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mutator := NewMutator(configPath)
|
||||||
|
|
||||||
|
fwd := Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mutator.AddForward("dev-cluster", "default", fwd)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify file was updated and contains the forward
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, cfg)
|
||||||
|
|
||||||
|
assert.Len(t, cfg.Contexts, 1)
|
||||||
|
assert.Equal(t, "dev-cluster", cfg.Contexts[0].Name)
|
||||||
|
assert.Len(t, cfg.Contexts[0].Namespaces, 1)
|
||||||
|
assert.Equal(t, "default", cfg.Contexts[0].Namespaces[0].Name)
|
||||||
|
assert.Len(t, cfg.Contexts[0].Namespaces[0].Forwards, 1)
|
||||||
|
assert.Equal(t, "pod/my-app", cfg.Contexts[0].Namespaces[0].Forwards[0].Resource)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMutator_AddForward_ExistingFile tests adding to existing config
|
||||||
|
func TestMutator_AddForward_ExistingFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create initial config
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev-cluster
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/existing-app
|
||||||
|
protocol: tcp
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mutator := NewMutator(configPath)
|
||||||
|
|
||||||
|
fwd := Forward{
|
||||||
|
Resource: "service/postgres",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Port: 5432,
|
||||||
|
LocalPort: 5432,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mutator.AddForward("dev-cluster", "default", fwd)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify both forwards exist
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Len(t, cfg.Contexts[0].Namespaces[0].Forwards, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMutator_AddForward_NewContext tests adding to new context
|
||||||
|
func TestMutator_AddForward_NewContext(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create initial config
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev-cluster
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app
|
||||||
|
protocol: tcp
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mutator := NewMutator(configPath)
|
||||||
|
|
||||||
|
fwd := Forward{
|
||||||
|
Resource: "pod/prod-app",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Port: 80,
|
||||||
|
LocalPort: 8081,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mutator.AddForward("prod-cluster", "production", fwd)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify new context was created
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Len(t, cfg.Contexts, 2)
|
||||||
|
assert.Equal(t, "prod-cluster", cfg.Contexts[1].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMutator_AddForward_DuplicatePort tests rejecting duplicate ports
|
||||||
|
func TestMutator_AddForward_DuplicatePort(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create initial config
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev-cluster
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app
|
||||||
|
protocol: tcp
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mutator := NewMutator(configPath)
|
||||||
|
|
||||||
|
fwd := Forward{
|
||||||
|
Resource: "pod/another-app",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Port: 9090,
|
||||||
|
LocalPort: 8080, // Duplicate local port
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mutator.AddForward("dev-cluster", "default", fwd)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "port 8080 is already in use")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMutator_AddForward_InvalidForward tests rejecting invalid forward
|
||||||
|
func TestMutator_AddForward_InvalidForward(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create initial config file
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev-cluster
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/existing-app
|
||||||
|
protocol: tcp
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mutator := NewMutator(configPath)
|
||||||
|
|
||||||
|
fwd := Forward{
|
||||||
|
Resource: "invalid/type/resource", // Invalid resource
|
||||||
|
Protocol: "tcp",
|
||||||
|
Port: 9090,
|
||||||
|
LocalPort: 9090,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mutator.AddForward("dev-cluster", "default", fwd)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "validation failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMutator_RemoveForwards tests removing forwards by predicate
|
||||||
|
func TestMutator_RemoveForwards(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create initial config with multiple forwards
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev-cluster
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app1
|
||||||
|
protocol: tcp
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
- resource: pod/app2
|
||||||
|
protocol: tcp
|
||||||
|
port: 8081
|
||||||
|
localPort: 8081
|
||||||
|
- resource: service/postgres
|
||||||
|
protocol: tcp
|
||||||
|
port: 5432
|
||||||
|
localPort: 5432
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mutator := NewMutator(configPath)
|
||||||
|
|
||||||
|
// Remove all pod resources
|
||||||
|
err = mutator.RemoveForwards(func(ctx, ns string, fwd Forward) bool {
|
||||||
|
return fwd.Resource == "pod/app1"
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the forward was removed
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Len(t, cfg.Contexts[0].Namespaces[0].Forwards, 2)
|
||||||
|
for _, fwd := range cfg.Contexts[0].Namespaces[0].Forwards {
|
||||||
|
assert.NotEqual(t, "pod/app1", fwd.Resource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMutator_RemoveForwards_RemovesEmptyNamespaces tests that empty namespaces are removed
|
||||||
|
func TestMutator_RemoveForwards_RemovesEmptyNamespaces(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create config with two namespaces
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev-cluster
|
||||||
|
namespaces:
|
||||||
|
- name: ns1
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app1
|
||||||
|
protocol: tcp
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
- name: ns2
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app2
|
||||||
|
protocol: tcp
|
||||||
|
port: 8081
|
||||||
|
localPort: 8081
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mutator := NewMutator(configPath)
|
||||||
|
|
||||||
|
// Remove all forwards from ns1
|
||||||
|
err = mutator.RemoveForwards(func(ctx, ns string, fwd Forward) bool {
|
||||||
|
return ns == "ns1"
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify ns1 was removed (has no forwards left)
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Len(t, cfg.Contexts[0].Namespaces, 1)
|
||||||
|
assert.Equal(t, "ns2", cfg.Contexts[0].Namespaces[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMutator_RemoveForwards_NonExistentFile tests removing from non-existent file
|
||||||
|
func TestMutator_RemoveForwards_NonExistentFile(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
mutator := NewMutator(configPath)
|
||||||
|
|
||||||
|
err := mutator.RemoveForwards(func(ctx, ns string, fwd Forward) bool {
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "failed to load config")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMutator_RemoveForwardByID tests removing a specific forward by ID
|
||||||
|
func TestMutator_RemoveForwardByID(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create initial config
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev-cluster
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app1
|
||||||
|
protocol: tcp
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
- resource: pod/app2
|
||||||
|
protocol: tcp
|
||||||
|
port: 8081
|
||||||
|
localPort: 8081
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mutator := NewMutator(configPath)
|
||||||
|
|
||||||
|
// Remove by ID
|
||||||
|
err = mutator.RemoveForwardByID("dev-cluster/default/pod/app1:8080")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the forward was removed
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Len(t, cfg.Contexts[0].Namespaces[0].Forwards, 1)
|
||||||
|
assert.Equal(t, "pod/app2", cfg.Contexts[0].Namespaces[0].Forwards[0].Resource)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMutator_UpdateForward tests updating an existing forward
|
||||||
|
func TestMutator_UpdateForward(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create initial config
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev-cluster
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app1
|
||||||
|
protocol: tcp
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mutator := NewMutator(configPath)
|
||||||
|
|
||||||
|
newFwd := Forward{
|
||||||
|
Resource: "pod/app1-updated",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Port: 9090,
|
||||||
|
LocalPort: 9090,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mutator.UpdateForward("dev-cluster/default/pod/app1:8080", "dev-cluster", "default", newFwd)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the forward was updated
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Len(t, cfg.Contexts[0].Namespaces[0].Forwards, 1)
|
||||||
|
assert.Equal(t, "pod/app1-updated", cfg.Contexts[0].Namespaces[0].Forwards[0].Resource)
|
||||||
|
assert.Equal(t, 9090, cfg.Contexts[0].Namespaces[0].Forwards[0].LocalPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMutator_UpdateForward_MoveToNewContext tests moving forward to new context
|
||||||
|
func TestMutator_UpdateForward_MoveToNewContext(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create initial config with multiple forwards (so removing one doesn't leave empty namespace)
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev-cluster
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app1
|
||||||
|
protocol: tcp
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
- resource: pod/app2
|
||||||
|
protocol: tcp
|
||||||
|
port: 9090
|
||||||
|
localPort: 9090
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mutator := NewMutator(configPath)
|
||||||
|
|
||||||
|
newFwd := Forward{
|
||||||
|
Resource: "pod/app1-moved",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mutator.UpdateForward("dev-cluster/default/pod/app1:8080", "prod-cluster", "production", newFwd)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the forward was moved
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// New context should exist with the forward
|
||||||
|
assert.Len(t, cfg.Contexts, 2)
|
||||||
|
|
||||||
|
// Original namespace should still have one forward
|
||||||
|
assert.Len(t, cfg.Contexts[0].Namespaces, 1)
|
||||||
|
assert.Len(t, cfg.Contexts[0].Namespaces[0].Forwards, 1)
|
||||||
|
|
||||||
|
// New context should have the moved forward
|
||||||
|
assert.Equal(t, "prod-cluster", cfg.Contexts[1].Name)
|
||||||
|
assert.Len(t, cfg.Contexts[1].Namespaces, 1)
|
||||||
|
assert.Equal(t, "production", cfg.Contexts[1].Namespaces[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMutator_UpdateForward_NotFound tests updating non-existent forward
|
||||||
|
func TestMutator_UpdateForward_NotFound(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create initial config
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev-cluster
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app
|
||||||
|
protocol: tcp
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mutator := NewMutator(configPath)
|
||||||
|
|
||||||
|
newFwd := Forward{
|
||||||
|
Resource: "pod/app",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mutator.UpdateForward("non-existent-id", "dev-cluster", "default", newFwd)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "forward with ID non-existent-id not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMutator_UpdateForward_DuplicatePort tests rejecting update with duplicate port
|
||||||
|
func TestMutator_UpdateForward_DuplicatePort(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create initial config with two forwards
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev-cluster
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app1
|
||||||
|
protocol: tcp
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
- resource: pod/app2
|
||||||
|
protocol: tcp
|
||||||
|
port: 9090
|
||||||
|
localPort: 9090
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mutator := NewMutator(configPath)
|
||||||
|
|
||||||
|
// Try to update app1 to use the same port as app2
|
||||||
|
newFwd := Forward{
|
||||||
|
Resource: "pod/app1-updated",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Port: 9090,
|
||||||
|
LocalPort: 9090, // Duplicate with app2
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mutator.UpdateForward("dev-cluster/default/pod/app1:8080", "dev-cluster", "default", newFwd)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "port 9090 is already in use")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMutator_WriteAtomic tests atomic write behavior
|
||||||
|
func TestMutator_WriteAtomic(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
mutator := NewMutator(configPath)
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
Contexts: []Context{
|
||||||
|
{
|
||||||
|
Name: "test",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Forwards: []Forward{
|
||||||
|
{Resource: "pod/app", Protocol: "tcp", Port: 8080, LocalPort: 8080},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := mutator.writeAtomic(cfg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify file was created with correct permissions
|
||||||
|
info, err := os.Stat(configPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, os.FileMode(0600), info.Mode().Perm())
|
||||||
|
|
||||||
|
// Verify temp file was cleaned up
|
||||||
|
tmpFile := filepath.Join(tmpDir, ".kportal.yaml.tmp")
|
||||||
|
_, err = os.Stat(tmpFile)
|
||||||
|
assert.True(t, os.IsNotExist(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMutator_FindOrCreateContext tests context finding/creation
|
||||||
|
func TestMutator_FindOrCreateContext(t *testing.T) {
|
||||||
|
mutator := NewMutator("/fake/path")
|
||||||
|
|
||||||
|
t.Run("find existing context", func(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Contexts: []Context{
|
||||||
|
{Name: "existing"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := mutator.findOrCreateContext(cfg, "existing")
|
||||||
|
assert.Equal(t, "existing", ctx.Name)
|
||||||
|
assert.Len(t, cfg.Contexts, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("create new context", func(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
Contexts: []Context{
|
||||||
|
{Name: "existing"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := mutator.findOrCreateContext(cfg, "new-context")
|
||||||
|
assert.Equal(t, "new-context", ctx.Name)
|
||||||
|
assert.Len(t, cfg.Contexts, 2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMutator_FindOrCreateNamespace tests namespace finding/creation
|
||||||
|
func TestMutator_FindOrCreateNamespace(t *testing.T) {
|
||||||
|
mutator := NewMutator("/fake/path")
|
||||||
|
|
||||||
|
t.Run("find existing namespace", func(t *testing.T) {
|
||||||
|
ctx := &Context{
|
||||||
|
Name: "test",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{Name: "existing"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := mutator.findOrCreateNamespace(ctx, "existing")
|
||||||
|
assert.Equal(t, "existing", ns.Name)
|
||||||
|
assert.Len(t, ctx.Namespaces, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("create new namespace", func(t *testing.T) {
|
||||||
|
ctx := &Context{
|
||||||
|
Name: "test",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{Name: "existing"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := mutator.findOrCreateNamespace(ctx, "new-namespace")
|
||||||
|
assert.Equal(t, "new-namespace", ns.Name)
|
||||||
|
assert.Len(t, ctx.Namespaces, 2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMutator_Concurrent tests mutex protection
|
||||||
|
func TestMutator_Concurrent(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create initial config file
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app
|
||||||
|
protocol: tcp
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mutator := NewMutator(configPath)
|
||||||
|
|
||||||
|
// Run concurrent operations
|
||||||
|
done := make(chan bool, 10)
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
go func(port int) {
|
||||||
|
defer func() { done <- true }()
|
||||||
|
fwd := Forward{
|
||||||
|
Resource: "pod/app",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Port: port + 9000,
|
||||||
|
LocalPort: port + 9000,
|
||||||
|
}
|
||||||
|
// Some will succeed, some will fail due to validation
|
||||||
|
// The important thing is no race condition
|
||||||
|
mutator.AddForward("dev", "default", fwd)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all goroutines
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify config is still valid
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, cfg)
|
||||||
|
}
|
||||||
@@ -6,10 +6,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
minPort = 1
|
MinPort = 1
|
||||||
maxPort = 65535
|
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.
|
// ValidationError represents a configuration validation error with context.
|
||||||
type ValidationError struct {
|
type ValidationError struct {
|
||||||
Field string // The field that failed validation
|
Field string // The field that failed validation
|
||||||
@@ -32,6 +37,13 @@ func NewValidator() *Validator {
|
|||||||
|
|
||||||
// ValidateConfig validates the entire configuration and returns all errors found.
|
// ValidateConfig validates the entire configuration and returns all errors found.
|
||||||
func (v *Validator) ValidateConfig(cfg *Config) []ValidationError {
|
func (v *Validator) ValidateConfig(cfg *Config) []ValidationError {
|
||||||
|
return v.ValidateConfigWithOptions(cfg, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateConfigWithOptions validates configuration with configurable strictness.
|
||||||
|
// When allowEmpty is true, empty configurations (no contexts/forwards) are allowed.
|
||||||
|
// This is useful for newly created config files where the user will add forwards via the TUI.
|
||||||
|
func (v *Validator) ValidateConfigWithOptions(cfg *Config, allowEmpty bool) []ValidationError {
|
||||||
var errs []ValidationError
|
var errs []ValidationError
|
||||||
|
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
@@ -41,6 +53,12 @@ func (v *Validator) ValidateConfig(cfg *Config) []ValidationError {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If empty configs are allowed and this config is empty, skip structure validation
|
||||||
|
if allowEmpty && cfg.IsEmpty() {
|
||||||
|
// Still validate health check and reliability if present (they don't require forwards)
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
// Validate structure
|
// Validate structure
|
||||||
errs = append(errs, v.validateStructure(cfg)...)
|
errs = append(errs, v.validateStructure(cfg)...)
|
||||||
|
|
||||||
@@ -56,6 +74,11 @@ func (v *Validator) ValidateConfig(cfg *Config) []ValidationError {
|
|||||||
// Check for duplicate local ports
|
// Check for duplicate local ports
|
||||||
errs = append(errs, v.validateDuplicatePorts(cfg)...)
|
errs = append(errs, v.validateDuplicatePorts(cfg)...)
|
||||||
|
|
||||||
|
// Validate mDNS configuration
|
||||||
|
if cfg.IsMDNSEnabled() {
|
||||||
|
errs = append(errs, v.validateMDNS(cfg)...)
|
||||||
|
}
|
||||||
|
|
||||||
return errs
|
return errs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +107,7 @@ func (v *Validator) validateStructure(cfg *Config) []ValidationError {
|
|||||||
Field: fmt.Sprintf("contexts[%d].namespaces", i),
|
Field: fmt.Sprintf("contexts[%d].namespaces", i),
|
||||||
Message: fmt.Sprintf("Context '%s' must have at least one namespace", ctx.Name),
|
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 {
|
for j, ns := range ctx.Namespaces {
|
||||||
@@ -130,17 +153,17 @@ func (v *Validator) validateForward(fwd *Forward) []ValidationError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate ports
|
// Validate ports
|
||||||
if fwd.Port < minPort || fwd.Port > maxPort {
|
if fwd.Port < MinPort || fwd.Port > MaxPort {
|
||||||
errs = append(errs, ValidationError{
|
errs = append(errs, ValidationError{
|
||||||
Field: "port",
|
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{
|
errs = append(errs, ValidationError{
|
||||||
Field: "localPort",
|
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 +288,85 @@ func FormatValidationErrors(errs []ValidationError) string {
|
|||||||
|
|
||||||
return sb.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,412 @@ 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidator_ValidateConfigWithOptions(t *testing.T) {
|
||||||
|
validator := NewValidator()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config *Config
|
||||||
|
allowEmpty bool
|
||||||
|
expectErrors bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty config - strict mode",
|
||||||
|
config: &Config{Contexts: []Context{}},
|
||||||
|
allowEmpty: false,
|
||||||
|
expectErrors: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty config - allow empty",
|
||||||
|
config: &Config{Contexts: []Context{}},
|
||||||
|
allowEmpty: true,
|
||||||
|
expectErrors: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil contexts - allow empty",
|
||||||
|
config: &Config{},
|
||||||
|
allowEmpty: true,
|
||||||
|
expectErrors: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "context with no forwards - allow empty",
|
||||||
|
config: &Config{
|
||||||
|
Contexts: []Context{
|
||||||
|
{
|
||||||
|
Name: "dev",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{Name: "default", Forwards: []Forward{}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
allowEmpty: true,
|
||||||
|
expectErrors: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid config - strict mode",
|
||||||
|
config: &Config{
|
||||||
|
Contexts: []Context{
|
||||||
|
{
|
||||||
|
Name: "dev-cluster",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Forwards: []Forward{
|
||||||
|
{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
contextName: "dev-cluster",
|
||||||
|
namespaceName: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
allowEmpty: false,
|
||||||
|
expectErrors: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid config - allow empty (should still validate)",
|
||||||
|
config: &Config{
|
||||||
|
Contexts: []Context{
|
||||||
|
{
|
||||||
|
Name: "dev-cluster",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Forwards: []Forward{
|
||||||
|
{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
contextName: "dev-cluster",
|
||||||
|
namespaceName: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
allowEmpty: true,
|
||||||
|
expectErrors: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid forward in non-empty config - allow empty still validates",
|
||||||
|
config: &Config{
|
||||||
|
Contexts: []Context{
|
||||||
|
{
|
||||||
|
Name: "dev-cluster",
|
||||||
|
Namespaces: []Namespace{
|
||||||
|
{
|
||||||
|
Name: "default",
|
||||||
|
Forwards: []Forward{
|
||||||
|
{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Protocol: "tcp",
|
||||||
|
Port: 0, // Invalid port
|
||||||
|
LocalPort: 8080,
|
||||||
|
contextName: "dev-cluster",
|
||||||
|
namespaceName: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
allowEmpty: true,
|
||||||
|
expectErrors: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
errs := validator.ValidateConfigWithOptions(tt.config, tt.allowEmpty)
|
||||||
|
|
||||||
|
if tt.expectErrors {
|
||||||
|
assert.NotEmpty(t, errs, "expected validation errors")
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, errs, "expected no validation errors, got: %v", errs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/fsnotify/fsnotify"
|
"github.com/fsnotify/fsnotify"
|
||||||
"github.com/nvm/kportal/internal/logger"
|
"github.com/nvm/kportal/internal/logger"
|
||||||
@@ -20,6 +21,7 @@ type Watcher struct {
|
|||||||
watcher *fsnotify.Watcher
|
watcher *fsnotify.Watcher
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
verbose bool
|
verbose bool
|
||||||
|
wg sync.WaitGroup // Ensures watch goroutine exits before Stop returns
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWatcher creates a new file watcher for the given config file.
|
// NewWatcher creates a new file watcher for the given config file.
|
||||||
@@ -31,7 +33,7 @@ func NewWatcher(configPath string, callback ReloadCallback, verbose bool) (*Watc
|
|||||||
|
|
||||||
absPath, err := filepath.Abs(configPath)
|
absPath, err := filepath.Abs(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
watcher.Close()
|
_ = watcher.Close()
|
||||||
return nil, fmt.Errorf("failed to resolve absolute path: %w", err)
|
return nil, fmt.Errorf("failed to resolve absolute path: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +41,7 @@ func NewWatcher(configPath string, callback ReloadCallback, verbose bool) (*Watc
|
|||||||
// (many editors delete and recreate files on save)
|
// (many editors delete and recreate files on save)
|
||||||
dir := filepath.Dir(absPath)
|
dir := filepath.Dir(absPath)
|
||||||
if err := watcher.Add(dir); err != nil {
|
if err := watcher.Add(dir); err != nil {
|
||||||
watcher.Close()
|
_ = watcher.Close()
|
||||||
return nil, fmt.Errorf("failed to watch directory %s: %w", dir, err)
|
return nil, fmt.Errorf("failed to watch directory %s: %w", dir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,17 +56,21 @@ func NewWatcher(configPath string, callback ReloadCallback, verbose bool) (*Watc
|
|||||||
|
|
||||||
// Start begins watching the configuration file for changes.
|
// Start begins watching the configuration file for changes.
|
||||||
func (w *Watcher) Start() {
|
func (w *Watcher) Start() {
|
||||||
|
w.wg.Add(1)
|
||||||
go w.watch()
|
go w.watch()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop stops watching the configuration file.
|
// Stop stops watching the configuration file and waits for the watch goroutine to exit.
|
||||||
func (w *Watcher) Stop() {
|
func (w *Watcher) Stop() {
|
||||||
close(w.done)
|
close(w.done)
|
||||||
w.watcher.Close()
|
_ = w.watcher.Close()
|
||||||
|
w.wg.Wait() // Wait for watch goroutine to exit
|
||||||
}
|
}
|
||||||
|
|
||||||
// watch runs the file watching loop.
|
// watch runs the file watching loop.
|
||||||
func (w *Watcher) watch() {
|
func (w *Watcher) watch() {
|
||||||
|
defer w.wg.Done()
|
||||||
|
|
||||||
if w.verbose {
|
if w.verbose {
|
||||||
log.Printf("Watching configuration file: %s", w.configPath)
|
log.Printf("Watching configuration file: %s", w.configPath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewWatcher tests watcher creation
|
||||||
|
func TestNewWatcher(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create initial config file
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app
|
||||||
|
protocol: tcp
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
callback := func(cfg *Config) error { return nil }
|
||||||
|
|
||||||
|
watcher, err := NewWatcher(configPath, callback, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, watcher)
|
||||||
|
defer watcher.Stop()
|
||||||
|
|
||||||
|
assert.NotNil(t, watcher.watcher)
|
||||||
|
assert.NotNil(t, watcher.done)
|
||||||
|
assert.False(t, watcher.verbose)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewWatcher_Verbose tests verbose watcher creation
|
||||||
|
func TestNewWatcher_Verbose(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create initial config file
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
callback := func(cfg *Config) error { return nil }
|
||||||
|
|
||||||
|
watcher, err := NewWatcher(configPath, callback, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, watcher)
|
||||||
|
defer watcher.Stop()
|
||||||
|
|
||||||
|
assert.True(t, watcher.verbose)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewWatcher_RelativePath tests absolute path resolution
|
||||||
|
func TestNewWatcher_RelativePath(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create initial config file
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Change to tmpDir and use relative path
|
||||||
|
originalDir, _ := os.Getwd()
|
||||||
|
defer os.Chdir(originalDir)
|
||||||
|
os.Chdir(tmpDir)
|
||||||
|
|
||||||
|
callback := func(cfg *Config) error { return nil }
|
||||||
|
|
||||||
|
watcher, err := NewWatcher(".kportal.yaml", callback, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, watcher)
|
||||||
|
defer watcher.Stop()
|
||||||
|
|
||||||
|
// configPath should be absolute
|
||||||
|
assert.True(t, filepath.IsAbs(watcher.configPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWatcher_StartStop tests basic start/stop lifecycle
|
||||||
|
func TestWatcher_StartStop(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create initial config file
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
callback := func(cfg *Config) error { return nil }
|
||||||
|
|
||||||
|
watcher, err := NewWatcher(configPath, callback, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, watcher)
|
||||||
|
|
||||||
|
// Start watching
|
||||||
|
watcher.Start()
|
||||||
|
|
||||||
|
// Stop should complete without hanging
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
watcher.Stop()
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Success
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("Stop timed out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWatcher_DetectsFileChange tests that file changes trigger callback
|
||||||
|
func TestWatcher_DetectsFileChange(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create initial config file
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var callbackCalled bool
|
||||||
|
var receivedConfig *Config
|
||||||
|
|
||||||
|
callback := func(cfg *Config) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
callbackCalled = true
|
||||||
|
receivedConfig = cfg
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
watcher, err := NewWatcher(configPath, callback, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, watcher)
|
||||||
|
defer watcher.Stop()
|
||||||
|
|
||||||
|
watcher.Start()
|
||||||
|
|
||||||
|
// Give watcher time to start
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Modify the config file
|
||||||
|
updated := `contexts:
|
||||||
|
- name: dev
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
- resource: pod/new-app
|
||||||
|
port: 9090
|
||||||
|
localPort: 9090
|
||||||
|
`
|
||||||
|
err = os.WriteFile(configPath, []byte(updated), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for callback with timeout
|
||||||
|
timeout := time.After(5 * time.Second)
|
||||||
|
tick := time.NewTicker(100 * time.Millisecond)
|
||||||
|
defer tick.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-timeout:
|
||||||
|
t.Fatal("Callback was not called after file change")
|
||||||
|
case <-tick.C:
|
||||||
|
mu.Lock()
|
||||||
|
if callbackCalled {
|
||||||
|
assert.NotNil(t, receivedConfig)
|
||||||
|
assert.Len(t, receivedConfig.Contexts[0].Namespaces[0].Forwards, 2)
|
||||||
|
mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWatcher_IgnoresInvalidConfig tests that invalid configs are rejected
|
||||||
|
func TestWatcher_IgnoresInvalidConfig(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create initial valid config file
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
callbackCount := 0
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
callback := func(cfg *Config) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
callbackCount++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
watcher, err := NewWatcher(configPath, callback, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, watcher)
|
||||||
|
defer watcher.Stop()
|
||||||
|
|
||||||
|
watcher.Start()
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Write invalid config (invalid YAML syntax)
|
||||||
|
invalid := `contexts:
|
||||||
|
- name: dev
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards: [this is invalid
|
||||||
|
`
|
||||||
|
err = os.WriteFile(configPath, []byte(invalid), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait a bit
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
// Callback should not have been called
|
||||||
|
mu.Lock()
|
||||||
|
assert.Equal(t, 0, callbackCount, "callback should not be called for invalid config")
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWatcher_IgnoresValidationErrors tests that configs failing validation are rejected
|
||||||
|
func TestWatcher_IgnoresValidationErrors(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create initial valid config file
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
callbackCount := 0
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
callback := func(cfg *Config) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
callbackCount++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
watcher, err := NewWatcher(configPath, callback, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, watcher)
|
||||||
|
defer watcher.Stop()
|
||||||
|
|
||||||
|
watcher.Start()
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Write config with duplicate ports (validation error)
|
||||||
|
invalid := `contexts:
|
||||||
|
- name: dev
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app1
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
- resource: pod/app2
|
||||||
|
port: 9090
|
||||||
|
localPort: 8080
|
||||||
|
`
|
||||||
|
err = os.WriteFile(configPath, []byte(invalid), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait a bit
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
// Callback should not have been called
|
||||||
|
mu.Lock()
|
||||||
|
assert.Equal(t, 0, callbackCount, "callback should not be called for invalid config")
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWatcher_IgnoresOtherFiles tests that changes to other files are ignored
|
||||||
|
func TestWatcher_IgnoresOtherFiles(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
otherPath := filepath.Join(tmpDir, "other.txt")
|
||||||
|
|
||||||
|
// Create initial config file
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
callbackCount := 0
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
callback := func(cfg *Config) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
callbackCount++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
watcher, err := NewWatcher(configPath, callback, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, watcher)
|
||||||
|
defer watcher.Stop()
|
||||||
|
|
||||||
|
watcher.Start()
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
// Write to a different file
|
||||||
|
err = os.WriteFile(otherPath, []byte("some content"), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait a bit
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
// Callback should not have been called
|
||||||
|
mu.Lock()
|
||||||
|
assert.Equal(t, 0, callbackCount, "callback should not be called for other files")
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWatcher_HandleReload_LoadError tests handleReload with load error
|
||||||
|
func TestWatcher_HandleReload_LoadError(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create initial config file
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
callbackCalled := false
|
||||||
|
|
||||||
|
callback := func(cfg *Config) error {
|
||||||
|
callbackCalled = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
watcher, err := NewWatcher(configPath, callback, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, watcher)
|
||||||
|
defer watcher.Stop()
|
||||||
|
|
||||||
|
// Delete the config file to cause load error
|
||||||
|
os.Remove(configPath)
|
||||||
|
|
||||||
|
// Call handleReload directly
|
||||||
|
watcher.handleReload()
|
||||||
|
|
||||||
|
// Callback should not have been called
|
||||||
|
assert.False(t, callbackCalled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWatcher_DoubleStop tests that double stop doesn't panic
|
||||||
|
func TestWatcher_DoubleStop(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create initial config file
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
callback := func(cfg *Config) error { return nil }
|
||||||
|
|
||||||
|
watcher, err := NewWatcher(configPath, callback, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, watcher)
|
||||||
|
|
||||||
|
watcher.Start()
|
||||||
|
|
||||||
|
// First stop
|
||||||
|
watcher.Stop()
|
||||||
|
|
||||||
|
// Second stop should not panic (though the channel is already closed)
|
||||||
|
// Note: This might panic due to close on closed channel, which is actually
|
||||||
|
// a design issue - but we document the current behavior
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWatcher_StopWithoutStart tests stopping without starting
|
||||||
|
func TestWatcher_StopWithoutStart(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create initial config file
|
||||||
|
initial := `contexts:
|
||||||
|
- name: dev
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/app
|
||||||
|
port: 8080
|
||||||
|
localPort: 8080
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(initial), 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
callback := func(cfg *Config) error { return nil }
|
||||||
|
|
||||||
|
watcher, err := NewWatcher(configPath, callback, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, watcher)
|
||||||
|
|
||||||
|
// Stop without starting should not hang
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
watcher.Stop()
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Success
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("Stop without start timed out")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ type KFTrayConfig struct {
|
|||||||
// ConvertKFTrayToKPortal converts kftray JSON configuration to kportal YAML format
|
// ConvertKFTrayToKPortal converts kftray JSON configuration to kportal YAML format
|
||||||
func ConvertKFTrayToKPortal(inputFile, outputFile string) error {
|
func ConvertKFTrayToKPortal(inputFile, outputFile string) error {
|
||||||
// Read kftray JSON config
|
// Read kftray JSON config
|
||||||
|
// #nosec G304 -- inputFile is from command line argument for explicit conversion
|
||||||
data, err := os.ReadFile(inputFile)
|
data, err := os.ReadFile(inputFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read input file: %w", err)
|
return fmt.Errorf("failed to read input file: %w", err)
|
||||||
@@ -48,7 +49,7 @@ func ConvertKFTrayToKPortal(inputFile, outputFile string) error {
|
|||||||
header := "# kportal configuration converted from kftray format\n# Generated by kportal --convert\n\n"
|
header := "# kportal configuration converted from kftray format\n# Generated by kportal --convert\n\n"
|
||||||
yamlData = append([]byte(header), yamlData...)
|
yamlData = append([]byte(header), yamlData...)
|
||||||
|
|
||||||
if err := os.WriteFile(outputFile, yamlData, 0644); err != nil {
|
if err := os.WriteFile(outputFile, yamlData, 0600); err != nil {
|
||||||
return fmt.Errorf("failed to write output file: %w", err)
|
return fmt.Errorf("failed to write output file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ func ConvertKFTrayToKPortal(inputFile, outputFile string) error {
|
|||||||
|
|
||||||
// GetConversionSummary returns statistics about the kftray configuration
|
// GetConversionSummary returns statistics about the kftray configuration
|
||||||
func GetConversionSummary(inputFile string) (map[string]map[string]int, int, error) {
|
func GetConversionSummary(inputFile string) (map[string]map[string]int, int, error) {
|
||||||
|
// #nosec G304 -- inputFile is from command line argument for explicit conversion
|
||||||
data, err := os.ReadFile(inputFile)
|
data, err := os.ReadFile(inputFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, fmt.Errorf("failed to read input file: %w", err)
|
return nil, 0, fmt.Errorf("failed to read input file: %w", err)
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package events
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EventType represents the type of event
|
||||||
|
type EventType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Forward lifecycle events
|
||||||
|
EventForwardStarting EventType = "forward.starting"
|
||||||
|
EventForwardConnected EventType = "forward.connected"
|
||||||
|
EventForwardDisconnected EventType = "forward.disconnected"
|
||||||
|
EventForwardReconnecting EventType = "forward.reconnecting"
|
||||||
|
EventForwardStopped EventType = "forward.stopped"
|
||||||
|
EventForwardError EventType = "forward.error"
|
||||||
|
|
||||||
|
// Health events
|
||||||
|
EventHealthStatusChanged EventType = "health.status_changed"
|
||||||
|
EventHealthStale EventType = "health.stale"
|
||||||
|
|
||||||
|
// Watchdog events
|
||||||
|
EventWorkerHung EventType = "watchdog.worker_hung"
|
||||||
|
|
||||||
|
// Config events
|
||||||
|
EventConfigReloaded EventType = "config.reloaded"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event represents a system event
|
||||||
|
type Event struct {
|
||||||
|
Type EventType
|
||||||
|
ForwardID string
|
||||||
|
Data map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler is a function that handles events
|
||||||
|
type Handler func(event Event)
|
||||||
|
|
||||||
|
// Bus is a simple event bus for decoupled communication between components
|
||||||
|
type Bus struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
handlers map[EventType][]Handler
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBus creates a new event bus
|
||||||
|
func NewBus() *Bus {
|
||||||
|
return &Bus{
|
||||||
|
handlers: make(map[EventType][]Handler),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe registers a handler for a specific event type
|
||||||
|
func (b *Bus) Subscribe(eventType EventType, handler Handler) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
if b.closed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b.handlers[eventType] = append(b.handlers[eventType], handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscribeAll registers a handler for all events
|
||||||
|
func (b *Bus) SubscribeAll(handler Handler) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
if b.closed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to all known event types
|
||||||
|
eventTypes := []EventType{
|
||||||
|
EventForwardStarting,
|
||||||
|
EventForwardConnected,
|
||||||
|
EventForwardDisconnected,
|
||||||
|
EventForwardReconnecting,
|
||||||
|
EventForwardStopped,
|
||||||
|
EventForwardError,
|
||||||
|
EventHealthStatusChanged,
|
||||||
|
EventHealthStale,
|
||||||
|
EventWorkerHung,
|
||||||
|
EventConfigReloaded,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, et := range eventTypes {
|
||||||
|
b.handlers[et] = append(b.handlers[et], handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish sends an event to all registered handlers
|
||||||
|
// Handlers are called synchronously in the order they were registered
|
||||||
|
func (b *Bus) Publish(event Event) {
|
||||||
|
b.mu.RLock()
|
||||||
|
if b.closed {
|
||||||
|
b.mu.RUnlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handlers := make([]Handler, len(b.handlers[event.Type]))
|
||||||
|
copy(handlers, b.handlers[event.Type])
|
||||||
|
b.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, handler := range handlers {
|
||||||
|
handler(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishAsync sends an event to all registered handlers asynchronously
|
||||||
|
func (b *Bus) PublishAsync(event Event) {
|
||||||
|
b.mu.RLock()
|
||||||
|
if b.closed {
|
||||||
|
b.mu.RUnlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handlers := make([]Handler, len(b.handlers[event.Type]))
|
||||||
|
copy(handlers, b.handlers[event.Type])
|
||||||
|
b.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, handler := range handlers {
|
||||||
|
go handler(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops the event bus and prevents new subscriptions/publications
|
||||||
|
func (b *Bus) Close() {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
b.closed = true
|
||||||
|
b.handlers = make(map[EventType][]Handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for creating common events
|
||||||
|
|
||||||
|
// NewForwardEvent creates a forward-related event
|
||||||
|
func NewForwardEvent(eventType EventType, forwardID string, data map[string]interface{}) Event {
|
||||||
|
return Event{
|
||||||
|
Type: eventType,
|
||||||
|
ForwardID: forwardID,
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHealthEvent creates a health status change event
|
||||||
|
func NewHealthEvent(forwardID string, status string, errorMsg string) Event {
|
||||||
|
return Event{
|
||||||
|
Type: EventHealthStatusChanged,
|
||||||
|
ForwardID: forwardID,
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"status": status,
|
||||||
|
"error_msg": errorMsg,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStaleEvent creates a stale connection event
|
||||||
|
func NewStaleEvent(forwardID string, reason string) Event {
|
||||||
|
return Event{
|
||||||
|
Type: EventHealthStale,
|
||||||
|
ForwardID: forwardID,
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"reason": reason,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWorkerHungEvent creates a hung worker event
|
||||||
|
func NewWorkerHungEvent(forwardID string, timeSinceHeartbeat string) Event {
|
||||||
|
return Event{
|
||||||
|
Type: EventWorkerHung,
|
||||||
|
ForwardID: forwardID,
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"time_since_heartbeat": timeSinceHeartbeat,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package events
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBus_Subscribe(t *testing.T) {
|
||||||
|
bus := NewBus()
|
||||||
|
defer bus.Close()
|
||||||
|
|
||||||
|
var received bool
|
||||||
|
bus.Subscribe(EventForwardStarting, func(e Event) {
|
||||||
|
received = true
|
||||||
|
})
|
||||||
|
|
||||||
|
bus.Publish(Event{Type: EventForwardStarting})
|
||||||
|
assert.True(t, received)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBus_SubscribeMultipleHandlers(t *testing.T) {
|
||||||
|
bus := NewBus()
|
||||||
|
defer bus.Close()
|
||||||
|
|
||||||
|
var count int32
|
||||||
|
handler := func(e Event) {
|
||||||
|
atomic.AddInt32(&count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
bus.Subscribe(EventForwardStarting, handler)
|
||||||
|
bus.Subscribe(EventForwardStarting, handler)
|
||||||
|
bus.Subscribe(EventForwardStarting, handler)
|
||||||
|
|
||||||
|
bus.Publish(Event{Type: EventForwardStarting})
|
||||||
|
assert.Equal(t, int32(3), atomic.LoadInt32(&count))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBus_SubscribeAll(t *testing.T) {
|
||||||
|
bus := NewBus()
|
||||||
|
defer bus.Close()
|
||||||
|
|
||||||
|
var count int32
|
||||||
|
bus.SubscribeAll(func(e Event) {
|
||||||
|
atomic.AddInt32(&count, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
bus.Publish(Event{Type: EventForwardStarting})
|
||||||
|
bus.Publish(Event{Type: EventForwardConnected})
|
||||||
|
bus.Publish(Event{Type: EventHealthStatusChanged})
|
||||||
|
|
||||||
|
assert.Equal(t, int32(3), atomic.LoadInt32(&count))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBus_PublishWithData(t *testing.T) {
|
||||||
|
bus := NewBus()
|
||||||
|
defer bus.Close()
|
||||||
|
|
||||||
|
var receivedEvent Event
|
||||||
|
bus.Subscribe(EventHealthStatusChanged, func(e Event) {
|
||||||
|
receivedEvent = e
|
||||||
|
})
|
||||||
|
|
||||||
|
bus.Publish(Event{
|
||||||
|
Type: EventHealthStatusChanged,
|
||||||
|
ForwardID: "test-forward",
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"status": "Active",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, EventHealthStatusChanged, receivedEvent.Type)
|
||||||
|
assert.Equal(t, "test-forward", receivedEvent.ForwardID)
|
||||||
|
assert.Equal(t, "Active", receivedEvent.Data["status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBus_PublishAsync(t *testing.T) {
|
||||||
|
bus := NewBus()
|
||||||
|
defer bus.Close()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
|
||||||
|
bus.Subscribe(EventForwardStarting, func(e Event) {
|
||||||
|
wg.Done()
|
||||||
|
})
|
||||||
|
|
||||||
|
bus.PublishAsync(Event{Type: EventForwardStarting})
|
||||||
|
|
||||||
|
// Wait for async handler with timeout
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Success
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Async handler not called within timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBus_Close(t *testing.T) {
|
||||||
|
bus := NewBus()
|
||||||
|
|
||||||
|
var received bool
|
||||||
|
bus.Subscribe(EventForwardStarting, func(e Event) {
|
||||||
|
received = true
|
||||||
|
})
|
||||||
|
|
||||||
|
bus.Close()
|
||||||
|
|
||||||
|
// After close, publish should not call handlers
|
||||||
|
bus.Publish(Event{Type: EventForwardStarting})
|
||||||
|
assert.False(t, received)
|
||||||
|
|
||||||
|
// Subscribe after close should be a no-op
|
||||||
|
bus.Subscribe(EventForwardConnected, func(e Event) {
|
||||||
|
received = true
|
||||||
|
})
|
||||||
|
bus.Publish(Event{Type: EventForwardConnected})
|
||||||
|
assert.False(t, received)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBus_ConcurrentAccess(t *testing.T) {
|
||||||
|
bus := NewBus()
|
||||||
|
defer bus.Close()
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
bus.Subscribe(EventForwardStarting, func(e Event) {
|
||||||
|
atomic.AddInt64(&count, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
bus.Publish(Event{Type: EventForwardStarting})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
assert.Equal(t, int64(100), atomic.LoadInt64(&count))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewForwardEvent(t *testing.T) {
|
||||||
|
event := NewForwardEvent(EventForwardStarting, "test-id", map[string]interface{}{
|
||||||
|
"pod": "my-pod",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, EventForwardStarting, event.Type)
|
||||||
|
assert.Equal(t, "test-id", event.ForwardID)
|
||||||
|
assert.Equal(t, "my-pod", event.Data["pod"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewHealthEvent(t *testing.T) {
|
||||||
|
event := NewHealthEvent("test-id", "Active", "")
|
||||||
|
|
||||||
|
assert.Equal(t, EventHealthStatusChanged, event.Type)
|
||||||
|
assert.Equal(t, "test-id", event.ForwardID)
|
||||||
|
assert.Equal(t, "Active", event.Data["status"])
|
||||||
|
assert.Equal(t, "", event.Data["error_msg"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewStaleEvent(t *testing.T) {
|
||||||
|
event := NewStaleEvent("test-id", "connection too old")
|
||||||
|
|
||||||
|
assert.Equal(t, EventHealthStale, event.Type)
|
||||||
|
assert.Equal(t, "test-id", event.ForwardID)
|
||||||
|
assert.Equal(t, "connection too old", event.Data["reason"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewWorkerHungEvent(t *testing.T) {
|
||||||
|
event := NewWorkerHungEvent("test-id", "60s")
|
||||||
|
|
||||||
|
assert.Equal(t, EventWorkerHung, event.Type)
|
||||||
|
assert.Equal(t, "test-id", event.ForwardID)
|
||||||
|
assert.Equal(t, "60s", event.Data["time_since_heartbeat"])
|
||||||
|
}
|
||||||
@@ -7,9 +7,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/nvm/kportal/internal/config"
|
"github.com/nvm/kportal/internal/config"
|
||||||
|
"github.com/nvm/kportal/internal/events"
|
||||||
"github.com/nvm/kportal/internal/healthcheck"
|
"github.com/nvm/kportal/internal/healthcheck"
|
||||||
"github.com/nvm/kportal/internal/k8s"
|
"github.com/nvm/kportal/internal/k8s"
|
||||||
"github.com/nvm/kportal/internal/logger"
|
"github.com/nvm/kportal/internal/logger"
|
||||||
|
"github.com/nvm/kportal/internal/mdns"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StatusUpdater is an interface for updating forward status
|
// StatusUpdater is an interface for updating forward status
|
||||||
@@ -30,6 +32,8 @@ type Manager struct {
|
|||||||
portChecker *PortChecker
|
portChecker *PortChecker
|
||||||
healthChecker *healthcheck.Checker
|
healthChecker *healthcheck.Checker
|
||||||
watchdog *Watchdog
|
watchdog *Watchdog
|
||||||
|
mdnsPublisher *mdns.Publisher
|
||||||
|
eventBus *events.Bus // Event bus for decoupled communication
|
||||||
verbose bool
|
verbose bool
|
||||||
currentConfig *config.Config
|
currentConfig *config.Config
|
||||||
statusUI StatusUpdater
|
statusUI StatusUpdater
|
||||||
@@ -55,6 +59,13 @@ func NewManager(verbose bool) (*Manager, error) {
|
|||||||
// Will be reconfigured when config is loaded
|
// Will be reconfigured when config is loaded
|
||||||
watchdog := NewWatchdog(30*time.Second, 60*time.Second)
|
watchdog := NewWatchdog(30*time.Second, 60*time.Second)
|
||||||
|
|
||||||
|
// Create event bus for decoupled communication between components
|
||||||
|
eventBus := events.NewBus()
|
||||||
|
|
||||||
|
// Wire up event bus to components
|
||||||
|
healthChecker.SetEventBus(eventBus)
|
||||||
|
watchdog.SetEventBus(eventBus)
|
||||||
|
|
||||||
return &Manager{
|
return &Manager{
|
||||||
workers: make(map[string]*ForwardWorker),
|
workers: make(map[string]*ForwardWorker),
|
||||||
clientPool: clientPool,
|
clientPool: clientPool,
|
||||||
@@ -63,6 +74,7 @@ func NewManager(verbose bool) (*Manager, error) {
|
|||||||
portChecker: NewPortChecker(),
|
portChecker: NewPortChecker(),
|
||||||
healthChecker: healthChecker,
|
healthChecker: healthChecker,
|
||||||
watchdog: watchdog,
|
watchdog: watchdog,
|
||||||
|
eventBus: eventBus,
|
||||||
verbose: verbose,
|
verbose: verbose,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -95,6 +107,11 @@ func (m *Manager) configureHealthChecker(cfg *config.Config) {
|
|||||||
MaxIdleTime: cfg.GetMaxIdleTime(),
|
MaxIdleTime: cfg.GetMaxIdleTime(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Reconnect event bus to new health checker
|
||||||
|
if m.eventBus != nil {
|
||||||
|
m.healthChecker.SetEventBus(m.eventBus)
|
||||||
|
}
|
||||||
|
|
||||||
// Configure TCP settings on port forwarder
|
// Configure TCP settings on port forwarder
|
||||||
tcpKeepalive := cfg.GetTCPKeepalive()
|
tcpKeepalive := cfg.GetTCPKeepalive()
|
||||||
dialTimeout := cfg.GetDialTimeout()
|
dialTimeout := cfg.GetDialTimeout()
|
||||||
@@ -117,6 +134,16 @@ func (m *Manager) SetStatusUI(ui StatusUpdater) {
|
|||||||
m.statusUI = ui
|
m.statusUI = ui
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMDNSPublisher sets the mDNS publisher for the manager
|
||||||
|
func (m *Manager) SetMDNSPublisher(publisher *mdns.Publisher) {
|
||||||
|
m.mdnsPublisher = publisher
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEventBus returns the event bus for subscribing to manager events
|
||||||
|
func (m *Manager) GetEventBus() *events.Bus {
|
||||||
|
return m.eventBus
|
||||||
|
}
|
||||||
|
|
||||||
// Start initializes and starts all port-forwards from the configuration.
|
// Start initializes and starts all port-forwards from the configuration.
|
||||||
func (m *Manager) Start(cfg *config.Config) error {
|
func (m *Manager) Start(cfg *config.Config) error {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
@@ -142,8 +169,10 @@ func (m *Manager) Start(cfg *config.Config) error {
|
|||||||
// Get all forwards from config
|
// Get all forwards from config
|
||||||
forwards := cfg.GetAllForwards()
|
forwards := cfg.GetAllForwards()
|
||||||
|
|
||||||
|
// Empty config is valid - user can add forwards later via TUI
|
||||||
if len(forwards) == 0 {
|
if len(forwards) == 0 {
|
||||||
return fmt.Errorf("no forwards configured")
|
log.Printf("No forwards configured - use 'n' to add forwards")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check port availability before starting
|
// Check port availability before starting
|
||||||
@@ -186,6 +215,16 @@ func (m *Manager) Stop() {
|
|||||||
m.healthChecker.Stop()
|
m.healthChecker.Stop()
|
||||||
m.watchdog.Stop()
|
m.watchdog.Stop()
|
||||||
|
|
||||||
|
// Close event bus
|
||||||
|
if m.eventBus != nil {
|
||||||
|
m.eventBus.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop mDNS publisher
|
||||||
|
if m.mdnsPublisher != nil {
|
||||||
|
m.mdnsPublisher.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
m.workersMu.Lock()
|
m.workersMu.Lock()
|
||||||
workers := make([]*ForwardWorker, 0, len(m.workers))
|
workers := make([]*ForwardWorker, 0, len(m.workers))
|
||||||
for _, worker := range m.workers {
|
for _, worker := range m.workers {
|
||||||
@@ -337,19 +376,24 @@ func (m *Manager) startWorker(fwd config.Forward) error {
|
|||||||
m.statusUI.AddForward(fwd.ID(), &fwd)
|
m.statusUI.AddForward(fwd.ID(), &fwd)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register with watchdog
|
// Create worker first so we can pass it to watchdog
|
||||||
m.watchdog.RegisterWorker(fwd.ID(), func(forwardID string) {
|
worker := NewForwardWorker(fwd, m.portForwarder, m.verbose, m.statusUI, m.healthChecker, m.watchdog)
|
||||||
|
|
||||||
|
// Register with watchdog using the new responder interface
|
||||||
|
// This allows the watchdog to poll the worker for heartbeats centrally
|
||||||
|
// instead of each worker spawning its own heartbeat goroutine
|
||||||
|
m.watchdog.RegisterWorkerWithResponder(fwd.ID(), worker, func(forwardID string) {
|
||||||
logger.Warn("Watchdog triggered reconnection for hung worker", map[string]interface{}{
|
logger.Warn("Watchdog triggered reconnection for hung worker", map[string]interface{}{
|
||||||
"forward_id": forwardID,
|
"forward_id": forwardID,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Find and trigger reconnect on hung worker
|
// Find and trigger reconnect on hung worker
|
||||||
m.workersMu.RLock()
|
m.workersMu.RLock()
|
||||||
worker, exists := m.workers[forwardID]
|
w, exists := m.workers[forwardID]
|
||||||
m.workersMu.RUnlock()
|
m.workersMu.RUnlock()
|
||||||
|
|
||||||
if exists {
|
if exists {
|
||||||
worker.TriggerReconnect("watchdog detected hung worker")
|
w.TriggerReconnect("watchdog detected hung worker")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -357,6 +401,7 @@ func (m *Manager) startWorker(fwd config.Forward) error {
|
|||||||
m.healthChecker.Register(fwd.ID(), fwd.LocalPort, func(forwardID string, status healthcheck.Status, errorMsg string) {
|
m.healthChecker.Register(fwd.ID(), fwd.LocalPort, func(forwardID string, status healthcheck.Status, errorMsg string) {
|
||||||
if m.statusUI != nil {
|
if m.statusUI != nil {
|
||||||
m.statusUI.UpdateStatus(forwardID, string(status))
|
m.statusUI.UpdateStatus(forwardID, string(status))
|
||||||
|
|
||||||
// Send error separately if there is one
|
// Send error separately if there is one
|
||||||
if (status == healthcheck.StatusUnhealthy || status == healthcheck.StatusStale) && errorMsg != "" {
|
if (status == healthcheck.StatusUnhealthy || status == healthcheck.StatusStale) && errorMsg != "" {
|
||||||
if ui, ok := m.statusUI.(interface{ SetError(id, msg string) }); ok {
|
if ui, ok := m.statusUI.(interface{ SetError(id, msg string) }); ok {
|
||||||
@@ -383,13 +428,28 @@ func (m *Manager) startWorker(fwd config.Forward) error {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create and start worker
|
// Start the worker (already created above)
|
||||||
worker := NewForwardWorker(fwd, m.portForwarder, m.verbose, m.statusUI, m.healthChecker, m.watchdog)
|
|
||||||
worker.Start()
|
worker.Start()
|
||||||
|
|
||||||
// Store worker
|
// Store worker
|
||||||
m.workers[fwd.ID()] = 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,6 +473,11 @@ func (m *Manager) stopWorkerInternal(id string, removeFromUI bool) error {
|
|||||||
m.healthChecker.Unregister(id)
|
m.healthChecker.Unregister(id)
|
||||||
m.watchdog.UnregisterWorker(id)
|
m.watchdog.UnregisterWorker(id)
|
||||||
|
|
||||||
|
// Unregister mDNS hostname
|
||||||
|
if m.mdnsPublisher != nil {
|
||||||
|
m.mdnsPublisher.Unregister(id)
|
||||||
|
}
|
||||||
|
|
||||||
// Notify UI - either remove or update to disabled status
|
// Notify UI - either remove or update to disabled status
|
||||||
if m.statusUI != nil {
|
if m.statusUI != nil {
|
||||||
if removeFromUI {
|
if removeFromUI {
|
||||||
@@ -449,6 +514,14 @@ func (m *Manager) GetWorkerCount() int {
|
|||||||
return len(m.workers)
|
return len(m.workers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetWorker returns a worker by ID, or nil if not found.
|
||||||
|
func (m *Manager) GetWorker(id string) *ForwardWorker {
|
||||||
|
m.workersMu.RLock()
|
||||||
|
defer m.workersMu.RUnlock()
|
||||||
|
|
||||||
|
return m.workers[id]
|
||||||
|
}
|
||||||
|
|
||||||
// extractPorts extracts all local ports from a list of forwards.
|
// extractPorts extracts all local ports from a list of forwards.
|
||||||
func (m *Manager) extractPorts(forwards []config.Forward) []int {
|
func (m *Manager) extractPorts(forwards []config.Forward) []int {
|
||||||
ports := make([]int, len(forwards))
|
ports := make([]int, len(forwards))
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
package forward
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nvm/kportal/internal/config"
|
||||||
|
"github.com/nvm/kportal/internal/events"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewManager tests manager creation
|
||||||
|
func TestNewManager(t *testing.T) {
|
||||||
|
t.Run("creates manager with default settings", func(t *testing.T) {
|
||||||
|
// Skip if no kubeconfig available (CI environment)
|
||||||
|
manager, err := NewManager(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("Skipping test - no kubeconfig available")
|
||||||
|
}
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
assert.NotNil(t, manager.workers)
|
||||||
|
assert.NotNil(t, manager.portChecker)
|
||||||
|
assert.NotNil(t, manager.healthChecker)
|
||||||
|
assert.NotNil(t, manager.watchdog)
|
||||||
|
assert.NotNil(t, manager.eventBus)
|
||||||
|
assert.False(t, manager.verbose)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("creates manager in verbose mode", func(t *testing.T) {
|
||||||
|
manager, err := NewManager(true)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("Skipping test - no kubeconfig available")
|
||||||
|
}
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
assert.True(t, manager.verbose)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManager_SetStatusUI tests setting the status UI
|
||||||
|
func TestManager_SetStatusUI(t *testing.T) {
|
||||||
|
manager, err := NewManager(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("Skipping test - no kubeconfig available")
|
||||||
|
}
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
mockUI := &MockStatusUpdater{}
|
||||||
|
manager.SetStatusUI(mockUI)
|
||||||
|
|
||||||
|
assert.Equal(t, mockUI, manager.statusUI)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManager_GetEventBus tests getting the event bus
|
||||||
|
func TestManager_GetEventBus(t *testing.T) {
|
||||||
|
manager, err := NewManager(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("Skipping test - no kubeconfig available")
|
||||||
|
}
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
bus := manager.GetEventBus()
|
||||||
|
assert.NotNil(t, bus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManager_GetWorkerCount tests worker count tracking
|
||||||
|
func TestManager_GetWorkerCount(t *testing.T) {
|
||||||
|
manager, err := NewManager(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("Skipping test - no kubeconfig available")
|
||||||
|
}
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
assert.Equal(t, 0, manager.GetWorkerCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManager_GetActiveForwards tests getting active forwards
|
||||||
|
func TestManager_GetActiveForwards(t *testing.T) {
|
||||||
|
manager, err := NewManager(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("Skipping test - no kubeconfig available")
|
||||||
|
}
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
forwards := manager.GetActiveForwards()
|
||||||
|
assert.Empty(t, forwards)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManager_GetWorker tests getting a worker by ID
|
||||||
|
func TestManager_GetWorker(t *testing.T) {
|
||||||
|
manager, err := NewManager(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("Skipping test - no kubeconfig available")
|
||||||
|
}
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
// Non-existent worker
|
||||||
|
worker := manager.GetWorker("non-existent")
|
||||||
|
assert.Nil(t, worker)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManager_Start_NilConfig tests starting with nil config
|
||||||
|
func TestManager_Start_NilConfig(t *testing.T) {
|
||||||
|
manager, err := NewManager(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("Skipping test - no kubeconfig available")
|
||||||
|
}
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
err = manager.Start(nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "configuration is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManager_Start_EmptyForwards tests starting with empty forwards
|
||||||
|
func TestManager_Start_EmptyForwards(t *testing.T) {
|
||||||
|
manager, err := NewManager(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("Skipping test - no kubeconfig available")
|
||||||
|
}
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
cfg := &config.Config{}
|
||||||
|
err = manager.Start(cfg)
|
||||||
|
// Empty config is now valid - allows users to add forwards via TUI
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManager_Reload_NilConfig tests reloading with nil config
|
||||||
|
func TestManager_Reload_NilConfig(t *testing.T) {
|
||||||
|
manager, err := NewManager(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("Skipping test - no kubeconfig available")
|
||||||
|
}
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
err = manager.Reload(nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "new configuration is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManager_EnableForward_NoConfig tests enabling without config
|
||||||
|
func TestManager_EnableForward_NoConfig(t *testing.T) {
|
||||||
|
manager, err := NewManager(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("Skipping test - no kubeconfig available")
|
||||||
|
}
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
err = manager.EnableForward("some-id")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "no configuration available")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManager_DisableForward_NotFound tests disabling non-existent forward
|
||||||
|
func TestManager_DisableForward_NotFound(t *testing.T) {
|
||||||
|
manager, err := NewManager(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("Skipping test - no kubeconfig available")
|
||||||
|
}
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
err = manager.DisableForward("non-existent")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "worker not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManager_extractPorts tests port extraction
|
||||||
|
func TestManager_extractPorts(t *testing.T) {
|
||||||
|
manager, err := NewManager(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("Skipping test - no kubeconfig available")
|
||||||
|
}
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
forwards := []config.Forward{
|
||||||
|
{LocalPort: 8080},
|
||||||
|
{LocalPort: 5432},
|
||||||
|
{LocalPort: 3000},
|
||||||
|
}
|
||||||
|
|
||||||
|
ports := manager.extractPorts(forwards)
|
||||||
|
assert.Equal(t, []int{8080, 5432, 3000}, ports)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManager_getResourceForPort tests finding resource by port
|
||||||
|
func TestManager_getResourceForPort(t *testing.T) {
|
||||||
|
manager, err := NewManager(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("Skipping test - no kubeconfig available")
|
||||||
|
}
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
forwards := []config.Forward{
|
||||||
|
{Resource: "pod/app1", LocalPort: 8080, Port: 80},
|
||||||
|
{Resource: "service/db", LocalPort: 5432, Port: 5432},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Found
|
||||||
|
resource := manager.getResourceForPort(forwards, 8080)
|
||||||
|
assert.Contains(t, resource, "app1")
|
||||||
|
|
||||||
|
// Not found
|
||||||
|
resource = manager.getResourceForPort(forwards, 9999)
|
||||||
|
assert.Equal(t, "unknown", resource)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockStatusUpdater is a mock implementation of StatusUpdater
|
||||||
|
type MockStatusUpdater struct {
|
||||||
|
updates []StatusUpdate
|
||||||
|
adds []ForwardAdd
|
||||||
|
removes []string
|
||||||
|
errorSets []ErrorSet
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusUpdate struct {
|
||||||
|
ID string
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ForwardAdd struct {
|
||||||
|
ID string
|
||||||
|
Fwd *config.Forward
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorSet struct {
|
||||||
|
ID string
|
||||||
|
Msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockStatusUpdater) UpdateStatus(id string, status string) {
|
||||||
|
m.updates = append(m.updates, StatusUpdate{ID: id, Status: status})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockStatusUpdater) AddForward(id string, fwd *config.Forward) {
|
||||||
|
m.adds = append(m.adds, ForwardAdd{ID: id, Fwd: fwd})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockStatusUpdater) Remove(id string) {
|
||||||
|
m.removes = append(m.removes, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockStatusUpdater) SetError(id, msg string) {
|
||||||
|
m.errorSets = append(m.errorSets, ErrorSet{ID: id, Msg: msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigureHealthChecker tests health checker configuration
|
||||||
|
func TestConfigureHealthChecker(t *testing.T) {
|
||||||
|
manager, err := NewManager(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("Skipping test - no kubeconfig available")
|
||||||
|
}
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
}{
|
||||||
|
{"tcp-dial method", "tcp-dial"},
|
||||||
|
{"data-transfer method", "data-transfer"},
|
||||||
|
{"unknown method defaults to data-transfer", "unknown"},
|
||||||
|
{"empty method defaults to data-transfer", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
HealthCheck: &config.HealthCheckSpec{
|
||||||
|
Method: tt.method,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not panic
|
||||||
|
manager.configureHealthChecker(cfg)
|
||||||
|
assert.NotNil(t, manager.healthChecker)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManager_Stop tests graceful shutdown
|
||||||
|
func TestManager_Stop(t *testing.T) {
|
||||||
|
manager, err := NewManager(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("Skipping test - no kubeconfig available")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop should not panic even with no workers
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
manager.Stop()
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Success
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("Stop timed out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManager_Reload_EmptyToEmpty tests reloading from empty to empty config
|
||||||
|
func TestManager_Reload_EmptyToEmpty(t *testing.T) {
|
||||||
|
manager, err := NewManager(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("Skipping test - no kubeconfig available")
|
||||||
|
}
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
cfg := &config.Config{}
|
||||||
|
err = manager.Reload(cfg)
|
||||||
|
// Should handle gracefully (stop all workers if any)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPortConflict tests the PortConflict struct
|
||||||
|
func TestPortConflict(t *testing.T) {
|
||||||
|
conflict := PortConflict{
|
||||||
|
Port: 8080,
|
||||||
|
Resource: "dev/default/pod/app:8080",
|
||||||
|
UsedBy: "nginx (PID 1234)",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, 8080, conflict.Port)
|
||||||
|
assert.Equal(t, "dev/default/pod/app:8080", conflict.Resource)
|
||||||
|
assert.Equal(t, "nginx (PID 1234)", conflict.UsedBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStatusUpdater_Interface tests that MockStatusUpdater implements StatusUpdater
|
||||||
|
func TestStatusUpdater_Interface(t *testing.T) {
|
||||||
|
var _ StatusUpdater = (*MockStatusUpdater)(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManager_WorkersMap tests workers map operations
|
||||||
|
func TestManager_WorkersMap(t *testing.T) {
|
||||||
|
manager, err := NewManager(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("Skipping test - no kubeconfig available")
|
||||||
|
}
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
assert.Empty(t, manager.workers)
|
||||||
|
|
||||||
|
// Verify concurrent-safe access patterns
|
||||||
|
manager.workersMu.RLock()
|
||||||
|
count := len(manager.workers)
|
||||||
|
manager.workersMu.RUnlock()
|
||||||
|
assert.Equal(t, 0, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestManager_EventBusIntegration tests event bus wiring
|
||||||
|
func TestManager_EventBusIntegration(t *testing.T) {
|
||||||
|
manager, err := NewManager(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("Skipping test - no kubeconfig available")
|
||||||
|
}
|
||||||
|
defer manager.Stop()
|
||||||
|
|
||||||
|
// Event bus should be wired to health checker and watchdog
|
||||||
|
assert.NotNil(t, manager.eventBus)
|
||||||
|
|
||||||
|
// Get event bus
|
||||||
|
bus := manager.GetEventBus()
|
||||||
|
require.NotNil(t, bus)
|
||||||
|
|
||||||
|
// SubscribeAll should work (no return value in this API)
|
||||||
|
bus.SubscribeAll(func(event events.Event) {
|
||||||
|
// Handler
|
||||||
|
})
|
||||||
|
}
|
||||||
+147
-42
@@ -6,11 +6,20 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"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
|
// isValidPID validates that a PID string contains only digits
|
||||||
func isValidPID(pid string) bool {
|
func isValidPID(pid string) bool {
|
||||||
if len(pid) == 0 || len(pid) > 9 {
|
if len(pid) == 0 || len(pid) > maxPIDLength {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, c := range pid {
|
for _, c := range pid {
|
||||||
@@ -21,6 +30,73 @@ func isValidPID(pid string) bool {
|
|||||||
return true
|
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 {
|
||||||
|
// #nosec G204 -- pid is validated by isValidPID() to contain only digits
|
||||||
|
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.
|
// PortConflict represents a local port that is already in use.
|
||||||
type PortConflict struct {
|
type PortConflict struct {
|
||||||
Port int // The conflicting port number
|
Port int // The conflicting port number
|
||||||
@@ -70,7 +146,7 @@ func (pc *PortChecker) isPortAvailable(port int) bool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
listener.Close()
|
_ = listener.Close()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +167,7 @@ func (pc *PortChecker) getProcessUsingPort(port int) string {
|
|||||||
func (pc *PortChecker) getProcessUsingPortUnix(port int) string {
|
func (pc *PortChecker) getProcessUsingPortUnix(port int) string {
|
||||||
// Use lsof to find the process
|
// Use lsof to find the process
|
||||||
// lsof -i :PORT -sTCP:LISTEN -t returns PIDs
|
// lsof -i :PORT -sTCP:LISTEN -t returns PIDs
|
||||||
|
// #nosec G204 -- port is an integer from config validation, not user input
|
||||||
cmd := exec.Command("lsof", "-i", fmt.Sprintf(":%d", port), "-sTCP:LISTEN", "-t")
|
cmd := exec.Command("lsof", "-i", fmt.Sprintf(":%d", port), "-sTCP:LISTEN", "-t")
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -102,27 +179,55 @@ func (pc *PortChecker) getProcessUsingPortUnix(port int) string {
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the first PID if multiple are returned
|
// Handle multiple PIDs (multiple processes on same port)
|
||||||
pids := strings.Split(pidStr, "\n")
|
pids := strings.Split(pidStr, "\n")
|
||||||
pid := pids[0]
|
var validProcesses []processInfo
|
||||||
|
|
||||||
if !isValidPID(pid) {
|
for _, pid := range pids {
|
||||||
return "unknown"
|
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
|
return formatProcessList(validProcesses)
|
||||||
cmd = exec.Command("ps", "-p", pid, "-o", "comm=")
|
}
|
||||||
output, err = cmd.Output()
|
|
||||||
if err != nil {
|
// isListeningState checks if a netstat line indicates a listening state.
|
||||||
return fmt.Sprintf("PID %s", pid)
|
// 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))
|
// Fallback: check if line contains LISTENING (most common case)
|
||||||
if procName == "" {
|
return strings.Contains(upperLine, "LISTENING")
|
||||||
return fmt.Sprintf("PID %s", pid)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%s (PID %s)", procName, pid)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getProcessUsingPortWindows uses netstat to find the process using a port on Windows.
|
// getProcessUsingPortWindows uses netstat to find the process using a port on Windows.
|
||||||
@@ -138,6 +243,8 @@ func (pc *PortChecker) getProcessUsingPortWindows(port int) string {
|
|||||||
lines := strings.Split(string(output), "\n")
|
lines := strings.Split(string(output), "\n")
|
||||||
portStr := fmt.Sprintf(":%d", port)
|
portStr := fmt.Sprintf(":%d", port)
|
||||||
|
|
||||||
|
var validProcesses []processInfo
|
||||||
|
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
if !strings.Contains(line, portStr) {
|
if !strings.Contains(line, portStr) {
|
||||||
continue
|
continue
|
||||||
@@ -146,44 +253,42 @@ func (pc *PortChecker) getProcessUsingPortWindows(port int) string {
|
|||||||
// Parse the line to extract PID
|
// Parse the line to extract PID
|
||||||
// Format: TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 1234
|
// Format: TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 1234
|
||||||
fields := strings.Fields(line)
|
fields := strings.Fields(line)
|
||||||
if len(fields) < 5 {
|
if len(fields) < minNetstatFields {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a LISTENING state
|
// Check if this is a LISTENING state (locale-aware)
|
||||||
if !strings.Contains(strings.ToUpper(line), "LISTENING") {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
pid := fields[len(fields)-1]
|
pid := fields[len(fields)-1]
|
||||||
|
|
||||||
if !isValidPID(pid) {
|
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
|
procName := getProcessNameByPIDWindows(pid)
|
||||||
cmd = exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %s", pid), "/FO", "CSV", "/NH")
|
validProcesses = append(validProcesses, processInfo{
|
||||||
output, err = cmd.Output()
|
pid: pid,
|
||||||
if err != nil {
|
name: procName,
|
||||||
return fmt.Sprintf("PID %s", pid)
|
isValid: true,
|
||||||
}
|
})
|
||||||
|
|
||||||
// Parse CSV output: "process.exe","1234","Console","1","12,345 K"
|
|
||||||
csvLine := strings.TrimSpace(string(output))
|
|
||||||
if csvLine == "" {
|
|
||||||
return fmt.Sprintf("PID %s", pid)
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(csvLine, ",")
|
|
||||||
if len(parts) > 0 {
|
|
||||||
procName := strings.Trim(parts[0], "\"")
|
|
||||||
return fmt.Sprintf("%s (PID %s)", procName, pid)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("PID %s", pid)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "unknown"
|
return formatProcessList(validProcesses)
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormatConflicts formats port conflicts into a human-readable error message.
|
// FormatConflicts formats port conflicts into a human-readable error message.
|
||||||
|
|||||||
@@ -2,11 +2,186 @@ package forward
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestIsValidPID tests PID validation
|
||||||
|
func TestIsValidPID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
pid string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"valid single digit", "1", true},
|
||||||
|
{"valid multi digit", "12345", true},
|
||||||
|
{"valid max length", "123456789", true},
|
||||||
|
{"empty string", "", false},
|
||||||
|
{"too long", "1234567890", false},
|
||||||
|
{"contains letter", "123a", false},
|
||||||
|
{"contains space", "123 ", false},
|
||||||
|
{"negative sign", "-123", false},
|
||||||
|
{"decimal", "12.3", false},
|
||||||
|
{"just zero", "0", true},
|
||||||
|
{"leading zeros", "00123", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := isValidPID(tt.pid)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatProcessInfo tests process info formatting
|
||||||
|
func TestFormatProcessInfo(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
info processInfo
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "invalid process",
|
||||||
|
info: processInfo{isValid: false},
|
||||||
|
expected: "unknown",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid with name and pid",
|
||||||
|
info: processInfo{pid: "1234", name: "nginx", isValid: true},
|
||||||
|
expected: "nginx (PID 1234)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid with only pid",
|
||||||
|
info: processInfo{pid: "5678", name: "", isValid: true},
|
||||||
|
expected: "PID 5678",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := formatProcessInfo(tt.info)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatProcessList tests process list formatting
|
||||||
|
func TestFormatProcessList(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
processes []processInfo
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty list",
|
||||||
|
processes: []processInfo{},
|
||||||
|
expected: "unknown",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single process",
|
||||||
|
processes: []processInfo{{pid: "1234", name: "nginx", isValid: true}},
|
||||||
|
expected: "nginx (PID 1234)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple processes",
|
||||||
|
processes: []processInfo{
|
||||||
|
{pid: "1234", name: "nginx", isValid: true},
|
||||||
|
{pid: "5678", name: "node", isValid: true},
|
||||||
|
},
|
||||||
|
expected: "nginx (PID 1234), node (PID 5678)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := formatProcessList(tt.processes)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsListeningState tests listening state detection
|
||||||
|
func TestIsListeningState(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
line string
|
||||||
|
fields []string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "English LISTENING",
|
||||||
|
line: "TCP 0.0.0.0:8080 0.0.0.0:0 LISTENING 1234",
|
||||||
|
fields: []string{"TCP", "0.0.0.0:8080", "0.0.0.0:0", "LISTENING", "1234"},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "German ABHÖREN",
|
||||||
|
line: "TCP 0.0.0.0:8080 0.0.0.0:0 ABHÖREN 1234",
|
||||||
|
fields: []string{"TCP", "0.0.0.0:8080", "0.0.0.0:0", "ABHÖREN", "1234"},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "French ÉCOUTE",
|
||||||
|
line: "TCP 0.0.0.0:8080 0.0.0.0:0 ÉCOUTE 1234",
|
||||||
|
fields: []string{"TCP", "0.0.0.0:8080", "0.0.0.0:0", "ÉCOUTE", "1234"},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Spanish ESCUCHANDO",
|
||||||
|
line: "TCP 0.0.0.0:8080 0.0.0.0:0 ESCUCHANDO 1234",
|
||||||
|
fields: []string{"TCP", "0.0.0.0:8080", "0.0.0.0:0", "ESCUCHANDO", "1234"},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ESTABLISHED (not listening)",
|
||||||
|
line: "TCP 192.168.1.1:8080 10.0.0.1:443 ESTABLISHED 1234",
|
||||||
|
fields: []string{"TCP", "192.168.1.1:8080", "10.0.0.1:443", "ESTABLISHED", "1234"},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "too few fields",
|
||||||
|
line: "TCP 0.0.0.0:8080",
|
||||||
|
fields: []string{"TCP", "0.0.0.0:8080"},
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lowercase listening (via fallback)",
|
||||||
|
line: "tcp 0.0.0.0:8080 0.0.0.0:0 listening 1234",
|
||||||
|
fields: []string{"tcp", "0.0.0.0:8080", "0.0.0.0:0", "listening", "1234"},
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := isListeningState(tt.line, tt.fields)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetProcessNameByPID tests process name lookup
|
||||||
|
func TestGetProcessNameByPID(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("Skipping Unix-specific test on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with PID 1 (init/systemd on Linux, launchd on macOS)
|
||||||
|
// This should return something on Unix systems
|
||||||
|
name := getProcessNameByPID("1")
|
||||||
|
// We don't assert the exact name since it varies by OS
|
||||||
|
// Just verify no panic and returns string
|
||||||
|
assert.IsType(t, "", name)
|
||||||
|
|
||||||
|
// Test with invalid PID
|
||||||
|
name = getProcessNameByPID("999999999")
|
||||||
|
// Should return empty string for non-existent process
|
||||||
|
assert.IsType(t, "", name)
|
||||||
|
}
|
||||||
|
|
||||||
func TestPortChecker_IsAvailable(t *testing.T) {
|
func TestPortChecker_IsAvailable(t *testing.T) {
|
||||||
pc := NewPortChecker()
|
pc := NewPortChecker()
|
||||||
|
|
||||||
|
|||||||
+128
-26
@@ -5,18 +5,29 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/nvm/kportal/internal/events"
|
||||||
"github.com/nvm/kportal/internal/logger"
|
"github.com/nvm/kportal/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Watchdog monitors worker goroutines to detect hung workers
|
const (
|
||||||
|
// defaultHeartbeatInterval is how often the watchdog sends heartbeats to workers
|
||||||
|
defaultHeartbeatInterval = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Watchdog monitors worker goroutines to detect hung workers.
|
||||||
|
// It centralizes heartbeat management - instead of each worker sending heartbeats,
|
||||||
|
// the watchdog polls workers periodically. This reduces goroutine count and
|
||||||
|
// simplifies worker implementation.
|
||||||
type Watchdog struct {
|
type Watchdog struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
workers map[string]*workerState // key: forward ID
|
workers map[string]*workerState // key: forward ID
|
||||||
checkInterval time.Duration
|
checkInterval time.Duration
|
||||||
hangThreshold time.Duration // How long without heartbeat before considered hung
|
hangThreshold time.Duration // How long without heartbeat before considered hung
|
||||||
ctx context.Context
|
heartbeatInterval time.Duration // How often to poll workers for heartbeat
|
||||||
cancel context.CancelFunc
|
ctx context.Context
|
||||||
wg sync.WaitGroup
|
cancel context.CancelFunc
|
||||||
|
wg sync.WaitGroup
|
||||||
|
eventBus *events.Bus // Optional event bus for decoupled communication
|
||||||
}
|
}
|
||||||
|
|
||||||
// workerState tracks the health of a single worker
|
// workerState tracks the health of a single worker
|
||||||
@@ -26,20 +37,37 @@ type workerState struct {
|
|||||||
heartbeatCount uint64
|
heartbeatCount uint64
|
||||||
isHung bool
|
isHung bool
|
||||||
onHungCallback func(forwardID string)
|
onHungCallback func(forwardID string)
|
||||||
|
worker HeartbeatResponder // Reference to worker for heartbeat polling
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeartbeatResponder is an interface for workers that can respond to heartbeat checks
|
||||||
|
type HeartbeatResponder interface {
|
||||||
|
// IsAlive returns true if the worker is still responsive
|
||||||
|
IsAlive() bool
|
||||||
|
// GetForwardID returns the forward ID this worker manages
|
||||||
|
GetForwardID() string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWatchdog creates a new goroutine watchdog
|
// NewWatchdog creates a new goroutine watchdog
|
||||||
func NewWatchdog(checkInterval, hangThreshold time.Duration) *Watchdog {
|
func NewWatchdog(checkInterval, hangThreshold time.Duration) *Watchdog {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
return &Watchdog{
|
return &Watchdog{
|
||||||
workers: make(map[string]*workerState),
|
workers: make(map[string]*workerState),
|
||||||
checkInterval: checkInterval,
|
checkInterval: checkInterval,
|
||||||
hangThreshold: hangThreshold,
|
hangThreshold: hangThreshold,
|
||||||
ctx: ctx,
|
heartbeatInterval: defaultHeartbeatInterval,
|
||||||
cancel: cancel,
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetEventBus sets the event bus for publishing watchdog events
|
||||||
|
func (w *Watchdog) SetEventBus(bus *events.Bus) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
w.eventBus = bus
|
||||||
|
}
|
||||||
|
|
||||||
// Start begins the watchdog monitoring loop
|
// Start begins the watchdog monitoring loop
|
||||||
func (w *Watchdog) Start() {
|
func (w *Watchdog) Start() {
|
||||||
w.wg.Add(1)
|
w.wg.Add(1)
|
||||||
@@ -70,6 +98,25 @@ func (w *Watchdog) RegisterWorker(forwardID string, onHungCallback func(string))
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterWorkerWithResponder adds a worker to monitor with heartbeat polling support
|
||||||
|
func (w *Watchdog) RegisterWorkerWithResponder(forwardID string, responder HeartbeatResponder, 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,
|
||||||
|
worker: responder,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug("Watchdog registered worker with responder", map[string]interface{}{
|
||||||
|
"forward_id": forwardID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// UnregisterWorker removes a worker from monitoring
|
// UnregisterWorker removes a worker from monitoring
|
||||||
func (w *Watchdog) UnregisterWorker(forwardID string) {
|
func (w *Watchdog) UnregisterWorker(forwardID string) {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
@@ -82,8 +129,9 @@ func (w *Watchdog) UnregisterWorker(forwardID string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heartbeat records that a worker is alive and processing
|
// Heartbeat records that a worker is alive and processing.
|
||||||
// Workers should call this periodically (e.g., in their main loop)
|
// This can be called by workers directly (legacy) or the watchdog can poll
|
||||||
|
// workers via HeartbeatResponder interface.
|
||||||
func (w *Watchdog) Heartbeat(forwardID string) {
|
func (w *Watchdog) Heartbeat(forwardID string) {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
@@ -106,28 +154,68 @@ func (w *Watchdog) GetWorkerState(forwardID string) (lastHeartbeat time.Time, co
|
|||||||
return time.Time{}, 0, false
|
return time.Time{}, 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// monitorLoop periodically checks all workers
|
// monitorLoop periodically checks all workers and polls for heartbeats
|
||||||
func (w *Watchdog) monitorLoop() {
|
func (w *Watchdog) monitorLoop() {
|
||||||
defer w.wg.Done()
|
defer w.wg.Done()
|
||||||
|
|
||||||
ticker := time.NewTicker(w.checkInterval)
|
checkTicker := time.NewTicker(w.checkInterval)
|
||||||
defer ticker.Stop()
|
defer checkTicker.Stop()
|
||||||
|
|
||||||
|
// Heartbeat polling ticker - polls workers for heartbeat more frequently
|
||||||
|
heartbeatTicker := time.NewTicker(w.heartbeatInterval)
|
||||||
|
defer heartbeatTicker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-w.ctx.Done():
|
case <-w.ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-heartbeatTicker.C:
|
||||||
|
// Poll all workers for heartbeat (centralized heartbeat management)
|
||||||
|
w.pollHeartbeats()
|
||||||
|
case <-checkTicker.C:
|
||||||
|
// Check for hung workers
|
||||||
w.checkWorkers()
|
w.checkWorkers()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkWorkers checks all registered workers for hung state
|
// pollHeartbeats polls all registered workers for heartbeat.
|
||||||
func (w *Watchdog) checkWorkers() {
|
// This centralizes heartbeat management in the watchdog instead of having
|
||||||
|
// each worker spawn its own heartbeat goroutine.
|
||||||
|
func (w *Watchdog) pollHeartbeats() {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for forwardID, state := range w.workers {
|
||||||
|
// If worker has a responder, poll it
|
||||||
|
if state.worker != nil {
|
||||||
|
if state.worker.IsAlive() {
|
||||||
|
state.lastHeartbeat = now
|
||||||
|
state.heartbeatCount++
|
||||||
|
state.isHung = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If no responder, worker must call Heartbeat() directly (legacy mode)
|
||||||
|
// This maintains backward compatibility
|
||||||
|
_ = forwardID // Avoid unused variable warning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
var eventBus *events.Bus
|
||||||
|
|
||||||
|
w.mu.Lock()
|
||||||
|
eventBus = w.eventBus
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for forwardID, state := range w.workers {
|
for forwardID, state := range w.workers {
|
||||||
timeSinceHeartbeat := now.Sub(state.lastHeartbeat)
|
timeSinceHeartbeat := now.Sub(state.lastHeartbeat)
|
||||||
@@ -145,14 +233,28 @@ func (w *Watchdog) checkWorkers() {
|
|||||||
"heartbeat_count": state.heartbeatCount,
|
"heartbeat_count": state.heartbeatCount,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Trigger callback to handle hung worker (without holding lock)
|
// Collect callback for deferred execution outside the lock
|
||||||
if state.onHungCallback != nil {
|
if state.onHungCallback != nil {
|
||||||
callback := state.onHungCallback
|
hungWorkers = append(hungWorkers, hungWorkerInfo{
|
||||||
w.mu.Unlock()
|
forwardID: forwardID,
|
||||||
callback(forwardID)
|
callback: state.onHungCallback,
|
||||||
w.mu.Lock()
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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 {
|
||||||
|
// Publish event if event bus is available
|
||||||
|
if eventBus != nil {
|
||||||
|
eventBus.Publish(events.NewWorkerHungEvent(hw.forwardID, w.hangThreshold.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
hw.callback(hw.forwardID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,12 +154,21 @@ func (s *WatchdogTestSuite) TestMultipleWorkers() {
|
|||||||
s.watchdog.RegisterWorker("worker-3", makeCallback("worker-3"))
|
s.watchdog.RegisterWorker("worker-3", makeCallback("worker-3"))
|
||||||
|
|
||||||
// worker-1: Keep sending heartbeats (healthy)
|
// worker-1: Keep sending heartbeats (healthy)
|
||||||
|
// Use a done channel to ensure goroutine exits before test ends
|
||||||
ticker1 := time.NewTicker(50 * time.Millisecond)
|
ticker1 := time.NewTicker(50 * time.Millisecond)
|
||||||
defer ticker1.Stop()
|
done := make(chan struct{})
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer ticker1.Stop()
|
||||||
for i := 0; i < 10; i++ {
|
for i := 0; i < 10; i++ {
|
||||||
<-ticker1.C
|
select {
|
||||||
s.watchdog.Heartbeat("worker-1")
|
case <-ticker1.C:
|
||||||
|
s.watchdog.Heartbeat("worker-1")
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -172,6 +181,10 @@ func (s *WatchdogTestSuite) TestMultipleWorkers() {
|
|||||||
// Wait for hung workers to be detected
|
// Wait for hung workers to be detected
|
||||||
time.Sleep(600 * time.Millisecond)
|
time.Sleep(600 * time.Millisecond)
|
||||||
|
|
||||||
|
// Signal goroutine to stop and wait for it
|
||||||
|
close(done)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
// Check results
|
// Check results
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|||||||
+135
-13
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/nvm/kportal/internal/config"
|
"github.com/nvm/kportal/internal/config"
|
||||||
"github.com/nvm/kportal/internal/healthcheck"
|
"github.com/nvm/kportal/internal/healthcheck"
|
||||||
|
"github.com/nvm/kportal/internal/httplog"
|
||||||
"github.com/nvm/kportal/internal/k8s"
|
"github.com/nvm/kportal/internal/k8s"
|
||||||
"github.com/nvm/kportal/internal/logger"
|
"github.com/nvm/kportal/internal/logger"
|
||||||
"github.com/nvm/kportal/internal/retry"
|
"github.com/nvm/kportal/internal/retry"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
portForwardReadyTimeout = 30 * time.Second
|
portForwardReadyTimeout = 30 * time.Second
|
||||||
|
httpLogPortOffset = 10000 // Offset for internal port when HTTP logging is enabled
|
||||||
)
|
)
|
||||||
|
|
||||||
// ForwardWorker manages a single port-forward connection with automatic retry.
|
// ForwardWorker manages a single port-forward connection with automatic retry.
|
||||||
@@ -27,7 +29,8 @@ type ForwardWorker struct {
|
|||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
doneChan chan struct{}
|
doneChan chan struct{}
|
||||||
reconnectChan chan string // Channel to trigger reconnection
|
reconnectChan chan string // Channel to trigger reconnection
|
||||||
|
successChan chan struct{} // Channel to signal successful connection (for backoff reset)
|
||||||
verbose bool
|
verbose bool
|
||||||
lastPod string // Track the last pod we connected to
|
lastPod string // Track the last pod we connected to
|
||||||
statusUI StatusUpdater
|
statusUI StatusUpdater
|
||||||
@@ -36,6 +39,7 @@ type ForwardWorker struct {
|
|||||||
startTime time.Time // Track when the worker started
|
startTime time.Time // Track when the worker started
|
||||||
forwardCancel context.CancelFunc // Cancel function for current forward attempt
|
forwardCancel context.CancelFunc // Cancel function for current forward attempt
|
||||||
forwardCancelMu sync.Mutex // Protects forwardCancel
|
forwardCancelMu sync.Mutex // Protects forwardCancel
|
||||||
|
httpProxy *httplog.Proxy // HTTP logging proxy (nil if not enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewForwardWorker creates a new ForwardWorker for a single forward configuration.
|
// NewForwardWorker creates a new ForwardWorker for a single forward configuration.
|
||||||
@@ -49,7 +53,8 @@ func NewForwardWorker(fwd config.Forward, portForwarder *k8s.PortForwarder, verb
|
|||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
doneChan: make(chan struct{}),
|
doneChan: make(chan struct{}),
|
||||||
reconnectChan: make(chan string, 1), // Buffered to avoid blocking
|
reconnectChan: make(chan string, 1), // Buffered to avoid blocking
|
||||||
|
successChan: make(chan struct{}, 1), // Buffered to avoid blocking
|
||||||
verbose: verbose,
|
verbose: verbose,
|
||||||
statusUI: statusUI,
|
statusUI: statusUI,
|
||||||
healthChecker: healthChecker,
|
healthChecker: healthChecker,
|
||||||
@@ -58,6 +63,16 @@ func NewForwardWorker(fwd config.Forward, portForwarder *k8s.PortForwarder, verb
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// signalConnectionSuccess signals that a connection was successfully established.
|
||||||
|
// This is used to reset the backoff timer after a successful connection.
|
||||||
|
func (w *ForwardWorker) signalConnectionSuccess() {
|
||||||
|
select {
|
||||||
|
case w.successChan <- struct{}{}:
|
||||||
|
default:
|
||||||
|
// Channel already has pending signal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TriggerReconnect triggers a reconnection (e.g., due to stale connection)
|
// TriggerReconnect triggers a reconnection (e.g., due to stale connection)
|
||||||
func (w *ForwardWorker) TriggerReconnect(reason string) {
|
func (w *ForwardWorker) TriggerReconnect(reason string) {
|
||||||
// Cancel current forward if running
|
// Cancel current forward if running
|
||||||
@@ -85,28 +100,68 @@ func (w *ForwardWorker) Start() {
|
|||||||
func (w *ForwardWorker) Stop() {
|
func (w *ForwardWorker) Stop() {
|
||||||
w.cancel()
|
w.cancel()
|
||||||
close(w.stopChan)
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAlive implements HeartbeatResponder interface.
|
||||||
|
// Returns true if the worker goroutine is still running and responsive.
|
||||||
|
func (w *ForwardWorker) IsAlive() bool {
|
||||||
|
select {
|
||||||
|
case <-w.doneChan:
|
||||||
|
return false
|
||||||
|
case <-w.ctx.Done():
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetForwardID implements HeartbeatResponder interface.
|
||||||
|
func (w *ForwardWorker) GetForwardID() string {
|
||||||
|
return w.forward.ID()
|
||||||
}
|
}
|
||||||
|
|
||||||
// run is the main worker loop that handles retries.
|
// run is the main worker loop that handles retries.
|
||||||
func (w *ForwardWorker) run() {
|
func (w *ForwardWorker) run() {
|
||||||
defer close(w.doneChan)
|
defer close(w.doneChan)
|
||||||
|
defer w.stopHTTPProxy() // Ensure proxy is stopped on exit
|
||||||
|
|
||||||
|
// Note: Heartbeat management is now centralized in the Watchdog.
|
||||||
|
// The watchdog polls workers via the HeartbeatResponder interface (IsAlive method)
|
||||||
|
// instead of each worker spawning its own heartbeat goroutine.
|
||||||
|
// This reduces goroutine count from 2N to N for N workers.
|
||||||
|
|
||||||
|
// Start HTTP logging proxy if enabled
|
||||||
|
if err := w.startHTTPProxy(); err != nil {
|
||||||
|
logger.Error("Failed to start HTTP logging proxy", map[string]interface{}{
|
||||||
|
"forward_id": w.forward.ID(),
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
// Continue without HTTP logging
|
||||||
|
}
|
||||||
|
|
||||||
backoff := retry.NewBackoff()
|
backoff := retry.NewBackoff()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
// Send heartbeat to watchdog to indicate we're alive
|
// Check if we should stop or reset backoff on successful connection
|
||||||
if w.watchdog != nil {
|
|
||||||
w.watchdog.Heartbeat(w.forward.ID())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we should stop
|
|
||||||
select {
|
select {
|
||||||
case <-w.ctx.Done():
|
case <-w.ctx.Done():
|
||||||
if w.verbose {
|
if w.verbose {
|
||||||
log.Printf("[%s] Worker stopped", w.forward.ID())
|
log.Printf("[%s] Worker stopped", w.forward.ID())
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
case <-w.successChan:
|
||||||
|
// Reset backoff after successful connection
|
||||||
|
backoff.Reset()
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,15 +279,26 @@ func (w *ForwardWorker) establishForward(podName string) error {
|
|||||||
w.forwardCancelMu.Unlock()
|
w.forwardCancelMu.Unlock()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Use sync.Once to ensure stopChan is closed exactly once
|
||||||
|
var closeOnce sync.Once
|
||||||
|
closeStopChan := func() {
|
||||||
|
closeOnce.Do(func() {
|
||||||
|
close(stopChan)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure stopChan is closed when this function exits (prevents goroutine leak)
|
||||||
|
defer closeStopChan()
|
||||||
|
|
||||||
// Start a goroutine to monitor for stop signal and reconnect triggers
|
// Start a goroutine to monitor for stop signal and reconnect triggers
|
||||||
go func() {
|
go func() {
|
||||||
select {
|
select {
|
||||||
case <-w.stopChan:
|
case <-w.stopChan:
|
||||||
close(stopChan)
|
closeStopChan()
|
||||||
case <-w.reconnectChan:
|
case <-w.reconnectChan:
|
||||||
close(stopChan)
|
closeStopChan()
|
||||||
case <-forwardCtx.Done():
|
case <-forwardCtx.Done():
|
||||||
close(stopChan)
|
closeStopChan()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -246,13 +312,20 @@ func (w *ForwardWorker) establishForward(podName string) error {
|
|||||||
errOut = io.Discard
|
errOut = io.Discard
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine local port for k8s port-forward
|
||||||
|
// If HTTP logging is enabled, we bind to an internal port and the proxy listens on the user-facing port
|
||||||
|
localPort := w.forward.LocalPort
|
||||||
|
if w.httpProxy != nil {
|
||||||
|
localPort = w.httpProxy.GetTargetPort()
|
||||||
|
}
|
||||||
|
|
||||||
// Create forward request
|
// Create forward request
|
||||||
req := &k8s.ForwardRequest{
|
req := &k8s.ForwardRequest{
|
||||||
ContextName: w.forward.GetContext(),
|
ContextName: w.forward.GetContext(),
|
||||||
Namespace: w.forward.GetNamespace(),
|
Namespace: w.forward.GetNamespace(),
|
||||||
Resource: w.forward.Resource,
|
Resource: w.forward.Resource,
|
||||||
Selector: w.forward.Selector,
|
Selector: w.forward.Selector,
|
||||||
LocalPort: w.forward.LocalPort,
|
LocalPort: localPort,
|
||||||
RemotePort: w.forward.Port,
|
RemotePort: w.forward.Port,
|
||||||
StopChan: stopChan,
|
StopChan: stopChan,
|
||||||
ReadyChan: readyChan,
|
ReadyChan: readyChan,
|
||||||
@@ -276,6 +349,8 @@ func (w *ForwardWorker) establishForward(podName string) error {
|
|||||||
if w.healthChecker != nil {
|
if w.healthChecker != nil {
|
||||||
w.healthChecker.MarkConnected(w.forward.ID())
|
w.healthChecker.MarkConnected(w.forward.ID())
|
||||||
}
|
}
|
||||||
|
// Signal success back to caller so backoff can be reset
|
||||||
|
w.signalConnectionSuccess()
|
||||||
case err := <-errChan:
|
case err := <-errChan:
|
||||||
return fmt.Errorf("failed to establish forward: %w", err)
|
return fmt.Errorf("failed to establish forward: %w", err)
|
||||||
case <-w.ctx.Done():
|
case <-w.ctx.Done():
|
||||||
@@ -325,6 +400,53 @@ func (w *ForwardWorker) IsRunning() bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startHTTPProxy starts the HTTP logging proxy if enabled
|
||||||
|
func (w *ForwardWorker) startHTTPProxy() error {
|
||||||
|
if !w.forward.IsHTTPLogEnabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate internal port for k8s tunnel
|
||||||
|
targetPort := w.forward.LocalPort + httpLogPortOffset
|
||||||
|
|
||||||
|
proxy, err := httplog.NewProxy(&w.forward, targetPort)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create HTTP proxy: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := proxy.Start(); err != nil {
|
||||||
|
return fmt.Errorf("failed to start HTTP proxy: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.httpProxy = proxy
|
||||||
|
|
||||||
|
logger.Info("HTTP logging proxy started", map[string]interface{}{
|
||||||
|
"forward_id": w.forward.ID(),
|
||||||
|
"local_port": w.forward.LocalPort,
|
||||||
|
"target_port": targetPort,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopHTTPProxy stops the HTTP logging proxy if running
|
||||||
|
func (w *ForwardWorker) stopHTTPProxy() {
|
||||||
|
if w.httpProxy != nil {
|
||||||
|
if err := w.httpProxy.Stop(); err != nil {
|
||||||
|
logger.Warn("Failed to stop HTTP proxy", map[string]interface{}{
|
||||||
|
"forward_id": w.forward.ID(),
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
w.httpProxy = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHTTPProxy returns the HTTP logging proxy if active
|
||||||
|
func (w *ForwardWorker) GetHTTPProxy() *httplog.Proxy {
|
||||||
|
return w.httpProxy
|
||||||
|
}
|
||||||
|
|
||||||
// logWriter implements io.Writer to write log messages with a prefix.
|
// logWriter implements io.Writer to write log messages with a prefix.
|
||||||
type logWriter struct {
|
type logWriter struct {
|
||||||
prefix string
|
prefix string
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package forward
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nvm/kportal/internal/config"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewForwardWorker tests worker creation
|
||||||
|
func TestNewForwardWorker(t *testing.T) {
|
||||||
|
fwd := config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
LocalPort: 8080,
|
||||||
|
Port: 80,
|
||||||
|
}
|
||||||
|
|
||||||
|
worker := NewForwardWorker(fwd, nil, false, nil, nil, nil)
|
||||||
|
|
||||||
|
assert.NotNil(t, worker)
|
||||||
|
assert.Equal(t, fwd, worker.forward)
|
||||||
|
assert.False(t, worker.verbose)
|
||||||
|
assert.NotNil(t, worker.ctx)
|
||||||
|
assert.NotNil(t, worker.stopChan)
|
||||||
|
assert.NotNil(t, worker.doneChan)
|
||||||
|
assert.NotNil(t, worker.reconnectChan)
|
||||||
|
assert.NotNil(t, worker.successChan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewForwardWorker_Verbose tests verbose mode worker creation
|
||||||
|
func TestNewForwardWorker_Verbose(t *testing.T) {
|
||||||
|
fwd := config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
LocalPort: 8080,
|
||||||
|
Port: 80,
|
||||||
|
}
|
||||||
|
|
||||||
|
worker := NewForwardWorker(fwd, nil, true, nil, nil, nil)
|
||||||
|
|
||||||
|
assert.True(t, worker.verbose)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWorker_GetForwardConfig tests getting forward config
|
||||||
|
func TestWorker_GetForwardConfig(t *testing.T) {
|
||||||
|
fwd := config.Forward{
|
||||||
|
Resource: "service/postgres",
|
||||||
|
LocalPort: 5432,
|
||||||
|
Port: 5432,
|
||||||
|
Alias: "db",
|
||||||
|
}
|
||||||
|
|
||||||
|
worker := NewForwardWorker(fwd, nil, false, nil, nil, nil)
|
||||||
|
result := worker.GetForward()
|
||||||
|
|
||||||
|
assert.Equal(t, fwd, result)
|
||||||
|
assert.Equal(t, "service/postgres", result.Resource)
|
||||||
|
assert.Equal(t, 5432, result.LocalPort)
|
||||||
|
assert.Equal(t, "db", result.Alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForwardWorker_GetForwardID tests GetForwardID implementation
|
||||||
|
func TestForwardWorker_GetForwardID(t *testing.T) {
|
||||||
|
fwd := config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
LocalPort: 8080,
|
||||||
|
Port: 80,
|
||||||
|
}
|
||||||
|
|
||||||
|
worker := NewForwardWorker(fwd, nil, false, nil, nil, nil)
|
||||||
|
id := worker.GetForwardID()
|
||||||
|
|
||||||
|
assert.NotEmpty(t, id)
|
||||||
|
assert.Equal(t, fwd.ID(), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForwardWorker_IsAlive tests IsAlive implementation
|
||||||
|
func TestForwardWorker_IsAlive(t *testing.T) {
|
||||||
|
fwd := config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
LocalPort: 8080,
|
||||||
|
Port: 80,
|
||||||
|
}
|
||||||
|
|
||||||
|
worker := NewForwardWorker(fwd, nil, false, nil, nil, nil)
|
||||||
|
|
||||||
|
// Before starting, worker should be "alive" (context not cancelled)
|
||||||
|
assert.True(t, worker.IsAlive())
|
||||||
|
|
||||||
|
// Cancel context
|
||||||
|
worker.cancel()
|
||||||
|
|
||||||
|
// After cancel, IsAlive should return false
|
||||||
|
assert.False(t, worker.IsAlive())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWorker_IsRunningState tests IsRunning method
|
||||||
|
func TestWorker_IsRunningState(t *testing.T) {
|
||||||
|
fwd := config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
LocalPort: 8080,
|
||||||
|
Port: 80,
|
||||||
|
}
|
||||||
|
|
||||||
|
worker := NewForwardWorker(fwd, nil, false, nil, nil, nil)
|
||||||
|
|
||||||
|
// Before done channel is closed, worker is "running"
|
||||||
|
assert.True(t, worker.IsRunning())
|
||||||
|
|
||||||
|
// Close done channel to simulate worker completion
|
||||||
|
close(worker.doneChan)
|
||||||
|
|
||||||
|
// After done channel closed, worker is not running
|
||||||
|
assert.False(t, worker.IsRunning())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForwardWorker_SignalConnectionSuccess tests success signaling
|
||||||
|
func TestForwardWorker_SignalConnectionSuccess(t *testing.T) {
|
||||||
|
fwd := config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
LocalPort: 8080,
|
||||||
|
Port: 80,
|
||||||
|
}
|
||||||
|
|
||||||
|
worker := NewForwardWorker(fwd, nil, false, nil, nil, nil)
|
||||||
|
|
||||||
|
// Signal success
|
||||||
|
worker.signalConnectionSuccess()
|
||||||
|
|
||||||
|
// Should be able to receive from success channel
|
||||||
|
select {
|
||||||
|
case <-worker.successChan:
|
||||||
|
// Success
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
t.Fatal("Expected signal on success channel")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second signal should not block (buffer of 1)
|
||||||
|
worker.signalConnectionSuccess()
|
||||||
|
worker.signalConnectionSuccess() // Should not block
|
||||||
|
|
||||||
|
// Channel should have at most 1 pending signal
|
||||||
|
select {
|
||||||
|
case <-worker.successChan:
|
||||||
|
// Got the signal
|
||||||
|
default:
|
||||||
|
// No signal (also acceptable - channel already had one)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForwardWorker_TriggerReconnect tests reconnect triggering
|
||||||
|
func TestForwardWorker_TriggerReconnect(t *testing.T) {
|
||||||
|
fwd := config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
LocalPort: 8080,
|
||||||
|
Port: 80,
|
||||||
|
}
|
||||||
|
|
||||||
|
worker := NewForwardWorker(fwd, nil, false, nil, nil, nil)
|
||||||
|
|
||||||
|
// Trigger reconnect
|
||||||
|
worker.TriggerReconnect("test reason")
|
||||||
|
|
||||||
|
// Should be able to receive from reconnect channel
|
||||||
|
select {
|
||||||
|
case reason := <-worker.reconnectChan:
|
||||||
|
assert.Equal(t, "test reason", reason)
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
t.Fatal("Expected signal on reconnect channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForwardWorker_TriggerReconnect_WithForwardCancel tests reconnect with active forward
|
||||||
|
func TestForwardWorker_TriggerReconnect_WithForwardCancel(t *testing.T) {
|
||||||
|
fwd := config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
LocalPort: 8080,
|
||||||
|
Port: 80,
|
||||||
|
}
|
||||||
|
|
||||||
|
worker := NewForwardWorker(fwd, nil, false, nil, nil, nil)
|
||||||
|
|
||||||
|
// Set up a forward cancel function
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
worker.forwardCancelMu.Lock()
|
||||||
|
worker.forwardCancel = cancel
|
||||||
|
worker.forwardCancelMu.Unlock()
|
||||||
|
|
||||||
|
// Trigger reconnect
|
||||||
|
worker.TriggerReconnect("stale connection")
|
||||||
|
|
||||||
|
// Context should be cancelled
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Success - context was cancelled
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
t.Fatal("Expected forward context to be cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForwardWorker_TriggerReconnect_NonBlocking tests non-blocking behavior
|
||||||
|
func TestForwardWorker_TriggerReconnect_NonBlocking(t *testing.T) {
|
||||||
|
fwd := config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
LocalPort: 8080,
|
||||||
|
Port: 80,
|
||||||
|
}
|
||||||
|
|
||||||
|
worker := NewForwardWorker(fwd, nil, false, nil, nil, nil)
|
||||||
|
|
||||||
|
// Fill the channel
|
||||||
|
worker.reconnectChan <- "first"
|
||||||
|
|
||||||
|
// Second trigger should not block
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
worker.TriggerReconnect("second")
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Success - didn't block
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
t.Fatal("TriggerReconnect blocked when channel was full")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForwardWorker_Stop tests graceful stop
|
||||||
|
func TestForwardWorker_Stop(t *testing.T) {
|
||||||
|
fwd := config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
LocalPort: 8080,
|
||||||
|
Port: 80,
|
||||||
|
}
|
||||||
|
|
||||||
|
worker := NewForwardWorker(fwd, nil, false, nil, nil, nil)
|
||||||
|
|
||||||
|
// Close done channel to simulate worker has finished
|
||||||
|
close(worker.doneChan)
|
||||||
|
|
||||||
|
// Stop should complete quickly since worker is "done"
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
worker.Stop()
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Success
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("Stop timed out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForwardWorker_Stop_Timeout tests stop timeout behavior
|
||||||
|
func TestForwardWorker_Stop_Timeout(t *testing.T) {
|
||||||
|
fwd := config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
LocalPort: 8080,
|
||||||
|
Port: 80,
|
||||||
|
}
|
||||||
|
|
||||||
|
worker := NewForwardWorker(fwd, nil, false, nil, nil, nil)
|
||||||
|
// Don't close doneChan - simulate hanging worker
|
||||||
|
|
||||||
|
// Stop should timeout after ~3 seconds
|
||||||
|
start := time.Now()
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
worker.Stop()
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
// Should have waited at least 2 seconds but not more than 5
|
||||||
|
assert.True(t, elapsed >= 2*time.Second, "Should wait for timeout")
|
||||||
|
assert.True(t, elapsed < 5*time.Second, "Should not wait too long")
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("Stop never completed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForwardWorker_GetHTTPProxy tests HTTP proxy getter
|
||||||
|
func TestForwardWorker_GetHTTPProxy(t *testing.T) {
|
||||||
|
fwd := config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
LocalPort: 8080,
|
||||||
|
Port: 80,
|
||||||
|
}
|
||||||
|
|
||||||
|
worker := NewForwardWorker(fwd, nil, false, nil, nil, nil)
|
||||||
|
|
||||||
|
// Initially nil
|
||||||
|
proxy := worker.GetHTTPProxy()
|
||||||
|
assert.Nil(t, proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForwardWorker_HeartbeatResponder tests HeartbeatResponder interface
|
||||||
|
func TestForwardWorker_HeartbeatResponder(t *testing.T) {
|
||||||
|
fwd := config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
LocalPort: 8080,
|
||||||
|
Port: 80,
|
||||||
|
}
|
||||||
|
|
||||||
|
worker := NewForwardWorker(fwd, nil, false, nil, nil, nil)
|
||||||
|
|
||||||
|
// Worker should implement HeartbeatResponder
|
||||||
|
var responder HeartbeatResponder = worker
|
||||||
|
assert.NotNil(t, responder)
|
||||||
|
|
||||||
|
// Test interface methods
|
||||||
|
assert.True(t, responder.IsAlive())
|
||||||
|
assert.NotEmpty(t, responder.GetForwardID())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLogWriter tests the logWriter implementation
|
||||||
|
func TestLogWriter(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prefix string
|
||||||
|
input []byte
|
||||||
|
}{
|
||||||
|
{"simple message", "[test] ", []byte("hello")},
|
||||||
|
{"empty message", "[test] ", []byte("")},
|
||||||
|
{"multiline", "[test] ", []byte("line1\nline2")},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
lw := &logWriter{prefix: tt.prefix}
|
||||||
|
n, err := lw.Write(tt.input)
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, len(tt.input), n)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTTPLogPortOffset tests the port offset constant
|
||||||
|
func TestHTTPLogPortOffset(t *testing.T) {
|
||||||
|
assert.Equal(t, 10000, httpLogPortOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPortForwardReadyTimeout tests the ready timeout constant
|
||||||
|
func TestPortForwardReadyTimeout(t *testing.T) {
|
||||||
|
assert.Equal(t, 30*time.Second, portForwardReadyTimeout)
|
||||||
|
}
|
||||||
+138
-59
@@ -7,8 +7,20 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/nvm/kportal/internal/config"
|
||||||
|
"github.com/nvm/kportal/internal/events"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// bufferPool is a sync.Pool for reusing buffers in data transfer health checks.
|
||||||
|
// This reduces GC pressure by avoiding allocation of 1KB buffers on every health check.
|
||||||
|
var bufferPool = sync.Pool{
|
||||||
|
New: func() interface{} {
|
||||||
|
buf := make([]byte, dataTransferSize)
|
||||||
|
return &buf
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
startupGracePeriod = 10 * time.Second
|
startupGracePeriod = 10 * time.Second
|
||||||
dataTransferSize = 1024 // bytes to read in data transfer test
|
dataTransferSize = 1024 // bytes to read in data transfer test
|
||||||
@@ -47,7 +59,9 @@ type PortHealth struct {
|
|||||||
// StatusCallback is called when a port's health status changes
|
// StatusCallback is called when a port's health status changes
|
||||||
type StatusCallback func(forwardID string, status Status, errorMsg string)
|
type StatusCallback func(forwardID string, status Status, errorMsg string)
|
||||||
|
|
||||||
// Checker performs periodic health checks on local ports
|
// Checker performs periodic health checks on local ports.
|
||||||
|
// Uses a single goroutine to check all registered ports, reducing overhead
|
||||||
|
// compared to one goroutine per port.
|
||||||
type Checker struct {
|
type Checker struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
ports map[string]*PortHealth // key: forward ID
|
ports map[string]*PortHealth // key: forward ID
|
||||||
@@ -60,6 +74,8 @@ type Checker struct {
|
|||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
|
started bool
|
||||||
|
eventBus *events.Bus // Optional event bus for decoupled communication
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckerOptions configures the health checker
|
// CheckerOptions configures the health checker
|
||||||
@@ -77,15 +93,15 @@ func NewChecker(interval, timeout time.Duration) *Checker {
|
|||||||
Interval: interval,
|
Interval: interval,
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
Method: CheckMethodDataTransfer,
|
Method: CheckMethodDataTransfer,
|
||||||
MaxConnectionAge: 25 * time.Minute,
|
MaxConnectionAge: config.DefaultMaxConnectionAge,
|
||||||
MaxIdleTime: 10 * time.Minute,
|
MaxIdleTime: config.DefaultMaxIdleTime,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCheckerWithOptions creates a new health checker with custom options
|
// NewCheckerWithOptions creates a new health checker with custom options
|
||||||
func NewCheckerWithOptions(opts CheckerOptions) *Checker {
|
func NewCheckerWithOptions(opts CheckerOptions) *Checker {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
return &Checker{
|
c := &Checker{
|
||||||
ports: make(map[string]*PortHealth),
|
ports: make(map[string]*PortHealth),
|
||||||
callbacks: make(map[string]StatusCallback),
|
callbacks: make(map[string]StatusCallback),
|
||||||
interval: opts.Interval,
|
interval: opts.Interval,
|
||||||
@@ -96,12 +112,25 @@ func NewCheckerWithOptions(opts CheckerOptions) *Checker {
|
|||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start the single monitoring loop
|
||||||
|
c.wg.Add(1)
|
||||||
|
go c.monitorLoop()
|
||||||
|
c.started = true
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetEventBus sets the event bus for publishing health events
|
||||||
|
func (c *Checker) SetEventBus(bus *events.Bus) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.eventBus = bus
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register adds a port to monitor
|
// Register adds a port to monitor
|
||||||
func (c *Checker) Register(forwardID string, port int, callback StatusCallback) {
|
func (c *Checker) Register(forwardID string, port int, callback StatusCallback) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
c.ports[forwardID] = &PortHealth{
|
c.ports[forwardID] = &PortHealth{
|
||||||
@@ -113,22 +142,33 @@ func (c *Checker) Register(forwardID string, port int, callback StatusCallback)
|
|||||||
LastActivity: now,
|
LastActivity: now,
|
||||||
}
|
}
|
||||||
c.callbacks[forwardID] = callback
|
c.callbacks[forwardID] = callback
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
// Start checking this port
|
// Perform immediate first check so status updates quickly
|
||||||
c.wg.Add(1)
|
// This prevents the forward from being stuck in "Starting" state
|
||||||
go c.checkLoop(forwardID)
|
// until the next ticker interval
|
||||||
|
go c.checkPort(forwardID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkConnected marks a forward as having established a new connection
|
// MarkConnected marks a forward as having established a new connection.
|
||||||
|
// This updates connection timestamps and triggers an immediate health check
|
||||||
|
// to verify the connection is actually working.
|
||||||
func (c *Checker) MarkConnected(forwardID string) {
|
func (c *Checker) MarkConnected(forwardID string) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
if health, exists := c.ports[forwardID]; exists {
|
health, exists := c.ports[forwardID]
|
||||||
now := time.Now()
|
if !exists {
|
||||||
health.ConnectionTime = now
|
c.mu.Unlock()
|
||||||
health.LastActivity = now
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
health.ConnectionTime = now
|
||||||
|
health.LastActivity = now
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
// Trigger immediate health check to verify connection and update status
|
||||||
|
go c.checkPort(forwardID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordActivity records data transfer activity for a forward
|
// RecordActivity records data transfer activity for a forward
|
||||||
@@ -150,44 +190,34 @@ func (c *Checker) Unregister(forwardID string) {
|
|||||||
delete(c.callbacks, forwardID)
|
delete(c.callbacks, forwardID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkReconnecting marks a forward as reconnecting (called by worker)
|
// markStatus is a helper to set a forward's status and notify on change.
|
||||||
func (c *Checker) MarkReconnecting(forwardID string) {
|
func (c *Checker) markStatus(forwardID string, newStatus Status) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
|
||||||
if health, exists := c.ports[forwardID]; exists {
|
health, exists := c.ports[forwardID]
|
||||||
oldStatus := health.Status
|
if !exists {
|
||||||
health.Status = StatusReconnect
|
|
||||||
health.LastCheck = time.Now()
|
|
||||||
|
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
if oldStatus != StatusReconnect {
|
|
||||||
c.notifyStatusChange(forwardID, StatusReconnect, "")
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
oldStatus := health.Status
|
||||||
|
health.Status = newStatus
|
||||||
|
health.LastCheck = time.Now()
|
||||||
c.mu.Unlock()
|
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)
|
// MarkStarting marks a forward as starting (called by worker)
|
||||||
func (c *Checker) MarkStarting(forwardID string) {
|
func (c *Checker) MarkStarting(forwardID string) {
|
||||||
c.mu.Lock()
|
c.markStatus(forwardID, StatusStarting)
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStatus returns the current health status of a forward
|
// GetStatus returns the current health status of a forward
|
||||||
@@ -201,6 +231,17 @@ func (c *Checker) GetStatus(forwardID string) (Status, bool) {
|
|||||||
return StatusUnhealthy, false
|
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
|
// GetAllErrors returns all forwards with errors and their error messages
|
||||||
func (c *Checker) GetAllErrors() map[string]string {
|
func (c *Checker) GetAllErrors() map[string]string {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
@@ -221,35 +262,52 @@ func (c *Checker) Stop() {
|
|||||||
c.wg.Wait()
|
c.wg.Wait()
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkLoop continuously checks a single port's health
|
// monitorLoop is the single goroutine that checks all registered ports periodically.
|
||||||
func (c *Checker) checkLoop(forwardID string) {
|
// This is more efficient than one goroutine per port as it reduces:
|
||||||
|
// - Goroutine overhead (stack memory, scheduler work)
|
||||||
|
// - Timer/ticker allocations
|
||||||
|
// - Lock contention (one lock acquisition per interval vs N)
|
||||||
|
func (c *Checker) monitorLoop() {
|
||||||
defer c.wg.Done()
|
defer c.wg.Done()
|
||||||
|
|
||||||
ticker := time.NewTicker(c.interval)
|
ticker := time.NewTicker(c.interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
// Do immediate first check - grace period logic will handle early failures
|
|
||||||
c.checkPort(forwardID)
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-c.ctx.Done():
|
case <-c.ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
// Check if this forward still exists
|
c.checkAllPorts()
|
||||||
c.mu.RLock()
|
|
||||||
_, exists := c.ports[forwardID]
|
|
||||||
c.mu.RUnlock()
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.checkPort(forwardID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkAllPorts performs health checks on all registered ports
|
||||||
|
func (c *Checker) checkAllPorts() {
|
||||||
|
// Get snapshot of ports to check
|
||||||
|
c.mu.RLock()
|
||||||
|
forwardIDs := make([]string, 0, len(c.ports))
|
||||||
|
for id := range c.ports {
|
||||||
|
forwardIDs = append(forwardIDs, id)
|
||||||
|
}
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
// Check each port
|
||||||
|
for _, forwardID := range forwardIDs {
|
||||||
|
// Check if still registered (may have been unregistered during iteration)
|
||||||
|
c.mu.RLock()
|
||||||
|
_, exists := c.ports[forwardID]
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
c.checkPort(forwardID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// checkPort performs a single health check on a port
|
// checkPort performs a single health check on a port
|
||||||
func (c *Checker) checkPort(forwardID string) {
|
func (c *Checker) checkPort(forwardID string) {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
@@ -313,12 +371,31 @@ func (c *Checker) checkPort(forwardID string) {
|
|||||||
health.Status = newStatus
|
health.Status = newStatus
|
||||||
health.LastCheck = now
|
health.LastCheck = now
|
||||||
health.ErrorMessage = errorMsg
|
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()
|
c.mu.Unlock()
|
||||||
|
|
||||||
// Notify if status changed
|
// Notify if status changed
|
||||||
if oldStatus != newStatus {
|
if oldStatus != newStatus {
|
||||||
c.notifyStatusChange(forwardID, newStatus, errorMsg)
|
c.notifyStatusChange(forwardID, newStatus, errorMsg)
|
||||||
|
|
||||||
|
// Publish to event bus if available
|
||||||
|
c.mu.RLock()
|
||||||
|
bus := c.eventBus
|
||||||
|
c.mu.RUnlock()
|
||||||
|
|
||||||
|
if bus != nil {
|
||||||
|
if newStatus == StatusStale {
|
||||||
|
bus.Publish(events.NewStaleEvent(forwardID, errorMsg))
|
||||||
|
} else {
|
||||||
|
bus.Publish(events.NewHealthEvent(forwardID, string(newStatus), errorMsg))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,7 +409,7 @@ func (c *Checker) checkTCPDial(port int) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
conn.Close()
|
_ = conn.Close()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,14 +427,16 @@ func (c *Checker) checkDataTransfer(port int) error {
|
|||||||
|
|
||||||
// Set a short read deadline to detect hung connections
|
// 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
|
// We don't expect to receive data, but we want to verify the connection isn't hung
|
||||||
conn.SetReadDeadline(time.Now().Add(c.timeout))
|
_ = conn.SetReadDeadline(time.Now().Add(c.timeout))
|
||||||
|
|
||||||
// Try to read a small amount of data
|
// Try to read a small amount of data
|
||||||
// Most servers will either:
|
// Most servers will either:
|
||||||
// 1. Send a banner (SSH, FTP, etc) - we'll read it successfully
|
// 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)
|
// 2. Wait for client to send first (HTTP, postgres) - we'll timeout (which is OK)
|
||||||
// 3. Hung/stale connection - will timeout with different error
|
// 3. Hung/stale connection - will timeout with different error
|
||||||
buf := make([]byte, dataTransferSize)
|
bufPtr := bufferPool.Get().(*[]byte)
|
||||||
|
buf := *bufPtr
|
||||||
|
defer bufferPool.Put(bufPtr)
|
||||||
_, err = conn.Read(buf)
|
_, err = conn.Read(buf)
|
||||||
|
|
||||||
// We expect either:
|
// We expect either:
|
||||||
|
|||||||
@@ -288,7 +288,8 @@ func (s *HealthCheckTestSuite) TestConnectionAgeDetection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestIdleTimeDetection tests max idle time detection
|
// 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() {
|
func (s *HealthCheckTestSuite) TestIdleTimeDetection() {
|
||||||
statusChanges := make(chan Status, 10)
|
statusChanges := make(chan Status, 10)
|
||||||
callback := func(forwardID string, status Status, errorMsg string) {
|
callback := func(forwardID string, status Status, errorMsg string) {
|
||||||
@@ -307,26 +308,23 @@ func (s *HealthCheckTestSuite) TestIdleTimeDetection() {
|
|||||||
|
|
||||||
checker.Register("test-forward", s.port, callback)
|
checker.Register("test-forward", s.port, callback)
|
||||||
|
|
||||||
// Wait for initial healthy status
|
// Wait long enough that idle time WOULD be exceeded if health checks didn't update LastActivity
|
||||||
var gotHealthy, gotStale bool
|
time.Sleep(500 * time.Millisecond)
|
||||||
timeout := time.After(1 * time.Second)
|
|
||||||
|
|
||||||
for {
|
// Verify connection is still healthy, not stale
|
||||||
select {
|
// This proves that successful health checks are updating LastActivity
|
||||||
case status := <-statusChanges:
|
status, exists := checker.GetStatus("test-forward")
|
||||||
if status == StatusHealthy || status == StatusStarting {
|
require.True(s.T(), exists)
|
||||||
gotHealthy = true
|
assert.Equal(s.T(), StatusHealthy, status, "Connection with passing health checks should NOT be marked as stale")
|
||||||
}
|
|
||||||
if status == StatusStale {
|
// Verify we never received a StatusStale callback
|
||||||
gotStale = true
|
select {
|
||||||
}
|
case status := <-statusChanges:
|
||||||
if gotHealthy && gotStale {
|
if status == StatusStale {
|
||||||
return // Test passed
|
s.T().Fatal("Connection should NOT be marked as stale when health checks are passing")
|
||||||
}
|
|
||||||
case <-timeout:
|
|
||||||
s.T().Fatalf("Expected StatusStale after max idle time exceeded. gotHealthy=%v, gotStale=%v",
|
|
||||||
gotHealthy, gotStale)
|
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
// No stale status - this is correct
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package httplog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry represents a single HTTP log entry
|
||||||
|
type Entry struct {
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
ForwardID string `json:"forward_id"`
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
Direction string `json:"direction"` // "request" or "response"
|
||||||
|
Method string `json:"method,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
StatusCode int `json:"status_code,omitempty"`
|
||||||
|
Headers map[string]string `json:"headers,omitempty"`
|
||||||
|
BodySize int `json:"body_size"`
|
||||||
|
Body string `json:"body,omitempty"`
|
||||||
|
LatencyMs int64 `json:"latency_ms,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogCallback is a function that receives log entries
|
||||||
|
type LogCallback func(entry Entry)
|
||||||
|
|
||||||
|
// Logger writes HTTP log entries to an output stream
|
||||||
|
type Logger struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
output io.Writer
|
||||||
|
file *os.File // Only set if we opened the file ourselves
|
||||||
|
forwardID string
|
||||||
|
maxBodyLen int
|
||||||
|
callbacks []LogCallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogger creates a new HTTP logger
|
||||||
|
// If logFile is empty, logs only go to registered callbacks (no file output)
|
||||||
|
// This prevents stdout corruption when running in TUI mode
|
||||||
|
func NewLogger(forwardID, logFile string, maxBodyLen int) (*Logger, error) {
|
||||||
|
l := &Logger{
|
||||||
|
forwardID: forwardID,
|
||||||
|
maxBodyLen: maxBodyLen,
|
||||||
|
}
|
||||||
|
|
||||||
|
if logFile == "" {
|
||||||
|
// Don't write to stdout - use io.Discard
|
||||||
|
// Log entries are delivered via callbacks to the UI
|
||||||
|
l.output = io.Discard
|
||||||
|
} else {
|
||||||
|
// #nosec G304 -- logFile is from config validation, not arbitrary user input
|
||||||
|
f, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
l.file = f
|
||||||
|
l.output = f
|
||||||
|
}
|
||||||
|
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddCallback registers a callback to receive log entries
|
||||||
|
func (l *Logger) AddCallback(cb LogCallback) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
l.callbacks = append(l.callbacks, cb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearCallbacks removes all registered callbacks
|
||||||
|
func (l *Logger) ClearCallbacks() {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
l.callbacks = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log writes a log entry as JSON
|
||||||
|
func (l *Logger) Log(entry Entry) error {
|
||||||
|
entry.ForwardID = l.forwardID
|
||||||
|
entry.Timestamp = time.Now()
|
||||||
|
|
||||||
|
// Truncate body if too large
|
||||||
|
if len(entry.Body) > l.maxBodyLen {
|
||||||
|
entry.Body = entry.Body[:l.maxBodyLen] + "...(truncated)"
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(entry)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
// Notify callbacks
|
||||||
|
for _, cb := range l.callbacks {
|
||||||
|
cb(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = l.output.Write(append(data, '\n'))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the logger
|
||||||
|
func (l *Logger) Close() error {
|
||||||
|
if l.file != nil {
|
||||||
|
return l.file.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMaxBodyLen returns the maximum body length for logging
|
||||||
|
func (l *Logger) GetMaxBodyLen() int {
|
||||||
|
return l.maxBodyLen
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
package httplog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewLogger_OutputModes tests different output configurations
|
||||||
|
func TestNewLogger_OutputModes(t *testing.T) {
|
||||||
|
t.Run("empty logFile uses io.Discard", func(t *testing.T) {
|
||||||
|
l, err := NewLogger("test-forward", "", 1024)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer l.Close()
|
||||||
|
|
||||||
|
assert.Nil(t, l.file)
|
||||||
|
assert.Equal(t, io.Discard, l.output)
|
||||||
|
assert.Equal(t, "test-forward", l.forwardID)
|
||||||
|
assert.Equal(t, 1024, l.maxBodyLen)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("file logger creates file", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
logFile := filepath.Join(tmpDir, "http.log")
|
||||||
|
|
||||||
|
l, err := NewLogger("test-forward", logFile, 2048)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer l.Close()
|
||||||
|
|
||||||
|
assert.NotNil(t, l.file)
|
||||||
|
assert.NotEqual(t, io.Discard, l.output)
|
||||||
|
assert.Equal(t, 2048, l.maxBodyLen)
|
||||||
|
|
||||||
|
// File should exist
|
||||||
|
_, err = os.Stat(logFile)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("file logger appends to existing file", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
logFile := filepath.Join(tmpDir, "http.log")
|
||||||
|
|
||||||
|
// Create file with existing content
|
||||||
|
err := os.WriteFile(logFile, []byte("existing\n"), 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
l, err := NewLogger("test-forward", logFile, 1024)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = l.Log(Entry{Direction: "request"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
l.Close()
|
||||||
|
|
||||||
|
// File should have both contents
|
||||||
|
data, _ := os.ReadFile(logFile)
|
||||||
|
assert.True(t, strings.HasPrefix(string(data), "existing\n"))
|
||||||
|
assert.Contains(t, string(data), "direction")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid path returns error", func(t *testing.T) {
|
||||||
|
_, err := NewLogger("test", "/nonexistent/path/file.log", 1024)
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLogger_Log tests basic logging functionality
|
||||||
|
func TestLogger_Log(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
l := &Logger{
|
||||||
|
forwardID: "fwd-123",
|
||||||
|
maxBodyLen: 100,
|
||||||
|
output: &buf,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := l.Log(Entry{
|
||||||
|
Direction: "request",
|
||||||
|
RequestID: "req-1",
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/api/users",
|
||||||
|
BodySize: 42,
|
||||||
|
Body: `{"name":"test"}`,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Parse output
|
||||||
|
var entry Entry
|
||||||
|
err = json.Unmarshal(buf.Bytes(), &entry)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "fwd-123", entry.ForwardID)
|
||||||
|
assert.Equal(t, "request", entry.Direction)
|
||||||
|
assert.Equal(t, "req-1", entry.RequestID)
|
||||||
|
assert.Equal(t, "POST", entry.Method)
|
||||||
|
assert.Equal(t, "/api/users", entry.Path)
|
||||||
|
assert.Equal(t, 42, entry.BodySize)
|
||||||
|
assert.Equal(t, `{"name":"test"}`, entry.Body)
|
||||||
|
assert.False(t, entry.Timestamp.IsZero())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLogger_Log_Response tests response logging
|
||||||
|
func TestLogger_Log_Response(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
l := &Logger{
|
||||||
|
forwardID: "fwd-123",
|
||||||
|
maxBodyLen: 1000,
|
||||||
|
output: &buf,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := l.Log(Entry{
|
||||||
|
Direction: "response",
|
||||||
|
RequestID: "req-1",
|
||||||
|
Method: "GET",
|
||||||
|
Path: "/api/status",
|
||||||
|
StatusCode: 200,
|
||||||
|
LatencyMs: 125,
|
||||||
|
Headers: map[string]string{"Content-Type": "application/json"},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var entry Entry
|
||||||
|
err = json.Unmarshal(buf.Bytes(), &entry)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "response", entry.Direction)
|
||||||
|
assert.Equal(t, 200, entry.StatusCode)
|
||||||
|
assert.Equal(t, int64(125), entry.LatencyMs)
|
||||||
|
assert.Equal(t, "application/json", entry.Headers["Content-Type"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLogger_Log_Error tests error logging
|
||||||
|
func TestLogger_Log_Error(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
l := &Logger{
|
||||||
|
forwardID: "fwd-123",
|
||||||
|
maxBodyLen: 100,
|
||||||
|
output: &buf,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := l.Log(Entry{
|
||||||
|
Direction: "error",
|
||||||
|
RequestID: "req-1",
|
||||||
|
Method: "GET",
|
||||||
|
Path: "/api/fail",
|
||||||
|
Error: "connection refused",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var entry Entry
|
||||||
|
err = json.Unmarshal(buf.Bytes(), &entry)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "error", entry.Direction)
|
||||||
|
assert.Equal(t, "connection refused", entry.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLogger_BodyTruncation tests body size limiting
|
||||||
|
func TestLogger_BodyTruncation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
maxBodyLen int
|
||||||
|
body string
|
||||||
|
expectTrunc bool
|
||||||
|
}{
|
||||||
|
{"body under limit", 100, "short", false},
|
||||||
|
{"body at limit", 5, "exact", false},
|
||||||
|
{"body over limit", 5, "this is too long", true},
|
||||||
|
{"empty body", 100, "", false},
|
||||||
|
{"zero max", 0, "any", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
l := &Logger{
|
||||||
|
forwardID: "test",
|
||||||
|
maxBodyLen: tt.maxBodyLen,
|
||||||
|
output: &buf,
|
||||||
|
}
|
||||||
|
|
||||||
|
l.Log(Entry{Body: tt.body})
|
||||||
|
|
||||||
|
var entry Entry
|
||||||
|
json.Unmarshal(buf.Bytes(), &entry)
|
||||||
|
|
||||||
|
if tt.expectTrunc {
|
||||||
|
assert.Contains(t, entry.Body, "...(truncated)")
|
||||||
|
} else {
|
||||||
|
assert.NotContains(t, entry.Body, "truncated")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLogger_Callbacks tests callback registration and invocation
|
||||||
|
func TestLogger_Callbacks(t *testing.T) {
|
||||||
|
l := &Logger{
|
||||||
|
forwardID: "test",
|
||||||
|
maxBodyLen: 100,
|
||||||
|
output: io.Discard,
|
||||||
|
}
|
||||||
|
|
||||||
|
var received []Entry
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
// Add callback
|
||||||
|
l.AddCallback(func(entry Entry) {
|
||||||
|
mu.Lock()
|
||||||
|
received = append(received, entry)
|
||||||
|
mu.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Log entries
|
||||||
|
l.Log(Entry{Direction: "request", Path: "/api/1"})
|
||||||
|
l.Log(Entry{Direction: "response", Path: "/api/1"})
|
||||||
|
l.Log(Entry{Direction: "request", Path: "/api/2"})
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
assert.Len(t, received, 3)
|
||||||
|
assert.Equal(t, "/api/1", received[0].Path)
|
||||||
|
assert.Equal(t, "response", received[1].Direction)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLogger_MultipleCallbacks tests multiple callbacks
|
||||||
|
func TestLogger_MultipleCallbacks(t *testing.T) {
|
||||||
|
l := &Logger{
|
||||||
|
forwardID: "test",
|
||||||
|
maxBodyLen: 100,
|
||||||
|
output: io.Discard,
|
||||||
|
}
|
||||||
|
|
||||||
|
count1 := 0
|
||||||
|
count2 := 0
|
||||||
|
|
||||||
|
l.AddCallback(func(entry Entry) { count1++ })
|
||||||
|
l.AddCallback(func(entry Entry) { count2++ })
|
||||||
|
|
||||||
|
l.Log(Entry{})
|
||||||
|
|
||||||
|
assert.Equal(t, 1, count1)
|
||||||
|
assert.Equal(t, 1, count2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLogger_ClearCallbacks tests callback clearing
|
||||||
|
func TestLogger_ClearCallbacks(t *testing.T) {
|
||||||
|
l := &Logger{
|
||||||
|
forwardID: "test",
|
||||||
|
maxBodyLen: 100,
|
||||||
|
output: io.Discard,
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
l.AddCallback(func(entry Entry) { count++ })
|
||||||
|
|
||||||
|
l.Log(Entry{})
|
||||||
|
assert.Equal(t, 1, count)
|
||||||
|
|
||||||
|
l.ClearCallbacks()
|
||||||
|
|
||||||
|
l.Log(Entry{})
|
||||||
|
assert.Equal(t, 1, count) // Still 1 - callback was cleared
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLogger_GetMaxBodyLen tests the getter
|
||||||
|
func TestLogger_GetMaxBodyLen(t *testing.T) {
|
||||||
|
l := &Logger{maxBodyLen: 4096}
|
||||||
|
assert.Equal(t, 4096, l.GetMaxBodyLen())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLogger_Close tests closing
|
||||||
|
func TestLogger_Close(t *testing.T) {
|
||||||
|
t.Run("close with no file", func(t *testing.T) {
|
||||||
|
l := &Logger{output: io.Discard}
|
||||||
|
err := l.Close()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("close with file", func(t *testing.T) {
|
||||||
|
tmpFile := filepath.Join(t.TempDir(), "test.log")
|
||||||
|
l, err := NewLogger("test", tmpFile, 100)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = l.Close()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// File should be closed (writing should fail or create new handle)
|
||||||
|
assert.NotNil(t, l.file) // reference still exists
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLogger_Concurrent tests thread safety
|
||||||
|
func TestLogger_Concurrent(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
l := &Logger{
|
||||||
|
forwardID: "test",
|
||||||
|
maxBodyLen: 100,
|
||||||
|
output: &buf,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add callback that accesses shared state
|
||||||
|
var callbackCount int
|
||||||
|
var mu sync.Mutex
|
||||||
|
l.AddCallback(func(entry Entry) {
|
||||||
|
mu.Lock()
|
||||||
|
callbackCount++
|
||||||
|
mu.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Concurrent writes
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(n int) {
|
||||||
|
defer wg.Done()
|
||||||
|
l.Log(Entry{
|
||||||
|
Direction: "request",
|
||||||
|
Path: "/api/" + string(rune('a'+n%26)),
|
||||||
|
})
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
assert.Equal(t, 100, callbackCount)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntry_Structure tests the Entry struct
|
||||||
|
func TestEntry_Structure(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
entry := Entry{
|
||||||
|
Timestamp: now,
|
||||||
|
ForwardID: "fwd-1",
|
||||||
|
RequestID: "req-1",
|
||||||
|
Direction: "request",
|
||||||
|
Method: "DELETE",
|
||||||
|
Path: "/api/items/123",
|
||||||
|
StatusCode: 204,
|
||||||
|
Headers: map[string]string{"X-Custom": "value"},
|
||||||
|
BodySize: 0,
|
||||||
|
Body: "",
|
||||||
|
LatencyMs: 50,
|
||||||
|
Error: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all fields
|
||||||
|
assert.Equal(t, now, entry.Timestamp)
|
||||||
|
assert.Equal(t, "fwd-1", entry.ForwardID)
|
||||||
|
assert.Equal(t, "req-1", entry.RequestID)
|
||||||
|
assert.Equal(t, "request", entry.Direction)
|
||||||
|
assert.Equal(t, "DELETE", entry.Method)
|
||||||
|
assert.Equal(t, "/api/items/123", entry.Path)
|
||||||
|
assert.Equal(t, 204, entry.StatusCode)
|
||||||
|
assert.Equal(t, "value", entry.Headers["X-Custom"])
|
||||||
|
assert.Equal(t, 0, entry.BodySize)
|
||||||
|
assert.Empty(t, entry.Body)
|
||||||
|
assert.Equal(t, int64(50), entry.LatencyMs)
|
||||||
|
assert.Empty(t, entry.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntry_JSONMarshaling tests JSON serialization
|
||||||
|
func TestEntry_JSONMarshaling(t *testing.T) {
|
||||||
|
entry := Entry{
|
||||||
|
Direction: "response",
|
||||||
|
Method: "GET",
|
||||||
|
Path: "/test",
|
||||||
|
StatusCode: 200,
|
||||||
|
LatencyMs: 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(entry)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var parsed Entry
|
||||||
|
err = json.Unmarshal(data, &parsed)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, entry.Direction, parsed.Direction)
|
||||||
|
assert.Equal(t, entry.StatusCode, parsed.StatusCode)
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
package httplog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nvm/kportal/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Proxy is an HTTP reverse proxy with logging capabilities
|
||||||
|
type Proxy struct {
|
||||||
|
localPort int // Port to listen on (user-facing)
|
||||||
|
targetPort int // Port to forward to (k8s tunnel)
|
||||||
|
logger *Logger
|
||||||
|
server *http.Server
|
||||||
|
forwardID string
|
||||||
|
filterPath string // Glob pattern for path filtering
|
||||||
|
includeHdrs bool
|
||||||
|
listener net.Listener
|
||||||
|
requestCount uint64
|
||||||
|
mu sync.Mutex
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProxy creates a new HTTP logging proxy
|
||||||
|
func NewProxy(fwd *config.Forward, targetPort int) (*Proxy, error) {
|
||||||
|
httpCfg := fwd.HTTPLog
|
||||||
|
if httpCfg == nil {
|
||||||
|
return nil, fmt.Errorf("HTTP log config is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger, err := NewLogger(fwd.ID(), httpCfg.LogFile, fwd.GetHTTPLogMaxBodySize())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create logger: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Proxy{
|
||||||
|
localPort: fwd.LocalPort,
|
||||||
|
targetPort: targetPort,
|
||||||
|
logger: logger,
|
||||||
|
forwardID: fwd.ID(),
|
||||||
|
filterPath: httpCfg.FilterPath,
|
||||||
|
includeHdrs: httpCfg.IncludeHeaders,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the HTTP proxy server
|
||||||
|
func (p *Proxy) Start() error {
|
||||||
|
p.mu.Lock()
|
||||||
|
if p.running {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return fmt.Errorf("proxy already running")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create listener
|
||||||
|
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p.localPort))
|
||||||
|
if err != nil {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return fmt.Errorf("failed to listen on port %d: %w", p.localPort, err)
|
||||||
|
}
|
||||||
|
p.listener = ln
|
||||||
|
|
||||||
|
// Create reverse proxy
|
||||||
|
director := func(req *http.Request) {
|
||||||
|
req.URL.Scheme = "http"
|
||||||
|
req.URL.Host = fmt.Sprintf("127.0.0.1:%d", p.targetPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := &httputil.ReverseProxy{
|
||||||
|
Director: director,
|
||||||
|
Transport: &loggingTransport{
|
||||||
|
proxy: p,
|
||||||
|
transport: http.DefaultTransport,
|
||||||
|
},
|
||||||
|
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
p.logError(r, err)
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
_, _ = w.Write([]byte("Proxy error: " + err.Error()))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
p.server = &http.Server{
|
||||||
|
Handler: proxy,
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
p.running = true
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
// Start serving (blocking)
|
||||||
|
go func() {
|
||||||
|
if err := p.server.Serve(ln); err != nil && err != http.ErrServerClosed {
|
||||||
|
// Log error but don't crash - proxy will be replaced on reconnect
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the HTTP proxy server
|
||||||
|
func (p *Proxy) Stop() error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
if !p.running {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.running = false
|
||||||
|
|
||||||
|
// Shutdown with timeout
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := p.server.Shutdown(ctx); err != nil {
|
||||||
|
// Force close - error ignored as we're already shutting down
|
||||||
|
_ = p.server.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.logger.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loggingTransport wraps http.RoundTripper to log requests and responses
|
||||||
|
type loggingTransport struct {
|
||||||
|
proxy *Proxy
|
||||||
|
transport http.RoundTripper
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
// Generate request ID
|
||||||
|
reqID := fmt.Sprintf("%d", atomic.AddUint64(&t.proxy.requestCount, 1))
|
||||||
|
|
||||||
|
// Check if we should log this request based on path filter
|
||||||
|
if !t.proxy.shouldLog(req.URL.Path) {
|
||||||
|
return t.transport.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
maxBodySize := t.proxy.logger.GetMaxBodyLen()
|
||||||
|
|
||||||
|
// Read request body with size limit to prevent memory exhaustion
|
||||||
|
var reqBody []byte
|
||||||
|
var reqBodySize int
|
||||||
|
if req.Body != nil {
|
||||||
|
reqBody, reqBodySize = t.readBodyLimited(req.Body, maxBodySize)
|
||||||
|
req.Body = io.NopCloser(bytes.NewBuffer(reqBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log request
|
||||||
|
reqEntry := Entry{
|
||||||
|
RequestID: reqID,
|
||||||
|
Direction: "request",
|
||||||
|
Method: req.Method,
|
||||||
|
Path: req.URL.Path,
|
||||||
|
BodySize: reqBodySize,
|
||||||
|
Body: string(reqBody),
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.proxy.includeHdrs {
|
||||||
|
reqEntry.Headers = flattenHeaders(req.Header)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = t.proxy.logger.Log(reqEntry)
|
||||||
|
|
||||||
|
// Make the request
|
||||||
|
resp, err := t.transport.RoundTrip(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read response body with size limit to prevent memory exhaustion
|
||||||
|
var respBody []byte
|
||||||
|
var respBodySize int
|
||||||
|
if resp.Body != nil {
|
||||||
|
respBody, respBodySize = t.readBodyLimited(resp.Body, maxBodySize)
|
||||||
|
resp.Body = io.NopCloser(bytes.NewBuffer(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
latency := time.Since(startTime)
|
||||||
|
|
||||||
|
// Log response
|
||||||
|
respEntry := Entry{
|
||||||
|
RequestID: reqID,
|
||||||
|
Direction: "response",
|
||||||
|
Method: req.Method,
|
||||||
|
Path: req.URL.Path,
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
BodySize: respBodySize,
|
||||||
|
Body: string(respBody),
|
||||||
|
LatencyMs: latency.Milliseconds(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if t.proxy.includeHdrs {
|
||||||
|
respEntry.Headers = flattenHeaders(resp.Header)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = t.proxy.logger.Log(respEntry)
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBodyLimited reads a body with a size limit to prevent memory exhaustion.
|
||||||
|
// Returns the body content (up to maxSize bytes) and the actual content length.
|
||||||
|
// If the body exceeds maxSize, it reads only maxSize bytes for logging but
|
||||||
|
// consumes the entire body to get the true size for BodySize reporting.
|
||||||
|
func (t *loggingTransport) readBodyLimited(body io.ReadCloser, maxSize int) ([]byte, int) {
|
||||||
|
// Read up to maxSize+1 to detect if there's more
|
||||||
|
limitedReader := io.LimitReader(body, int64(maxSize+1))
|
||||||
|
data, err := io.ReadAll(limitedReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
actualSize := len(data)
|
||||||
|
wasTruncated := actualSize > maxSize
|
||||||
|
|
||||||
|
// If we read exactly maxSize+1, there might be more data
|
||||||
|
// Discard the rest but count the bytes for accurate BodySize
|
||||||
|
if wasTruncated {
|
||||||
|
data = data[:maxSize] // Keep only maxSize bytes for logging
|
||||||
|
// Count remaining bytes without storing them
|
||||||
|
remaining, _ := io.Copy(io.Discard, body)
|
||||||
|
actualSize = maxSize + int(remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, actualSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldLog checks if the request path matches the filter
|
||||||
|
func (p *Proxy) shouldLog(path string) bool {
|
||||||
|
if p.filterPath == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
matched, err := filepath.Match(p.filterPath, path)
|
||||||
|
if err != nil {
|
||||||
|
// Invalid pattern, log everything
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also try matching with ** for prefix patterns like /api/*
|
||||||
|
if !matched && strings.HasSuffix(p.filterPath, "/*") {
|
||||||
|
prefix := strings.TrimSuffix(p.filterPath, "/*")
|
||||||
|
matched = strings.HasPrefix(path, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
// logError logs an error entry
|
||||||
|
func (p *Proxy) logError(req *http.Request, err error) {
|
||||||
|
entry := Entry{
|
||||||
|
RequestID: fmt.Sprintf("%d", atomic.AddUint64(&p.requestCount, 1)),
|
||||||
|
Direction: "error",
|
||||||
|
Method: req.Method,
|
||||||
|
Path: req.URL.Path,
|
||||||
|
Error: err.Error(),
|
||||||
|
}
|
||||||
|
_ = p.logger.Log(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// flattenHeaders converts http.Header to map[string]string
|
||||||
|
func flattenHeaders(h http.Header) map[string]string {
|
||||||
|
result := make(map[string]string, len(h))
|
||||||
|
for k, v := range h {
|
||||||
|
result[k] = strings.Join(v, ", ")
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTargetPort returns the target port for the k8s tunnel
|
||||||
|
func (p *Proxy) GetTargetPort() int {
|
||||||
|
return p.targetPort
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLogger returns the HTTP logger for subscribing to log entries
|
||||||
|
func (p *Proxy) GetLogger() *Logger {
|
||||||
|
return p.logger
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
package httplog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nvm/kportal/internal/config"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLogger(t *testing.T) {
|
||||||
|
// Create a buffer to capture output
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
l := &Logger{
|
||||||
|
forwardID: "test-forward",
|
||||||
|
maxBodyLen: 100,
|
||||||
|
output: &buf,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log an entry
|
||||||
|
err := l.Log(Entry{
|
||||||
|
Direction: "request",
|
||||||
|
Method: "GET",
|
||||||
|
Path: "/test",
|
||||||
|
BodySize: 0,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Parse the output
|
||||||
|
var entry Entry
|
||||||
|
err = json.Unmarshal(buf.Bytes(), &entry)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "test-forward", entry.ForwardID)
|
||||||
|
assert.Equal(t, "request", entry.Direction)
|
||||||
|
assert.Equal(t, "GET", entry.Method)
|
||||||
|
assert.Equal(t, "/test", entry.Path)
|
||||||
|
assert.False(t, entry.Timestamp.IsZero())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoggerBodyTruncation(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
l := &Logger{
|
||||||
|
forwardID: "test-forward",
|
||||||
|
maxBodyLen: 10,
|
||||||
|
output: &buf,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log an entry with a long body
|
||||||
|
err := l.Log(Entry{
|
||||||
|
Direction: "request",
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/test",
|
||||||
|
Body: "this is a very long body that should be truncated",
|
||||||
|
BodySize: 50,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Parse the output
|
||||||
|
var entry Entry
|
||||||
|
err = json.Unmarshal(buf.Bytes(), &entry)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "this is a ...(truncated)", entry.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyShouldLog(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filterPath string
|
||||||
|
path string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"no filter", "", "/anything", true},
|
||||||
|
{"exact match", "/api", "/api", true},
|
||||||
|
{"no match", "/api", "/other", false},
|
||||||
|
{"prefix match", "/api/*", "/api/users", true},
|
||||||
|
{"prefix no match", "/api/*", "/other/users", false},
|
||||||
|
{"wildcard", "/api/*/test", "/api/v1/test", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
p := &Proxy{filterPath: tt.filterPath}
|
||||||
|
assert.Equal(t, tt.expected, p.shouldLog(tt.path))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyIntegration(t *testing.T) {
|
||||||
|
// Create a buffer for log output
|
||||||
|
var logBuf bytes.Buffer
|
||||||
|
|
||||||
|
// Create config
|
||||||
|
fwd := &config.Forward{
|
||||||
|
LocalPort: 0, // Will be assigned dynamically
|
||||||
|
HTTPLog: &config.HTTPLogSpec{
|
||||||
|
Enabled: true,
|
||||||
|
IncludeHeaders: true,
|
||||||
|
MaxBodySize: 1024,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create logger with buffer
|
||||||
|
logger := &Logger{
|
||||||
|
forwardID: "test",
|
||||||
|
maxBodyLen: 1024,
|
||||||
|
output: &logBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create proxy manually for testing
|
||||||
|
proxy := &Proxy{
|
||||||
|
localPort: 0, // Will use ephemeral port
|
||||||
|
targetPort: 0, // Not used in this test
|
||||||
|
logger: logger,
|
||||||
|
forwardID: fwd.ID(),
|
||||||
|
filterPath: "",
|
||||||
|
includeHdrs: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test shouldLog
|
||||||
|
assert.True(t, proxy.shouldLog("/any/path"))
|
||||||
|
|
||||||
|
// Test logging through logger directly
|
||||||
|
err := logger.Log(Entry{
|
||||||
|
RequestID: "1",
|
||||||
|
Direction: "request",
|
||||||
|
Method: "GET",
|
||||||
|
Path: "/test",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify log output
|
||||||
|
assert.Contains(t, logBuf.String(), `"direction":"request"`)
|
||||||
|
assert.Contains(t, logBuf.String(), `"method":"GET"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlattenHeaders(t *testing.T) {
|
||||||
|
h := http.Header{
|
||||||
|
"Content-Type": []string{"application/json"},
|
||||||
|
"Accept": []string{"text/html", "application/json"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := flattenHeaders(h)
|
||||||
|
|
||||||
|
assert.Equal(t, "application/json", result["Content-Type"])
|
||||||
|
assert.Equal(t, "text/html, application/json", result["Accept"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewLogger(t *testing.T) {
|
||||||
|
// Test stdout logger
|
||||||
|
l, err := NewLogger("test-forward", "", 1024)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, l)
|
||||||
|
assert.Nil(t, l.file) // No file when using stdout
|
||||||
|
l.Close()
|
||||||
|
|
||||||
|
// Test file logger (using temp file)
|
||||||
|
tmpFile := t.TempDir() + "/test.log"
|
||||||
|
l, err = NewLogger("test-forward", tmpFile, 1024)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, l)
|
||||||
|
assert.NotNil(t, l.file)
|
||||||
|
|
||||||
|
// Write something
|
||||||
|
err = l.Log(Entry{Direction: "request", Method: "GET"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
l.Close()
|
||||||
|
|
||||||
|
// Verify file has content
|
||||||
|
data, err := os.ReadFile(tmpFile)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(data), `"direction":"request"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewProxy tests proxy creation
|
||||||
|
func TestNewProxy(t *testing.T) {
|
||||||
|
t.Run("valid config", func(t *testing.T) {
|
||||||
|
fwd := &config.Forward{
|
||||||
|
LocalPort: 8080,
|
||||||
|
Port: 80,
|
||||||
|
HTTPLog: &config.HTTPLogSpec{
|
||||||
|
Enabled: true,
|
||||||
|
FilterPath: "/api/*",
|
||||||
|
IncludeHeaders: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy, err := NewProxy(fwd, 18080)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, proxy)
|
||||||
|
|
||||||
|
assert.Equal(t, 8080, proxy.localPort)
|
||||||
|
assert.Equal(t, 18080, proxy.targetPort)
|
||||||
|
assert.Equal(t, "/api/*", proxy.filterPath)
|
||||||
|
assert.True(t, proxy.includeHdrs)
|
||||||
|
assert.NotNil(t, proxy.logger)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil HTTPLog config", func(t *testing.T) {
|
||||||
|
fwd := &config.Forward{
|
||||||
|
LocalPort: 8080,
|
||||||
|
HTTPLog: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy, err := NewProxy(fwd, 18080)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Nil(t, proxy)
|
||||||
|
assert.Contains(t, err.Error(), "HTTP log config is nil")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_GetTargetPort tests target port getter
|
||||||
|
func TestProxy_GetTargetPort(t *testing.T) {
|
||||||
|
proxy := &Proxy{targetPort: 19090}
|
||||||
|
assert.Equal(t, 19090, proxy.GetTargetPort())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_GetLogger tests logger getter
|
||||||
|
func TestProxy_GetLogger(t *testing.T) {
|
||||||
|
logger := &Logger{forwardID: "test"}
|
||||||
|
proxy := &Proxy{logger: logger}
|
||||||
|
|
||||||
|
result := proxy.GetLogger()
|
||||||
|
assert.Equal(t, logger, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_ShouldLog tests path filtering
|
||||||
|
func TestProxy_ShouldLog(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filterPath string
|
||||||
|
path string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
// No filter - log everything
|
||||||
|
{"empty filter logs all", "", "/any/path", true},
|
||||||
|
{"empty filter logs root", "", "/", true},
|
||||||
|
|
||||||
|
// Exact match
|
||||||
|
{"exact match", "/api", "/api", true},
|
||||||
|
{"exact no match", "/api", "/other", false},
|
||||||
|
|
||||||
|
// Wildcard patterns
|
||||||
|
{"single wildcard match", "/api/*", "/api/users", true},
|
||||||
|
{"single wildcard no match", "/api/*", "/other/users", false},
|
||||||
|
{"middle wildcard", "/api/*/test", "/api/v1/test", true},
|
||||||
|
{"middle wildcard no match", "/api/*/test", "/api/v1/other", false},
|
||||||
|
|
||||||
|
// Prefix patterns (/* suffix special handling)
|
||||||
|
{"prefix match", "/api/*", "/api/users/123", true},
|
||||||
|
{"prefix match nested", "/api/*", "/api/users/123/deep", true},
|
||||||
|
|
||||||
|
// Edge cases
|
||||||
|
{"empty path", "/api/*", "", false},
|
||||||
|
{"trailing slash filter", "/api/", "/api/", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
p := &Proxy{filterPath: tt.filterPath}
|
||||||
|
result := p.shouldLog(tt.path)
|
||||||
|
assert.Equal(t, tt.expected, result, "filterPath=%q, path=%q", tt.filterPath, tt.path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_ShouldLog_InvalidPattern tests behavior with invalid glob patterns
|
||||||
|
func TestProxy_ShouldLog_InvalidPattern(t *testing.T) {
|
||||||
|
// Invalid glob pattern (unclosed bracket)
|
||||||
|
p := &Proxy{filterPath: "/api/[invalid"}
|
||||||
|
|
||||||
|
// Should default to logging everything on invalid pattern
|
||||||
|
assert.True(t, p.shouldLog("/any/path"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_StartStop tests basic start/stop lifecycle
|
||||||
|
func TestProxy_StartStop(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := &Logger{
|
||||||
|
forwardID: "test",
|
||||||
|
maxBodyLen: 1024,
|
||||||
|
output: &buf,
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := &Proxy{
|
||||||
|
localPort: 0, // Ephemeral port
|
||||||
|
targetPort: 9999,
|
||||||
|
logger: logger,
|
||||||
|
forwardID: "test-fwd",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start
|
||||||
|
err := proxy.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, proxy.running)
|
||||||
|
assert.NotNil(t, proxy.listener)
|
||||||
|
assert.NotNil(t, proxy.server)
|
||||||
|
|
||||||
|
// Double start should fail
|
||||||
|
err = proxy.Start()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "already running")
|
||||||
|
|
||||||
|
// Stop
|
||||||
|
err = proxy.Stop()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, proxy.running)
|
||||||
|
|
||||||
|
// Double stop should be OK
|
||||||
|
err = proxy.Stop()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_Start_PortInUse tests behavior when port is already in use
|
||||||
|
func TestProxy_Start_PortInUse(t *testing.T) {
|
||||||
|
// Start first proxy
|
||||||
|
logger1 := &Logger{output: bytes.NewBuffer(nil), maxBodyLen: 100}
|
||||||
|
proxy1 := &Proxy{
|
||||||
|
localPort: 0, // Ephemeral
|
||||||
|
targetPort: 9999,
|
||||||
|
logger: logger1,
|
||||||
|
}
|
||||||
|
err := proxy1.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer proxy1.Stop()
|
||||||
|
|
||||||
|
// Get the actual port
|
||||||
|
addr := proxy1.listener.Addr().(*net.TCPAddr)
|
||||||
|
usedPort := addr.Port
|
||||||
|
|
||||||
|
// Try to start second proxy on same port
|
||||||
|
logger2 := &Logger{output: bytes.NewBuffer(nil), maxBodyLen: 100}
|
||||||
|
proxy2 := &Proxy{
|
||||||
|
localPort: usedPort,
|
||||||
|
targetPort: 9999,
|
||||||
|
logger: logger2,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = proxy2.Start()
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "failed to listen")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFlattenHeaders_EdgeCases tests header flattening edge cases
|
||||||
|
func TestFlattenHeaders_EdgeCases(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
headers http.Header
|
||||||
|
expected map[string]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty headers",
|
||||||
|
headers: http.Header{},
|
||||||
|
expected: map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single value",
|
||||||
|
headers: http.Header{"X-Test": {"value"}},
|
||||||
|
expected: map[string]string{"X-Test": "value"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple values same key",
|
||||||
|
headers: http.Header{"Accept": {"text/html", "application/json", "text/plain"}},
|
||||||
|
expected: map[string]string{"Accept": "text/html, application/json, text/plain"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty value",
|
||||||
|
headers: http.Header{"X-Empty": {""}},
|
||||||
|
expected: map[string]string{"X-Empty": ""},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := flattenHeaders(tt.headers)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_RequestCount tests request counting
|
||||||
|
func TestProxy_RequestCount(t *testing.T) {
|
||||||
|
proxy := &Proxy{requestCount: 0}
|
||||||
|
|
||||||
|
// Simulate incrementing (normally done by loggingTransport)
|
||||||
|
assert.Equal(t, uint64(0), proxy.requestCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxy_LogError tests error logging
|
||||||
|
func TestProxy_LogError(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
logger := &Logger{
|
||||||
|
forwardID: "test",
|
||||||
|
maxBodyLen: 1024,
|
||||||
|
output: &buf,
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := &Proxy{
|
||||||
|
logger: logger,
|
||||||
|
forwardID: "test-fwd",
|
||||||
|
}
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", "/test", nil)
|
||||||
|
proxy.logError(req, assert.AnError)
|
||||||
|
|
||||||
|
var entry Entry
|
||||||
|
err := json.Unmarshal(buf.Bytes(), &entry)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "error", entry.Direction)
|
||||||
|
assert.Equal(t, "GET", entry.Method)
|
||||||
|
assert.Equal(t, "/test", entry.Path)
|
||||||
|
assert.Contains(t, entry.Error, "assert.AnError")
|
||||||
|
}
|
||||||
+68
-13
@@ -9,6 +9,8 @@ import (
|
|||||||
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/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.
|
// Discovery provides cluster introspection capabilities for the UI wizards.
|
||||||
@@ -41,9 +43,10 @@ type ContainerInfo struct {
|
|||||||
|
|
||||||
// PortInfo describes a port exposed by a container or service.
|
// PortInfo describes a port exposed by a container or service.
|
||||||
type PortInfo struct {
|
type PortInfo struct {
|
||||||
Name string
|
Name string
|
||||||
Port int32
|
Port int32
|
||||||
Protocol string
|
TargetPort int32 // For services: the actual pod port to forward to
|
||||||
|
Protocol string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceInfo contains information about a service.
|
// ServiceInfo contains information about a service.
|
||||||
@@ -205,7 +208,60 @@ func (d *Discovery) ListPodsWithSelector(ctx context.Context, contextName, names
|
|||||||
return pods, nil
|
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.
|
// 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) {
|
func (d *Discovery) ListServices(ctx context.Context, contextName, namespace string) ([]ServiceInfo, error) {
|
||||||
client, err := d.pool.GetClient(contextName)
|
client, err := d.pool.GetClient(contextName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -221,10 +277,13 @@ func (d *Discovery) ListServices(ctx context.Context, contextName, namespace str
|
|||||||
for _, svc := range svcList.Items {
|
for _, svc := range svcList.Items {
|
||||||
ports := make([]PortInfo, 0, len(svc.Spec.Ports))
|
ports := make([]PortInfo, 0, len(svc.Spec.Ports))
|
||||||
for _, port := range svc.Spec.Ports {
|
for _, port := range svc.Spec.Ports {
|
||||||
|
targetPort := d.resolveTargetPort(ctx, client, namespace, &svc, &port)
|
||||||
|
|
||||||
ports = append(ports, PortInfo{
|
ports = append(ports, PortInfo{
|
||||||
Name: port.Name,
|
Name: port.Name,
|
||||||
Port: port.Port,
|
Port: port.Port,
|
||||||
Protocol: string(port.Protocol),
|
TargetPort: targetPort,
|
||||||
|
Protocol: string(port.Protocol),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,15 +351,11 @@ func CheckPortAvailability(port int) (bool, string, error) {
|
|||||||
addr := fmt.Sprintf(":%d", port)
|
addr := fmt.Sprintf(":%d", port)
|
||||||
listener, err := net.Listen("tcp", addr)
|
listener, err := net.Listen("tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Port is in use
|
// Port is in use - return error details
|
||||||
// Try to get process info (best-effort)
|
return false, err.Error(), nil
|
||||||
processInfo := "unknown process"
|
|
||||||
// Note: Getting process info requires platform-specific code
|
|
||||||
// For now, just return a generic message
|
|
||||||
return false, processInfo, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Port is available, close the listener
|
// Port is available, close the listener
|
||||||
listener.Close()
|
_ = listener.Close()
|
||||||
return true, "", nil
|
return true, "", 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)
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/nvm/kportal/internal/config"
|
||||||
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
@@ -30,8 +32,8 @@ func NewPortForwarder(clientPool *ClientPool, resolver *ResourceResolver) *PortF
|
|||||||
return &PortForwarder{
|
return &PortForwarder{
|
||||||
clientPool: clientPool,
|
clientPool: clientPool,
|
||||||
resolver: resolver,
|
resolver: resolver,
|
||||||
tcpKeepalive: 30 * time.Second, // Default: 30 second keepalive
|
tcpKeepalive: config.DefaultTCPKeepalive,
|
||||||
dialTimeout: 30 * time.Second, // Default: 30 second dial timeout
|
dialTimeout: config.DefaultDialTimeout,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +142,9 @@ func (pf *PortForwarder) forwardToService(ctx context.Context, req *ForwardReque
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get pods backing the service using label selector
|
// 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})
|
selector := metav1.FormatLabelSelector(&metav1.LabelSelector{MatchLabels: service.Spec.Selector})
|
||||||
pods, err := client.CoreV1().Pods(req.Namespace).List(ctx, metav1.ListOptions{
|
pods, err := client.CoreV1().Pods(req.Namespace).List(ctx, metav1.ListOptions{
|
||||||
LabelSelector: selector,
|
LabelSelector: selector,
|
||||||
@@ -257,6 +262,9 @@ func (pf *PortForwarder) GetPodForResource(ctx context.Context, contextName, nam
|
|||||||
return "", fmt.Errorf("failed to get service: %w", err)
|
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})
|
selector := metav1.FormatLabelSelector(&metav1.LabelSelector{MatchLabels: service.Spec.Selector})
|
||||||
pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
|
pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
|
||||||
LabelSelector: selector,
|
LabelSelector: selector,
|
||||||
|
|||||||
@@ -173,21 +173,31 @@ func (r *ResourceResolver) resolvePodSelector(ctx context.Context, contextName,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getFromCache retrieves a cached resolution result if it exists and hasn't expired.
|
// getFromCache retrieves a cached resolution result if it exists and hasn't expired.
|
||||||
|
// Expired entries are removed to prevent memory growth over time.
|
||||||
func (r *ResourceResolver) getFromCache(key string) string {
|
func (r *ResourceResolver) getFromCache(key string) string {
|
||||||
r.cacheMu.RLock()
|
r.cacheMu.RLock()
|
||||||
defer r.cacheMu.RUnlock()
|
|
||||||
|
|
||||||
entry, exists := r.cache[key]
|
entry, exists := r.cache[key]
|
||||||
if !exists {
|
if !exists {
|
||||||
|
r.cacheMu.RUnlock()
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if expired
|
// Check if expired
|
||||||
if time.Now().After(entry.expiresAt) {
|
if time.Now().After(entry.expiresAt) {
|
||||||
|
r.cacheMu.RUnlock()
|
||||||
|
// Upgrade to write lock and delete expired entry
|
||||||
|
r.cacheMu.Lock()
|
||||||
|
// Double-check entry still exists and is still expired (may have been updated)
|
||||||
|
if entry, exists := r.cache[key]; exists && time.Now().After(entry.expiresAt) {
|
||||||
|
delete(r.cache, key)
|
||||||
|
}
|
||||||
|
r.cacheMu.Unlock()
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return entry.resource.Name
|
name := entry.resource.Name
|
||||||
|
r.cacheMu.RUnlock()
|
||||||
|
return name
|
||||||
}
|
}
|
||||||
|
|
||||||
// putInCache stores a resolution result in the cache with TTL.
|
// putInCache stores a resolution result in the cache with TTL.
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
// Allow zeroconf's internal goroutines (recv4, recv6) to fully initialize.
|
||||||
|
// This prevents a race condition where shutdown() could set connections to nil
|
||||||
|
// while recv goroutines are still starting up.
|
||||||
|
time.Sleep(startupSettleTime)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startupSettleTime is a small delay after zeroconf registration to allow internal
|
||||||
|
// goroutines (recv4, recv6) to fully initialize before any shutdown can occur.
|
||||||
|
// This works around a race condition in grandcat/zeroconf where shutdown() sets
|
||||||
|
// connections to nil while recv goroutines may still be initializing.
|
||||||
|
// See: https://github.com/grandcat/zeroconf/issues/95
|
||||||
|
const startupSettleTime = 50 * time.Millisecond
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
}
|
||||||
@@ -24,7 +24,8 @@ type Backoff struct {
|
|||||||
func NewBackoff() *Backoff {
|
func NewBackoff() *Backoff {
|
||||||
return &Backoff{
|
return &Backoff{
|
||||||
attempt: 0,
|
attempt: 0,
|
||||||
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
// #nosec G404 -- math/rand is appropriate for backoff jitter; cryptographic randomness not needed
|
||||||
|
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewBenchmarkState tests the constructor
|
||||||
|
func TestNewBenchmarkState(t *testing.T) {
|
||||||
|
state := newBenchmarkState("forward-123", "my-service", 8080)
|
||||||
|
|
||||||
|
assert.Equal(t, "forward-123", state.forwardID)
|
||||||
|
assert.Equal(t, "my-service", state.forwardAlias)
|
||||||
|
assert.Equal(t, 8080, state.localPort)
|
||||||
|
assert.Equal(t, BenchmarkStepConfig, state.step)
|
||||||
|
assert.Equal(t, "/", state.urlPath)
|
||||||
|
assert.Equal(t, "GET", state.method)
|
||||||
|
assert.Equal(t, 10, state.concurrency)
|
||||||
|
assert.Equal(t, 100, state.requests)
|
||||||
|
assert.Equal(t, 0, state.cursor)
|
||||||
|
assert.False(t, state.running)
|
||||||
|
assert.Nil(t, state.results)
|
||||||
|
assert.Nil(t, state.error)
|
||||||
|
assert.Nil(t, state.cancelFunc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBenchmarkState_StepTransitions tests step progression
|
||||||
|
func TestBenchmarkState_StepTransitions(t *testing.T) {
|
||||||
|
state := newBenchmarkState("fwd", "alias", 8080)
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
assert.Equal(t, BenchmarkStepConfig, state.step)
|
||||||
|
|
||||||
|
// Move to running
|
||||||
|
state.step = BenchmarkStepRunning
|
||||||
|
state.running = true
|
||||||
|
assert.Equal(t, BenchmarkStepRunning, state.step)
|
||||||
|
assert.True(t, state.running)
|
||||||
|
|
||||||
|
// Move to results
|
||||||
|
state.step = BenchmarkStepResults
|
||||||
|
state.running = false
|
||||||
|
assert.Equal(t, BenchmarkStepResults, state.step)
|
||||||
|
assert.False(t, state.running)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBenchmarkState_ProgressTracking tests progress updates
|
||||||
|
func TestBenchmarkState_ProgressTracking(t *testing.T) {
|
||||||
|
state := newBenchmarkState("fwd", "alias", 8080)
|
||||||
|
state.step = BenchmarkStepRunning
|
||||||
|
state.running = true
|
||||||
|
state.total = 100
|
||||||
|
|
||||||
|
// Simulate progress updates
|
||||||
|
updates := []struct {
|
||||||
|
progress int
|
||||||
|
total int
|
||||||
|
}{
|
||||||
|
{10, 100},
|
||||||
|
{50, 100},
|
||||||
|
{75, 100},
|
||||||
|
{100, 100},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, u := range updates {
|
||||||
|
state.progress = u.progress
|
||||||
|
state.total = u.total
|
||||||
|
assert.Equal(t, u.progress, state.progress)
|
||||||
|
assert.Equal(t, u.total, state.total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBenchmarkState_CancelFunc tests cancel function handling
|
||||||
|
func TestBenchmarkState_CancelFunc(t *testing.T) {
|
||||||
|
state := newBenchmarkState("fwd", "alias", 8080)
|
||||||
|
|
||||||
|
cancelled := false
|
||||||
|
state.cancelFunc = func() {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NotNil(t, state.cancelFunc)
|
||||||
|
|
||||||
|
// Call cancel
|
||||||
|
state.cancelFunc()
|
||||||
|
assert.True(t, cancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBenchmarkState_Results tests result storage
|
||||||
|
func TestBenchmarkState_Results(t *testing.T) {
|
||||||
|
state := newBenchmarkState("fwd", "alias", 8080)
|
||||||
|
|
||||||
|
results := &BenchmarkResults{
|
||||||
|
TotalRequests: 100,
|
||||||
|
Successful: 95,
|
||||||
|
Failed: 5,
|
||||||
|
MinLatency: 10.5,
|
||||||
|
MaxLatency: 250.0,
|
||||||
|
AvgLatency: 45.2,
|
||||||
|
P50Latency: 40.0,
|
||||||
|
P95Latency: 120.0,
|
||||||
|
P99Latency: 200.0,
|
||||||
|
Throughput: 150.5,
|
||||||
|
BytesRead: 1024000,
|
||||||
|
StatusCodes: map[int]int{
|
||||||
|
200: 90,
|
||||||
|
201: 5,
|
||||||
|
500: 5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
state.results = results
|
||||||
|
state.step = BenchmarkStepResults
|
||||||
|
|
||||||
|
assert.Equal(t, 100, state.results.TotalRequests)
|
||||||
|
assert.Equal(t, 95, state.results.Successful)
|
||||||
|
assert.Equal(t, 5, state.results.Failed)
|
||||||
|
assert.Equal(t, 45.2, state.results.AvgLatency)
|
||||||
|
assert.Equal(t, 150.5, state.results.Throughput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBenchmarkState_Error tests error handling
|
||||||
|
func TestBenchmarkState_Error(t *testing.T) {
|
||||||
|
state := newBenchmarkState("fwd", "alias", 8080)
|
||||||
|
|
||||||
|
assert.Nil(t, state.error)
|
||||||
|
|
||||||
|
// Simulate error
|
||||||
|
state.error = assert.AnError
|
||||||
|
state.step = BenchmarkStepResults
|
||||||
|
state.running = false
|
||||||
|
|
||||||
|
assert.NotNil(t, state.error)
|
||||||
|
assert.Nil(t, state.results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBenchmarkState_ConfigFields tests configuration field updates
|
||||||
|
func TestBenchmarkState_ConfigFields(t *testing.T) {
|
||||||
|
state := newBenchmarkState("fwd", "alias", 8080)
|
||||||
|
|
||||||
|
// Update URL path
|
||||||
|
state.urlPath = "/api/v1/health"
|
||||||
|
assert.Equal(t, "/api/v1/health", state.urlPath)
|
||||||
|
|
||||||
|
// Update method
|
||||||
|
state.method = "POST"
|
||||||
|
assert.Equal(t, "POST", state.method)
|
||||||
|
|
||||||
|
// Update concurrency
|
||||||
|
state.concurrency = 50
|
||||||
|
assert.Equal(t, 50, state.concurrency)
|
||||||
|
|
||||||
|
// Update requests
|
||||||
|
state.requests = 1000
|
||||||
|
assert.Equal(t, 1000, state.requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBenchmarkState_CursorBounds tests cursor navigation bounds
|
||||||
|
func TestBenchmarkState_CursorBounds(t *testing.T) {
|
||||||
|
state := newBenchmarkState("fwd", "alias", 8080)
|
||||||
|
|
||||||
|
// There are 4 config fields (0-3)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cursor int
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{"first field", 0, 0},
|
||||||
|
{"second field", 1, 1},
|
||||||
|
{"third field", 2, 2},
|
||||||
|
{"fourth field", 3, 3},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
state.cursor = tt.cursor
|
||||||
|
assert.Equal(t, tt.expected, state.cursor)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBenchmarkState_ProgressChannel tests progress channel handling
|
||||||
|
func TestBenchmarkState_ProgressChannel(t *testing.T) {
|
||||||
|
state := newBenchmarkState("fwd", "alias", 8080)
|
||||||
|
|
||||||
|
// Create a progress channel
|
||||||
|
state.progressCh = make(chan BenchmarkProgressMsg, 10)
|
||||||
|
|
||||||
|
// Send some progress
|
||||||
|
state.progressCh <- BenchmarkProgressMsg{
|
||||||
|
ForwardID: "fwd",
|
||||||
|
Completed: 50,
|
||||||
|
Total: 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive and verify
|
||||||
|
msg := <-state.progressCh
|
||||||
|
assert.Equal(t, "fwd", msg.ForwardID)
|
||||||
|
assert.Equal(t, 50, msg.Completed)
|
||||||
|
assert.Equal(t, 100, msg.Total)
|
||||||
|
|
||||||
|
// Close channel
|
||||||
|
close(state.progressCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBenchmarkStepValues tests step constants
|
||||||
|
func TestBenchmarkStepValues(t *testing.T) {
|
||||||
|
assert.Equal(t, BenchmarkStep(0), BenchmarkStepConfig)
|
||||||
|
assert.Equal(t, BenchmarkStep(1), BenchmarkStepRunning)
|
||||||
|
assert.Equal(t, BenchmarkStep(2), BenchmarkStepResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBenchmarkResults_StatusCodeMap tests status code tracking
|
||||||
|
func TestBenchmarkResults_StatusCodeMap(t *testing.T) {
|
||||||
|
results := &BenchmarkResults{
|
||||||
|
StatusCodes: make(map[int]int),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate collecting status codes
|
||||||
|
codes := []int{200, 200, 200, 201, 404, 500, 200}
|
||||||
|
for _, code := range codes {
|
||||||
|
results.StatusCodes[code]++
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, 4, results.StatusCodes[200])
|
||||||
|
assert.Equal(t, 1, results.StatusCodes[201])
|
||||||
|
assert.Equal(t, 1, results.StatusCodes[404])
|
||||||
|
assert.Equal(t, 1, results.StatusCodes[500])
|
||||||
|
}
|
||||||
+288
-107
@@ -2,15 +2,25 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/lipgloss/table"
|
||||||
"github.com/nvm/kportal/internal/config"
|
"github.com/nvm/kportal/internal/config"
|
||||||
"github.com/nvm/kportal/internal/k8s"
|
"github.com/nvm/kportal/internal/k8s"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// safeRecover recovers from panics and logs them
|
||||||
|
// Use with defer at the start of goroutines and callbacks that could panic
|
||||||
|
func safeRecover(context string) {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[UI] Panic recovered in %s: %v", context, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ForwardUpdateMsg is sent when a forward status changes
|
// ForwardUpdateMsg is sent when a forward status changes
|
||||||
type ForwardUpdateMsg struct {
|
type ForwardUpdateMsg struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -34,6 +44,10 @@ type ForwardRemoveMsg struct {
|
|||||||
ID string
|
ID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTTPLogSubscriber is a function that subscribes to HTTP logs for a forward
|
||||||
|
// It returns a cleanup function to call when unsubscribing
|
||||||
|
type HTTPLogSubscriber func(forwardID string, callback func(entry HTTPLogEntry)) func()
|
||||||
|
|
||||||
// BubbleTeaUI is a bubbletea-based terminal UI
|
// BubbleTeaUI is a bubbletea-based terminal UI
|
||||||
type BubbleTeaUI struct {
|
type BubbleTeaUI struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -46,6 +60,11 @@ type BubbleTeaUI struct {
|
|||||||
version string
|
version string
|
||||||
errors map[string]string // Track error messages by forward ID
|
errors map[string]string // Track error messages by forward ID
|
||||||
|
|
||||||
|
// Update notification
|
||||||
|
updateAvailable bool
|
||||||
|
updateVersion string
|
||||||
|
updateURL string
|
||||||
|
|
||||||
// Modal wizard state
|
// Modal wizard state
|
||||||
viewMode ViewMode
|
viewMode ViewMode
|
||||||
addWizard *AddWizardState
|
addWizard *AddWizardState
|
||||||
@@ -57,10 +76,22 @@ type BubbleTeaUI struct {
|
|||||||
deleteConfirmAlias string
|
deleteConfirmAlias string
|
||||||
deleteConfirmCursor int // 0 = Yes, 1 = No
|
deleteConfirmCursor int // 0 = Yes, 1 = No
|
||||||
|
|
||||||
|
// Benchmark state
|
||||||
|
benchmarkState *BenchmarkState
|
||||||
|
|
||||||
|
// HTTP log viewing state
|
||||||
|
httpLogState *HTTPLogState
|
||||||
|
|
||||||
|
// Log callback cleanup function
|
||||||
|
httpLogCleanup func()
|
||||||
|
|
||||||
// Dependencies for wizards
|
// Dependencies for wizards
|
||||||
discovery *k8s.Discovery
|
discovery *k8s.Discovery
|
||||||
mutator *config.Mutator
|
mutator *config.Mutator
|
||||||
configPath string
|
configPath string
|
||||||
|
|
||||||
|
// Manager for accessing workers
|
||||||
|
httpLogSubscriber HTTPLogSubscriber
|
||||||
}
|
}
|
||||||
|
|
||||||
// bubbletea model
|
// bubbletea model
|
||||||
@@ -96,6 +127,24 @@ func (ui *BubbleTeaUI) SetWizardDependencies(discovery *k8s.Discovery, mutator *
|
|||||||
ui.configPath = configPath
|
ui.configPath = configPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetHTTPLogSubscriber sets the function to subscribe to HTTP logs
|
||||||
|
func (ui *BubbleTeaUI) SetHTTPLogSubscriber(subscriber HTTPLogSubscriber) {
|
||||||
|
ui.mu.Lock()
|
||||||
|
defer ui.mu.Unlock()
|
||||||
|
|
||||||
|
ui.httpLogSubscriber = subscriber
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// Start starts the bubbletea application
|
||||||
func (ui *BubbleTeaUI) Start() error {
|
func (ui *BubbleTeaUI) Start() error {
|
||||||
m := model{ui: ui}
|
m := model{ui: ui}
|
||||||
@@ -169,8 +218,9 @@ func (ui *BubbleTeaUI) UpdateStatus(id string, status string) {
|
|||||||
if fwd, ok := ui.forwards[id]; ok {
|
if fwd, ok := ui.forwards[id]; ok {
|
||||||
fwd.Status = status
|
fwd.Status = status
|
||||||
}
|
}
|
||||||
// Clear error if status is not Error
|
// Only clear error when forward becomes Active again
|
||||||
if status != "Error" {
|
// This keeps error visible during Reconnecting/Starting states
|
||||||
|
if status == "Active" {
|
||||||
delete(ui.errors, id)
|
delete(ui.errors, id)
|
||||||
}
|
}
|
||||||
ui.mu.Unlock()
|
ui.mu.Unlock()
|
||||||
@@ -196,13 +246,35 @@ func (ui *BubbleTeaUI) Remove(id string) {
|
|||||||
ui.mu.Lock()
|
ui.mu.Lock()
|
||||||
delete(ui.forwards, id)
|
delete(ui.forwards, id)
|
||||||
|
|
||||||
|
// Clear any error associated with this forward
|
||||||
|
delete(ui.errors, id)
|
||||||
|
|
||||||
// Remove from order
|
// Remove from order
|
||||||
|
removedIndex := -1
|
||||||
for i, fid := range ui.forwardOrder {
|
for i, fid := range ui.forwardOrder {
|
||||||
if fid == id {
|
if fid == id {
|
||||||
|
removedIndex = i
|
||||||
ui.forwardOrder = append(ui.forwardOrder[:i], ui.forwardOrder[i+1:]...)
|
ui.forwardOrder = append(ui.forwardOrder[:i], ui.forwardOrder[i+1:]...)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Adjust selectedIndex if necessary
|
||||||
|
if removedIndex >= 0 {
|
||||||
|
// If we removed the selected item or an item before it, adjust
|
||||||
|
if ui.selectedIndex >= len(ui.forwardOrder) {
|
||||||
|
ui.selectedIndex = len(ui.forwardOrder) - 1
|
||||||
|
}
|
||||||
|
// Ensure selectedIndex is never negative
|
||||||
|
if ui.selectedIndex < 0 {
|
||||||
|
ui.selectedIndex = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear delete confirmation if we're deleting the same forward
|
||||||
|
if ui.deleteConfirming && ui.deleteConfirmID == id {
|
||||||
|
ui.resetDeleteConfirmation()
|
||||||
|
}
|
||||||
ui.mu.Unlock()
|
ui.mu.Unlock()
|
||||||
|
|
||||||
if ui.program != nil {
|
if ui.program != nil {
|
||||||
@@ -237,6 +309,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m.handleAddWizardKeys(msg)
|
return m.handleAddWizardKeys(msg)
|
||||||
case ViewModeRemoveWizard:
|
case ViewModeRemoveWizard:
|
||||||
return m.handleRemoveWizardKeys(msg)
|
return m.handleRemoveWizardKeys(msg)
|
||||||
|
case ViewModeBenchmark:
|
||||||
|
return m.handleBenchmarkKeys(msg)
|
||||||
|
case ViewModeHTTPLog:
|
||||||
|
return m.handleHTTPLogKeys(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward management messages (always update main view data)
|
// Forward management messages (always update main view data)
|
||||||
@@ -266,6 +342,23 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.ui.addWizard = nil
|
m.ui.addWizard = nil
|
||||||
m.ui.removeWizard = nil
|
m.ui.removeWizard = nil
|
||||||
m.ui.mu.Unlock()
|
m.ui.mu.Unlock()
|
||||||
|
return m, tea.ClearScreen
|
||||||
|
|
||||||
|
case BenchmarkCompleteMsg:
|
||||||
|
return m.handleBenchmarkComplete(msg)
|
||||||
|
|
||||||
|
case BenchmarkProgressMsg:
|
||||||
|
return m.handleBenchmarkProgress(msg)
|
||||||
|
|
||||||
|
case HTTPLogEntryMsg:
|
||||||
|
return m.handleHTTPLogEntry(msg)
|
||||||
|
|
||||||
|
case clearCopyMessageMsg:
|
||||||
|
m.ui.mu.Lock()
|
||||||
|
if m.ui.httpLogState != nil {
|
||||||
|
m.ui.httpLogState.copyMessage = ""
|
||||||
|
}
|
||||||
|
m.ui.mu.Unlock()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +400,12 @@ func (m model) View() string {
|
|||||||
case ViewModeRemoveWizard:
|
case ViewModeRemoveWizard:
|
||||||
modal := m.renderRemoveWizard()
|
modal := m.renderRemoveWizard()
|
||||||
return overlayContent(mainView, modal, termWidth, termHeight)
|
return overlayContent(mainView, modal, termWidth, termHeight)
|
||||||
|
case ViewModeBenchmark:
|
||||||
|
modal := m.renderBenchmark()
|
||||||
|
return overlayContent(mainView, modal, termWidth, termHeight)
|
||||||
|
case ViewModeHTTPLog:
|
||||||
|
// HTTP Log is full-screen, don't overlay on main view
|
||||||
|
return m.renderHTTPLog()
|
||||||
default:
|
default:
|
||||||
return mainView
|
return mainView
|
||||||
}
|
}
|
||||||
@@ -324,128 +423,132 @@ func (m model) renderMainView() string {
|
|||||||
termHeight = 40 // Fallback
|
termHeight = 40 // Fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// Styles
|
// Color palette
|
||||||
titleStyle := lipgloss.NewStyle().
|
headerColor := lipgloss.Color("220") // Yellow
|
||||||
Bold(true).
|
activeColor := lipgloss.Color("46") // Green
|
||||||
Foreground(lipgloss.Color("220")).
|
warningColor := lipgloss.Color("220") // Yellow
|
||||||
Padding(0, 1)
|
errorColor := lipgloss.Color("196") // Red
|
||||||
|
mutedColor := lipgloss.Color("240") // Gray
|
||||||
headerStyle := lipgloss.NewStyle().
|
selectedBg := lipgloss.Color("240") // Gray background
|
||||||
Bold(true).
|
selectedFg := lipgloss.Color("230") // Light foreground
|
||||||
Foreground(lipgloss.Color("220"))
|
|
||||||
|
|
||||||
separatorStyle := lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("240"))
|
|
||||||
|
|
||||||
selectedStyle := lipgloss.NewStyle().
|
|
||||||
Background(lipgloss.Color("240")).
|
|
||||||
Foreground(lipgloss.Color("230"))
|
|
||||||
|
|
||||||
disabledStyle := lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("240"))
|
|
||||||
|
|
||||||
activeStyle := lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("46"))
|
|
||||||
|
|
||||||
startingStyle := lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("220"))
|
|
||||||
|
|
||||||
errorStyle := lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("196"))
|
|
||||||
|
|
||||||
// Title with version
|
// Title with version
|
||||||
|
titleStyle := lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(headerColor).
|
||||||
|
Padding(0, 1)
|
||||||
|
|
||||||
title := fmt.Sprintf("kportal v%s - Port Forwarding Status", m.ui.version)
|
title := fmt.Sprintf("kportal v%s - Port Forwarding Status", m.ui.version)
|
||||||
b.WriteString(titleStyle.Render(title))
|
b.WriteString(titleStyle.Render(title))
|
||||||
b.WriteString("\n\n")
|
|
||||||
|
|
||||||
// Header
|
// Show update notification if available
|
||||||
header := fmt.Sprintf("%-15s %-18s %-20s %-10s %-21s %7s %7s %s",
|
if m.ui.updateAvailable {
|
||||||
"CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE", "LOCAL", "STATUS")
|
updateStyle := lipgloss.NewStyle().
|
||||||
b.WriteString(headerStyle.Render(header))
|
Foreground(lipgloss.Color("42")). // Green
|
||||||
b.WriteString("\n")
|
Bold(true)
|
||||||
b.WriteString(separatorStyle.Render(strings.Repeat("─", 120)))
|
updateMsg := fmt.Sprintf(" Update available: v%s", m.ui.updateVersion)
|
||||||
b.WriteString("\n")
|
b.WriteString(updateStyle.Render(updateMsg))
|
||||||
|
}
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
// No forwards
|
// No forwards
|
||||||
if len(m.ui.forwardOrder) == 0 {
|
if len(m.ui.forwardOrder) == 0 {
|
||||||
b.WriteString(disabledStyle.Render("\nNo forwards configured\n"))
|
disabledStyle := lipgloss.NewStyle().Foreground(mutedColor)
|
||||||
|
b.WriteString(disabledStyle.Render("No forwards configured\n"))
|
||||||
} else {
|
} else {
|
||||||
// Display forwards
|
// Build table rows
|
||||||
for idx, id := range m.ui.forwardOrder {
|
var rows [][]string
|
||||||
|
for _, id := range m.ui.forwardOrder {
|
||||||
fwd, ok := m.ui.forwards[id]
|
fwd, ok := m.ui.forwards[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
isSelected := (idx == m.ui.selectedIndex)
|
|
||||||
isDisabled := m.ui.disabledMap[id] || fwd.Status == "Disabled"
|
isDisabled := m.ui.disabledMap[id] || fwd.Status == "Disabled"
|
||||||
|
|
||||||
// Selection indicator
|
|
||||||
indicator := " "
|
|
||||||
if isSelected {
|
|
||||||
indicator = "> "
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status icon and text
|
// Status icon and text
|
||||||
statusIcon := "● "
|
statusIcon := "●"
|
||||||
statusText := fwd.Status
|
statusText := fwd.Status
|
||||||
|
|
||||||
if isDisabled {
|
if isDisabled {
|
||||||
statusIcon = "○ "
|
statusIcon = "○"
|
||||||
statusText = "Disabled"
|
statusText = "Disabled"
|
||||||
} else {
|
} else {
|
||||||
switch fwd.Status {
|
switch fwd.Status {
|
||||||
case "Starting":
|
case "Starting":
|
||||||
statusIcon = "○ "
|
statusIcon = "○"
|
||||||
case "Reconnecting":
|
case "Reconnecting":
|
||||||
statusIcon = "◐ "
|
statusIcon = "◐"
|
||||||
case "Error":
|
case "Error":
|
||||||
statusIcon = "✗ "
|
statusIcon = "✗"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format row
|
rows = append(rows, []string{
|
||||||
row := fmt.Sprintf("%s%-15s %-18s %-20s %-10s %-21s %7d %7d %s%s",
|
truncate(fwd.Context, 14),
|
||||||
indicator,
|
truncate(fwd.Namespace, 16),
|
||||||
truncate(fwd.Context, 15),
|
truncate(fwd.Alias, 18),
|
||||||
truncate(fwd.Namespace, 18),
|
truncate(fwd.Type, 8),
|
||||||
truncate(fwd.Alias, 20),
|
truncate(fwd.Resource, 20),
|
||||||
truncate(fwd.Type, 10),
|
fmt.Sprintf("%d", fwd.RemotePort),
|
||||||
truncate(fwd.Resource, 21),
|
fmt.Sprintf("%d", fwd.LocalPort),
|
||||||
fwd.RemotePort,
|
statusIcon + " " + statusText,
|
||||||
fwd.LocalPort,
|
})
|
||||||
statusIcon,
|
|
||||||
statusText)
|
|
||||||
|
|
||||||
// Apply styling
|
|
||||||
if isSelected {
|
|
||||||
row = selectedStyle.Render(row)
|
|
||||||
} else if isDisabled {
|
|
||||||
row = disabledStyle.Render(row)
|
|
||||||
} else {
|
|
||||||
// Color the status part
|
|
||||||
switch fwd.Status {
|
|
||||||
case "Active":
|
|
||||||
parts := strings.Split(row, statusIcon)
|
|
||||||
if len(parts) == 2 {
|
|
||||||
row = parts[0] + activeStyle.Render(statusIcon+statusText)
|
|
||||||
}
|
|
||||||
case "Starting", "Reconnecting":
|
|
||||||
parts := strings.Split(row, statusIcon)
|
|
||||||
if len(parts) == 2 {
|
|
||||||
row = parts[0] + startingStyle.Render(statusIcon+statusText)
|
|
||||||
}
|
|
||||||
case "Error":
|
|
||||||
parts := strings.Split(row, statusIcon)
|
|
||||||
if len(parts) == 2 {
|
|
||||||
row = parts[0] + errorStyle.Render(statusIcon+statusText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString(row)
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create table with styling (no borders for cleaner look)
|
||||||
|
t := table.New().
|
||||||
|
Border(lipgloss.HiddenBorder()).
|
||||||
|
Headers("CONTEXT", "NAMESPACE", "ALIAS", "TYPE", "RESOURCE", "REMOTE", "LOCAL", "STATUS").
|
||||||
|
Rows(rows...).
|
||||||
|
StyleFunc(func(row, col int) lipgloss.Style {
|
||||||
|
// Header row
|
||||||
|
if row == table.HeaderRow {
|
||||||
|
return lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(headerColor).
|
||||||
|
Padding(0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the forward for this row to check its status
|
||||||
|
baseStyle := lipgloss.NewStyle().Padding(0, 1)
|
||||||
|
|
||||||
|
if row >= 0 && row < len(m.ui.forwardOrder) {
|
||||||
|
id := m.ui.forwardOrder[row]
|
||||||
|
fwd, ok := m.ui.forwards[id]
|
||||||
|
isSelected := row == m.ui.selectedIndex
|
||||||
|
isDisabled := m.ui.disabledMap[id] || (ok && fwd.Status == "Disabled")
|
||||||
|
|
||||||
|
// Selected row gets background highlight
|
||||||
|
if isSelected {
|
||||||
|
return baseStyle.
|
||||||
|
Background(selectedBg).
|
||||||
|
Foreground(selectedFg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disabled rows are muted
|
||||||
|
if isDisabled {
|
||||||
|
return baseStyle.Foreground(mutedColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status column gets colored based on status
|
||||||
|
if col == 7 && ok { // STATUS column
|
||||||
|
switch fwd.Status {
|
||||||
|
case "Active":
|
||||||
|
return baseStyle.Foreground(activeColor)
|
||||||
|
case "Starting", "Reconnecting":
|
||||||
|
return baseStyle.Foreground(warningColor)
|
||||||
|
case "Error":
|
||||||
|
return baseStyle.Foreground(errorColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseStyle
|
||||||
|
})
|
||||||
|
|
||||||
|
b.WriteString(t.Render())
|
||||||
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display errors if any (before footer)
|
// Display errors if any (before footer)
|
||||||
@@ -499,18 +602,82 @@ func (m model) renderMainView() string {
|
|||||||
footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
||||||
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
|
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
|
||||||
|
|
||||||
footer := fmt.Sprintf("%s/%s: Navigate %s: Toggle %s: New %s: Edit %s: Delete %s: Quit │ Total: %d",
|
// Get terminal width for footer wrapping
|
||||||
keyStyle.Render("↑↓"),
|
termWidth := m.termWidth
|
||||||
keyStyle.Render("jk"),
|
if termWidth == 0 {
|
||||||
keyStyle.Render("Space"),
|
termWidth = 120
|
||||||
keyStyle.Render("n"),
|
}
|
||||||
keyStyle.Render("e"),
|
|
||||||
keyStyle.Render("d"),
|
|
||||||
keyStyle.Render("q"),
|
|
||||||
len(m.ui.forwardOrder))
|
|
||||||
|
|
||||||
// Fill space to push footer to bottom (reserve 2 lines: 1 for spacing, 1 for footer)
|
// Define key bindings as structured data for flexible rendering
|
||||||
footerHeight := 2
|
type keyBinding struct {
|
||||||
|
key string
|
||||||
|
desc string
|
||||||
|
}
|
||||||
|
bindings := []keyBinding{
|
||||||
|
{"↑↓/jk", "Navigate"},
|
||||||
|
{"Space", "Toggle"},
|
||||||
|
{"n", "New"},
|
||||||
|
{"e", "Edit"},
|
||||||
|
{"d", "Delete"},
|
||||||
|
{"b", "Bench"},
|
||||||
|
{"l", "Logs"},
|
||||||
|
{"q", "Quit"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build footer lines that fit within terminal width
|
||||||
|
var footerLines []string
|
||||||
|
var currentLine strings.Builder
|
||||||
|
currentLineVisualLen := 0
|
||||||
|
|
||||||
|
// Calculate how much space we need for the total count suffix
|
||||||
|
totalSuffix := fmt.Sprintf(" │ Total: %d", len(m.ui.forwardOrder))
|
||||||
|
totalSuffixLen := len(totalSuffix)
|
||||||
|
|
||||||
|
// Available width (account for some margin)
|
||||||
|
availableWidth := termWidth - 4
|
||||||
|
|
||||||
|
for i, binding := range bindings {
|
||||||
|
// Build this binding's text
|
||||||
|
keyRendered := keyStyle.Render(binding.key)
|
||||||
|
bindingText := keyRendered + ": " + binding.desc
|
||||||
|
// Visual length without ANSI codes
|
||||||
|
bindingVisualLen := len(binding.key) + 2 + len(binding.desc)
|
||||||
|
|
||||||
|
// Add separator if not first item on line
|
||||||
|
separator := ""
|
||||||
|
separatorLen := 0
|
||||||
|
if currentLine.Len() > 0 {
|
||||||
|
separator = " "
|
||||||
|
separatorLen = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this binding fits on current line
|
||||||
|
// For the last binding, also need to fit the total suffix
|
||||||
|
neededWidth := currentLineVisualLen + separatorLen + bindingVisualLen
|
||||||
|
if i == len(bindings)-1 {
|
||||||
|
neededWidth += totalSuffixLen
|
||||||
|
}
|
||||||
|
|
||||||
|
if neededWidth > availableWidth && currentLine.Len() > 0 {
|
||||||
|
// Start a new line
|
||||||
|
footerLines = append(footerLines, currentLine.String())
|
||||||
|
currentLine.Reset()
|
||||||
|
currentLineVisualLen = 0
|
||||||
|
separator = ""
|
||||||
|
separatorLen = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLine.WriteString(separator)
|
||||||
|
currentLine.WriteString(bindingText)
|
||||||
|
currentLineVisualLen += separatorLen + bindingVisualLen
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add total count to the last line
|
||||||
|
currentLine.WriteString(totalSuffix)
|
||||||
|
footerLines = append(footerLines, currentLine.String())
|
||||||
|
|
||||||
|
// Calculate footer height
|
||||||
|
footerHeight := len(footerLines) + 1 // +1 for the blank line before footer
|
||||||
remainingLines := termHeight - currentLines - footerHeight
|
remainingLines := termHeight - currentLines - footerHeight
|
||||||
if remainingLines > 0 {
|
if remainingLines > 0 {
|
||||||
b.WriteString(strings.Repeat("\n", remainingLines))
|
b.WriteString(strings.Repeat("\n", remainingLines))
|
||||||
@@ -518,7 +685,12 @@ func (m model) renderMainView() string {
|
|||||||
|
|
||||||
// Add footer at bottom
|
// Add footer at bottom
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
b.WriteString(footerStyle.Render(footer))
|
for i, line := range footerLines {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
b.WriteString(footerStyle.Render(line))
|
||||||
|
}
|
||||||
|
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
@@ -574,6 +746,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
|
// renderDeleteConfirmation renders the delete confirmation dialog
|
||||||
func (m model) renderDeleteConfirmation() string {
|
func (m model) renderDeleteConfirmation() string {
|
||||||
m.ui.mu.RLock()
|
m.ui.mu.RLock()
|
||||||
@@ -622,7 +803,7 @@ func (m model) renderDeleteConfirmation() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
b.WriteString(helpStyle.Render("←/→: Navigate Enter: Confirm Esc: Cancel"))
|
b.WriteString(wrapHelpText("←/→: Navigate Enter: Confirm Esc: Cancel", wizardHelpWidth(m.termWidth)))
|
||||||
|
|
||||||
// Wrap in a box using wizard style
|
// Wrap in a box using wizard style
|
||||||
boxStyle := lipgloss.NewStyle().
|
boxStyle := lipgloss.NewStyle().
|
||||||
|
|||||||
@@ -0,0 +1,529 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nvm/kportal/internal/config"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewBubbleTeaUI tests the constructor
|
||||||
|
func TestNewBubbleTeaUI(t *testing.T) {
|
||||||
|
callback := func(id string, enable bool) {}
|
||||||
|
|
||||||
|
ui := NewBubbleTeaUI(callback, "1.0.0")
|
||||||
|
|
||||||
|
assert.NotNil(t, ui)
|
||||||
|
assert.NotNil(t, ui.forwards)
|
||||||
|
assert.NotNil(t, ui.forwardOrder)
|
||||||
|
assert.NotNil(t, ui.disabledMap)
|
||||||
|
assert.NotNil(t, ui.errors)
|
||||||
|
assert.Equal(t, "1.0.0", ui.version)
|
||||||
|
assert.Equal(t, ViewModeMain, ui.viewMode)
|
||||||
|
assert.Equal(t, 0, ui.selectedIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBubbleTeaUI_AddForward tests adding forwards
|
||||||
|
func TestBubbleTeaUI_AddForward(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
Alias: "my-app",
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.AddForward("test-id", fwd)
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
defer ui.mu.RUnlock()
|
||||||
|
|
||||||
|
assert.Len(t, ui.forwards, 1)
|
||||||
|
assert.Len(t, ui.forwardOrder, 1)
|
||||||
|
assert.Equal(t, "test-id", ui.forwardOrder[0])
|
||||||
|
|
||||||
|
status := ui.forwards["test-id"]
|
||||||
|
assert.Equal(t, "my-app", status.Alias)
|
||||||
|
assert.Equal(t, "my-app", status.Resource)
|
||||||
|
assert.Equal(t, "pod", status.Type)
|
||||||
|
assert.Equal(t, 8080, status.LocalPort)
|
||||||
|
assert.Equal(t, 8080, status.RemotePort)
|
||||||
|
assert.Equal(t, "Starting", status.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBubbleTeaUI_AddForward_ServiceResource tests adding a service forward
|
||||||
|
func TestBubbleTeaUI_AddForward_ServiceResource(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: "service/postgres",
|
||||||
|
Port: 5432,
|
||||||
|
LocalPort: 5432,
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.AddForward("svc-id", fwd)
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
defer ui.mu.RUnlock()
|
||||||
|
|
||||||
|
status := ui.forwards["svc-id"]
|
||||||
|
assert.Equal(t, "postgres", status.Alias) // Uses resource name when no alias
|
||||||
|
assert.Equal(t, "postgres", status.Resource)
|
||||||
|
assert.Equal(t, "service", status.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBubbleTeaUI_AddForward_ReEnable tests re-enabling a disabled forward
|
||||||
|
func TestBubbleTeaUI_AddForward_ReEnable(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add forward
|
||||||
|
ui.AddForward("test-id", fwd)
|
||||||
|
|
||||||
|
// Disable it
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.disabledMap["test-id"] = true
|
||||||
|
ui.forwards["test-id"].Status = "Disabled"
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
// Re-add (re-enable)
|
||||||
|
ui.AddForward("test-id", fwd)
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
defer ui.mu.RUnlock()
|
||||||
|
|
||||||
|
assert.False(t, ui.disabledMap["test-id"])
|
||||||
|
assert.Equal(t, "Starting", ui.forwards["test-id"].Status)
|
||||||
|
assert.Len(t, ui.forwardOrder, 1) // Should not duplicate
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBubbleTeaUI_UpdateStatus tests status updates
|
||||||
|
func TestBubbleTeaUI_UpdateStatus(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
}
|
||||||
|
ui.AddForward("test-id", fwd)
|
||||||
|
|
||||||
|
// Update to Active
|
||||||
|
ui.UpdateStatus("test-id", "Active")
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
assert.Equal(t, "Active", ui.forwards["test-id"].Status)
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
|
||||||
|
// Update to Error
|
||||||
|
ui.UpdateStatus("test-id", "Error")
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
assert.Equal(t, "Error", ui.forwards["test-id"].Status)
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBubbleTeaUI_UpdateStatus_ClearsErrorOnActive tests that errors are cleared when status becomes Active
|
||||||
|
func TestBubbleTeaUI_UpdateStatus_ClearsErrorOnActive(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
}
|
||||||
|
ui.AddForward("test-id", fwd)
|
||||||
|
|
||||||
|
// Set an error
|
||||||
|
ui.SetError("test-id", "connection refused")
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
assert.Equal(t, "connection refused", ui.errors["test-id"])
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
|
||||||
|
// Update to Active - should clear error
|
||||||
|
ui.UpdateStatus("test-id", "Active")
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
_, hasError := ui.errors["test-id"]
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
|
||||||
|
assert.False(t, hasError, "Error should be cleared when status becomes Active")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBubbleTeaUI_UpdateStatus_KeepsErrorOnReconnecting tests that errors persist during reconnection
|
||||||
|
func TestBubbleTeaUI_UpdateStatus_KeepsErrorOnReconnecting(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
}
|
||||||
|
ui.AddForward("test-id", fwd)
|
||||||
|
|
||||||
|
// Set an error
|
||||||
|
ui.SetError("test-id", "connection refused")
|
||||||
|
|
||||||
|
// Update to Reconnecting - should keep error
|
||||||
|
ui.UpdateStatus("test-id", "Reconnecting")
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
assert.Equal(t, "connection refused", ui.errors["test-id"])
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBubbleTeaUI_SetError tests error setting
|
||||||
|
func TestBubbleTeaUI_SetError(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
}
|
||||||
|
ui.AddForward("test-id", fwd)
|
||||||
|
|
||||||
|
ui.SetError("test-id", "connection timeout")
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
defer ui.mu.RUnlock()
|
||||||
|
|
||||||
|
assert.Equal(t, "connection timeout", ui.errors["test-id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBubbleTeaUI_Remove tests forward removal
|
||||||
|
func TestBubbleTeaUI_Remove(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
}
|
||||||
|
ui.AddForward("test-id", fwd)
|
||||||
|
|
||||||
|
ui.Remove("test-id")
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
defer ui.mu.RUnlock()
|
||||||
|
|
||||||
|
assert.Len(t, ui.forwards, 0)
|
||||||
|
assert.Len(t, ui.forwardOrder, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBubbleTeaUI_Remove_ClearsErrors tests that removal clears associated errors
|
||||||
|
func TestBubbleTeaUI_Remove_ClearsErrors(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
}
|
||||||
|
ui.AddForward("test-id", fwd)
|
||||||
|
ui.SetError("test-id", "some error")
|
||||||
|
|
||||||
|
ui.Remove("test-id")
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
defer ui.mu.RUnlock()
|
||||||
|
|
||||||
|
_, hasError := ui.errors["test-id"]
|
||||||
|
assert.False(t, hasError, "Error should be cleared on removal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBubbleTeaUI_Remove_AdjustsSelectedIndex tests index adjustment after removal
|
||||||
|
func TestBubbleTeaUI_Remove_AdjustsSelectedIndex(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
forwards []string
|
||||||
|
selectedIndex int
|
||||||
|
removeID string
|
||||||
|
expectedIndex int
|
||||||
|
expectedRemaining int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "remove selected item (last in list)",
|
||||||
|
forwards: []string{"a", "b", "c"},
|
||||||
|
selectedIndex: 2,
|
||||||
|
removeID: "c",
|
||||||
|
expectedIndex: 1, // Should move to previous item
|
||||||
|
expectedRemaining: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove item before selected",
|
||||||
|
forwards: []string{"a", "b", "c"},
|
||||||
|
selectedIndex: 2,
|
||||||
|
removeID: "a",
|
||||||
|
expectedIndex: 1, // Index shifts down but points to same item
|
||||||
|
expectedRemaining: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove item after selected",
|
||||||
|
forwards: []string{"a", "b", "c"},
|
||||||
|
selectedIndex: 0,
|
||||||
|
removeID: "c",
|
||||||
|
expectedIndex: 0, // No change needed
|
||||||
|
expectedRemaining: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove only item",
|
||||||
|
forwards: []string{"a"},
|
||||||
|
selectedIndex: 0,
|
||||||
|
removeID: "a",
|
||||||
|
expectedIndex: 0, // Stays at 0 (clamped)
|
||||||
|
expectedRemaining: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove middle item when selected is after",
|
||||||
|
forwards: []string{"a", "b", "c", "d"},
|
||||||
|
selectedIndex: 3,
|
||||||
|
removeID: "b",
|
||||||
|
expectedIndex: 2, // Adjusts down
|
||||||
|
expectedRemaining: 3,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
// Add forwards
|
||||||
|
for _, id := range tt.forwards {
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: "pod/" + id,
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
}
|
||||||
|
ui.AddForward(id, fwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set selected index
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.selectedIndex = tt.selectedIndex
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
// Remove
|
||||||
|
ui.Remove(tt.removeID)
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
defer ui.mu.RUnlock()
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expectedIndex, ui.selectedIndex)
|
||||||
|
assert.Len(t, ui.forwardOrder, tt.expectedRemaining)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBubbleTeaUI_Remove_ClearsDeleteConfirmation tests that pending delete confirmation is cleared
|
||||||
|
func TestBubbleTeaUI_Remove_ClearsDeleteConfirmation(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
}
|
||||||
|
ui.AddForward("test-id", fwd)
|
||||||
|
|
||||||
|
// Set up delete confirmation
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.deleteConfirming = true
|
||||||
|
ui.deleteConfirmID = "test-id"
|
||||||
|
ui.deleteConfirmAlias = "my-app"
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
// Remove the forward
|
||||||
|
ui.Remove("test-id")
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
defer ui.mu.RUnlock()
|
||||||
|
|
||||||
|
assert.False(t, ui.deleteConfirming, "Delete confirmation should be cleared")
|
||||||
|
assert.Empty(t, ui.deleteConfirmID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBubbleTeaUI_Remove_KeepsOtherDeleteConfirmation tests that unrelated delete confirmation persists
|
||||||
|
func TestBubbleTeaUI_Remove_KeepsOtherDeleteConfirmation(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
fwd1 := &config.Forward{Resource: "pod/app1", Port: 8080, LocalPort: 8080}
|
||||||
|
fwd2 := &config.Forward{Resource: "pod/app2", Port: 8081, LocalPort: 8081}
|
||||||
|
ui.AddForward("id-1", fwd1)
|
||||||
|
ui.AddForward("id-2", fwd2)
|
||||||
|
|
||||||
|
// Set up delete confirmation for id-2
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.deleteConfirming = true
|
||||||
|
ui.deleteConfirmID = "id-2"
|
||||||
|
ui.deleteConfirmAlias = "app2"
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
// Remove id-1 (different forward)
|
||||||
|
ui.Remove("id-1")
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
defer ui.mu.RUnlock()
|
||||||
|
|
||||||
|
assert.True(t, ui.deleteConfirming, "Delete confirmation for other forward should persist")
|
||||||
|
assert.Equal(t, "id-2", ui.deleteConfirmID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBubbleTeaUI_MoveSelection tests cursor movement
|
||||||
|
func TestBubbleTeaUI_MoveSelection(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
// Add some forwards
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: "pod/app",
|
||||||
|
Port: 8080 + i,
|
||||||
|
LocalPort: 8080 + i,
|
||||||
|
}
|
||||||
|
ui.AddForward(string(rune('a'+i)), fwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
initialIndex int
|
||||||
|
delta int
|
||||||
|
expectedIndex int
|
||||||
|
}{
|
||||||
|
{"move down from 0", 0, 1, 1},
|
||||||
|
{"move down from middle", 2, 1, 3},
|
||||||
|
{"move up from middle", 2, -1, 1},
|
||||||
|
{"cannot move below 0", 0, -1, 0},
|
||||||
|
{"cannot move above max", 4, 1, 4},
|
||||||
|
{"large delta clamped to max", 0, 100, 4},
|
||||||
|
{"large negative delta clamped to 0", 4, -100, 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.selectedIndex = tt.initialIndex
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
ui.moveSelection(tt.delta)
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
assert.Equal(t, tt.expectedIndex, ui.selectedIndex)
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBubbleTeaUI_MoveSelection_EmptyList tests movement with no forwards
|
||||||
|
func TestBubbleTeaUI_MoveSelection_EmptyList(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
// Should not panic with empty list
|
||||||
|
ui.moveSelection(1)
|
||||||
|
ui.moveSelection(-1)
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
assert.Equal(t, 0, ui.selectedIndex)
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBubbleTeaUI_ToggleSelected tests toggling forward state
|
||||||
|
func TestBubbleTeaUI_ToggleSelected(t *testing.T) {
|
||||||
|
callback := func(id string, enable bool) {
|
||||||
|
// Callback is called in a goroutine
|
||||||
|
}
|
||||||
|
|
||||||
|
ui := NewBubbleTeaUI(callback, "1.0.0")
|
||||||
|
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
}
|
||||||
|
ui.AddForward("test-id", fwd)
|
||||||
|
|
||||||
|
// Toggle to disabled
|
||||||
|
ui.toggleSelected()
|
||||||
|
|
||||||
|
// Wait for goroutine
|
||||||
|
ui.mu.RLock()
|
||||||
|
isDisabled := ui.disabledMap["test-id"]
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
|
||||||
|
assert.True(t, isDisabled)
|
||||||
|
|
||||||
|
// Toggle back to enabled
|
||||||
|
ui.toggleSelected()
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
isDisabled = ui.disabledMap["test-id"]
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
|
||||||
|
assert.False(t, isDisabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBubbleTeaUI_SetUpdateAvailable tests update notification
|
||||||
|
func TestBubbleTeaUI_SetUpdateAvailable(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
ui.SetUpdateAvailable("2.0.0", "https://example.com/update")
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
defer ui.mu.RUnlock()
|
||||||
|
|
||||||
|
assert.True(t, ui.updateAvailable)
|
||||||
|
assert.Equal(t, "2.0.0", ui.updateVersion)
|
||||||
|
assert.Equal(t, "https://example.com/update", ui.updateURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBubbleTeaUI_SetWizardDependencies tests dependency injection
|
||||||
|
func TestBubbleTeaUI_SetWizardDependencies(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
// Initially nil
|
||||||
|
ui.mu.RLock()
|
||||||
|
assert.Nil(t, ui.discovery)
|
||||||
|
assert.Nil(t, ui.mutator)
|
||||||
|
assert.Empty(t, ui.configPath)
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
|
||||||
|
// Set dependencies (using nil for simplicity - just testing the setter)
|
||||||
|
ui.SetWizardDependencies(nil, nil, "/path/to/config.yaml")
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
defer ui.mu.RUnlock()
|
||||||
|
|
||||||
|
assert.Equal(t, "/path/to/config.yaml", ui.configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBubbleTeaUI_ResetDeleteConfirmation tests the reset helper
|
||||||
|
func TestBubbleTeaUI_ResetDeleteConfirmation(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
// Set up confirmation state
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.deleteConfirming = true
|
||||||
|
ui.deleteConfirmID = "test-id"
|
||||||
|
ui.deleteConfirmAlias = "test-alias"
|
||||||
|
ui.deleteConfirmCursor = 1
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.resetDeleteConfirmation()
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
defer ui.mu.RUnlock()
|
||||||
|
|
||||||
|
assert.False(t, ui.deleteConfirming)
|
||||||
|
assert.Empty(t, ui.deleteConfirmID)
|
||||||
|
assert.Empty(t, ui.deleteConfirmAlias)
|
||||||
|
assert.Equal(t, 0, ui.deleteConfirmCursor)
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nvm/kportal/internal/k8s"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMessageTypes tests the message type structures
|
||||||
|
func TestMessageTypes(t *testing.T) {
|
||||||
|
t.Run("ContextsLoadedMsg", func(t *testing.T) {
|
||||||
|
msg := ContextsLoadedMsg{
|
||||||
|
contexts: []string{"ctx1", "ctx2"},
|
||||||
|
}
|
||||||
|
assert.Len(t, msg.contexts, 2)
|
||||||
|
assert.Nil(t, msg.err)
|
||||||
|
|
||||||
|
errMsg := ContextsLoadedMsg{
|
||||||
|
err: assert.AnError,
|
||||||
|
}
|
||||||
|
assert.NotNil(t, errMsg.err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NamespacesLoadedMsg", func(t *testing.T) {
|
||||||
|
msg := NamespacesLoadedMsg{
|
||||||
|
namespaces: []string{"default", "kube-system"},
|
||||||
|
}
|
||||||
|
assert.Len(t, msg.namespaces, 2)
|
||||||
|
assert.Nil(t, msg.err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PodsLoadedMsg", func(t *testing.T) {
|
||||||
|
msg := PodsLoadedMsg{
|
||||||
|
pods: []k8s.PodInfo{
|
||||||
|
{Name: "pod1", Namespace: "default"},
|
||||||
|
{Name: "pod2", Namespace: "default"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.Len(t, msg.pods, 2)
|
||||||
|
assert.Nil(t, msg.err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ServicesLoadedMsg", func(t *testing.T) {
|
||||||
|
msg := ServicesLoadedMsg{
|
||||||
|
services: []k8s.ServiceInfo{
|
||||||
|
{Name: "svc1", Namespace: "default"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.Len(t, msg.services, 1)
|
||||||
|
assert.Nil(t, msg.err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SelectorValidatedMsg", func(t *testing.T) {
|
||||||
|
validMsg := SelectorValidatedMsg{
|
||||||
|
valid: true,
|
||||||
|
pods: []k8s.PodInfo{
|
||||||
|
{Name: "matched-pod"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.True(t, validMsg.valid)
|
||||||
|
assert.Len(t, validMsg.pods, 1)
|
||||||
|
|
||||||
|
invalidMsg := SelectorValidatedMsg{
|
||||||
|
valid: false,
|
||||||
|
err: assert.AnError,
|
||||||
|
}
|
||||||
|
assert.False(t, invalidMsg.valid)
|
||||||
|
assert.NotNil(t, invalidMsg.err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PortCheckedMsg", func(t *testing.T) {
|
||||||
|
availableMsg := PortCheckedMsg{
|
||||||
|
port: 8080,
|
||||||
|
available: true,
|
||||||
|
message: "Port 8080 available",
|
||||||
|
}
|
||||||
|
assert.Equal(t, 8080, availableMsg.port)
|
||||||
|
assert.True(t, availableMsg.available)
|
||||||
|
|
||||||
|
unavailableMsg := PortCheckedMsg{
|
||||||
|
port: 8080,
|
||||||
|
available: false,
|
||||||
|
message: "Port 8080 in use by process",
|
||||||
|
}
|
||||||
|
assert.False(t, unavailableMsg.available)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ForwardSavedMsg", func(t *testing.T) {
|
||||||
|
successMsg := ForwardSavedMsg{success: true}
|
||||||
|
assert.True(t, successMsg.success)
|
||||||
|
|
||||||
|
failMsg := ForwardSavedMsg{success: false, err: assert.AnError}
|
||||||
|
assert.False(t, failMsg.success)
|
||||||
|
assert.NotNil(t, failMsg.err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ForwardsRemovedMsg", func(t *testing.T) {
|
||||||
|
msg := ForwardsRemovedMsg{
|
||||||
|
success: true,
|
||||||
|
count: 3,
|
||||||
|
}
|
||||||
|
assert.True(t, msg.success)
|
||||||
|
assert.Equal(t, 3, msg.count)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("WizardCompleteMsg", func(t *testing.T) {
|
||||||
|
msg := WizardCompleteMsg{}
|
||||||
|
assert.NotNil(t, msg)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("BenchmarkCompleteMsg", func(t *testing.T) {
|
||||||
|
msg := BenchmarkCompleteMsg{
|
||||||
|
ForwardID: "fwd-123",
|
||||||
|
Results: nil,
|
||||||
|
Error: nil,
|
||||||
|
}
|
||||||
|
assert.Equal(t, "fwd-123", msg.ForwardID)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("BenchmarkProgressMsg", func(t *testing.T) {
|
||||||
|
msg := BenchmarkProgressMsg{
|
||||||
|
ForwardID: "fwd-123",
|
||||||
|
Completed: 50,
|
||||||
|
Total: 100,
|
||||||
|
}
|
||||||
|
assert.Equal(t, "fwd-123", msg.ForwardID)
|
||||||
|
assert.Equal(t, 50, msg.Completed)
|
||||||
|
assert.Equal(t, 100, msg.Total)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("HTTPLogEntryMsg", func(t *testing.T) {
|
||||||
|
msg := HTTPLogEntryMsg{
|
||||||
|
Entry: HTTPLogEntry{
|
||||||
|
Method: "GET",
|
||||||
|
Path: "/api/test",
|
||||||
|
StatusCode: 200,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.Equal(t, "GET", msg.Entry.Method)
|
||||||
|
assert.Equal(t, "/api/test", msg.Entry.Path)
|
||||||
|
assert.Equal(t, 200, msg.Entry.StatusCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckPortCmd tests the port availability check command
|
||||||
|
func TestCheckPortCmd_PortAvailability(t *testing.T) {
|
||||||
|
// Create a temporary config file for testing
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
// Create an empty config file
|
||||||
|
err := os.WriteFile(configPath, []byte("contexts: []\n"), 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Test checking a random high port that should be available
|
||||||
|
cmd := checkPortCmd(59999, configPath)
|
||||||
|
msg := cmd()
|
||||||
|
|
||||||
|
portMsg, ok := msg.(PortCheckedMsg)
|
||||||
|
require.True(t, ok, "Expected PortCheckedMsg")
|
||||||
|
assert.Equal(t, 59999, portMsg.port)
|
||||||
|
// The port may or may not be available depending on the system,
|
||||||
|
// but we verify the message structure is correct
|
||||||
|
assert.NotEmpty(t, portMsg.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckPortCmd_ConfigConflict tests port conflict detection in config
|
||||||
|
func TestCheckPortCmd_ConfigConflict(t *testing.T) {
|
||||||
|
// Create a temporary config file with a forward using port 8080
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
||||||
|
|
||||||
|
configContent := `contexts:
|
||||||
|
- name: test-ctx
|
||||||
|
namespaces:
|
||||||
|
- name: default
|
||||||
|
forwards:
|
||||||
|
- resource: pod/my-app
|
||||||
|
port: 80
|
||||||
|
localPort: 8080
|
||||||
|
`
|
||||||
|
err := os.WriteFile(configPath, []byte(configContent), 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Test checking port that's already in config
|
||||||
|
cmd := checkPortCmd(8080, configPath)
|
||||||
|
msg := cmd()
|
||||||
|
|
||||||
|
portMsg, ok := msg.(PortCheckedMsg)
|
||||||
|
require.True(t, ok, "Expected PortCheckedMsg")
|
||||||
|
assert.Equal(t, 8080, portMsg.port)
|
||||||
|
assert.False(t, portMsg.available, "Port should not be available (in config)")
|
||||||
|
assert.Contains(t, portMsg.message, "already assigned")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCheckPortCmd_InvalidConfig tests behavior with invalid config file
|
||||||
|
func TestCheckPortCmd_InvalidConfig(t *testing.T) {
|
||||||
|
// Use a non-existent config path
|
||||||
|
cmd := checkPortCmd(59998, "/nonexistent/path/.kportal.yaml")
|
||||||
|
msg := cmd()
|
||||||
|
|
||||||
|
portMsg, ok := msg.(PortCheckedMsg)
|
||||||
|
require.True(t, ok, "Expected PortCheckedMsg")
|
||||||
|
// Should still return a result (just skip config check)
|
||||||
|
assert.Equal(t, 59998, portMsg.port)
|
||||||
|
assert.NotEmpty(t, portMsg.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListenBenchmarkProgressCmd tests the progress listener command
|
||||||
|
func TestListenBenchmarkProgressCmd(t *testing.T) {
|
||||||
|
progressCh := make(chan BenchmarkProgressMsg, 1)
|
||||||
|
|
||||||
|
// Send a progress message
|
||||||
|
progressCh <- BenchmarkProgressMsg{
|
||||||
|
ForwardID: "fwd-123",
|
||||||
|
Completed: 25,
|
||||||
|
Total: 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := listenBenchmarkProgressCmd(progressCh)
|
||||||
|
msg := cmd()
|
||||||
|
|
||||||
|
progressMsg, ok := msg.(BenchmarkProgressMsg)
|
||||||
|
require.True(t, ok, "Expected BenchmarkProgressMsg")
|
||||||
|
assert.Equal(t, "fwd-123", progressMsg.ForwardID)
|
||||||
|
assert.Equal(t, 25, progressMsg.Completed)
|
||||||
|
assert.Equal(t, 100, progressMsg.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListenBenchmarkProgressCmd_ChannelClosed tests behavior when channel closes
|
||||||
|
func TestListenBenchmarkProgressCmd_ChannelClosed(t *testing.T) {
|
||||||
|
progressCh := make(chan BenchmarkProgressMsg)
|
||||||
|
close(progressCh)
|
||||||
|
|
||||||
|
cmd := listenBenchmarkProgressCmd(progressCh)
|
||||||
|
msg := cmd()
|
||||||
|
|
||||||
|
assert.Nil(t, msg, "Should return nil when channel is closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunBenchmarkCmd_Cancellation tests benchmark cancellation
|
||||||
|
func TestRunBenchmarkCmd_Cancellation(t *testing.T) {
|
||||||
|
// Create a context that's already cancelled
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // Cancel immediately
|
||||||
|
|
||||||
|
progressCh := make(chan BenchmarkProgressMsg, 100)
|
||||||
|
|
||||||
|
cmd := runBenchmarkCmd(ctx, "fwd-123", 59997, "/", "GET", 1, 10, progressCh)
|
||||||
|
|
||||||
|
// Run with timeout to prevent hanging
|
||||||
|
done := make(chan bool, 1)
|
||||||
|
var msg interface{}
|
||||||
|
go func() {
|
||||||
|
msg = cmd()
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Command completed
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("runBenchmarkCmd timed out")
|
||||||
|
}
|
||||||
|
|
||||||
|
completeMsg, ok := msg.(BenchmarkCompleteMsg)
|
||||||
|
require.True(t, ok, "Expected BenchmarkCompleteMsg")
|
||||||
|
assert.Equal(t, "fwd-123", completeMsg.ForwardID)
|
||||||
|
// When cancelled, we expect either an error or the context cancellation message
|
||||||
|
// The benchmark may or may not have had time to process the cancellation
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestK8sAPITimeout tests that the timeout constant is correct
|
||||||
|
func TestK8sAPITimeout(t *testing.T) {
|
||||||
|
assert.Equal(t, 10*time.Second, k8sAPITimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemovableForwardStruct tests the RemovableForward structure used by commands
|
||||||
|
func TestRemovableForwardStruct(t *testing.T) {
|
||||||
|
rf := RemovableForward{
|
||||||
|
ID: "fwd-123",
|
||||||
|
Context: "prod",
|
||||||
|
Namespace: "default",
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Selector: "app=my-app",
|
||||||
|
Alias: "my-app",
|
||||||
|
Port: 80,
|
||||||
|
LocalPort: 8080,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "fwd-123", rf.ID)
|
||||||
|
assert.Equal(t, "prod", rf.Context)
|
||||||
|
assert.Equal(t, "default", rf.Namespace)
|
||||||
|
assert.Equal(t, "pod/my-app", rf.Resource)
|
||||||
|
assert.Equal(t, "app=my-app", rf.Selector)
|
||||||
|
assert.Equal(t, "my-app", rf.Alias)
|
||||||
|
assert.Equal(t, 80, rf.Port)
|
||||||
|
assert.Equal(t, 8080, rf.LocalPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBenchmarkProgressCallback tests the progress callback in runBenchmarkCmd
|
||||||
|
func TestBenchmarkProgressCallback(t *testing.T) {
|
||||||
|
// Test that progress channel handles blocking gracefully
|
||||||
|
progressCh := make(chan BenchmarkProgressMsg, 1) // Small buffer
|
||||||
|
|
||||||
|
// Fill the channel
|
||||||
|
progressCh <- BenchmarkProgressMsg{Completed: 1, Total: 100}
|
||||||
|
|
||||||
|
// Test non-blocking send by creating callback similar to runBenchmarkCmd
|
||||||
|
callback := func(completed, total int) {
|
||||||
|
select {
|
||||||
|
case progressCh <- BenchmarkProgressMsg{
|
||||||
|
ForwardID: "test",
|
||||||
|
Completed: completed,
|
||||||
|
Total: total,
|
||||||
|
}:
|
||||||
|
default:
|
||||||
|
// Drop if channel is full - should not block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not block even with full channel
|
||||||
|
done := make(chan bool, 1)
|
||||||
|
go func() {
|
||||||
|
callback(50, 100) // This should not block
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Success - didn't block
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
t.Fatal("Callback blocked when channel was full")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTTPLogEntry tests the HTTPLogEntry structure
|
||||||
|
func TestHTTPLogEntry(t *testing.T) {
|
||||||
|
entry := HTTPLogEntry{
|
||||||
|
Timestamp: "2025-11-26T10:30:00Z",
|
||||||
|
Direction: "request",
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/api/users",
|
||||||
|
StatusCode: 201,
|
||||||
|
LatencyMs: 150,
|
||||||
|
BodySize: 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "2025-11-26T10:30:00Z", entry.Timestamp)
|
||||||
|
assert.Equal(t, "request", entry.Direction)
|
||||||
|
assert.Equal(t, "POST", entry.Method)
|
||||||
|
assert.Equal(t, "/api/users", entry.Path)
|
||||||
|
assert.Equal(t, 201, entry.StatusCode)
|
||||||
|
assert.Equal(t, int64(150), entry.LatencyMs)
|
||||||
|
assert.Equal(t, 1024, entry.BodySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTTPLogSubscriberType tests the HTTPLogSubscriber function type
|
||||||
|
func TestHTTPLogSubscriberType(t *testing.T) {
|
||||||
|
// Test that our mock matches the type
|
||||||
|
mock := NewMockHTTPLogSubscriber()
|
||||||
|
var subscriber HTTPLogSubscriber = mock.GetSubscriberFunc()
|
||||||
|
|
||||||
|
// Test subscription
|
||||||
|
callCount := 0
|
||||||
|
cleanup := subscriber("fwd-123", func(entry HTTPLogEntry) {
|
||||||
|
callCount++
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send an entry
|
||||||
|
mock.SendEntry("fwd-123", HTTPLogEntry{Method: "GET"})
|
||||||
|
assert.Equal(t, 1, callCount)
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
cleanup()
|
||||||
|
assert.Equal(t, 1, mock.CleanupCalls)
|
||||||
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nvm/kportal/internal/config"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestConcurrent_AddAndRemove tests concurrent add and remove operations
|
||||||
|
// Run with: go test -race ./internal/ui/...
|
||||||
|
func TestConcurrent_AddAndRemove(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
numGoroutines := 100
|
||||||
|
|
||||||
|
// Concurrent adds
|
||||||
|
for i := 0; i < numGoroutines; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: fmt.Sprintf("pod/app-%d", idx),
|
||||||
|
Port: 8080 + idx,
|
||||||
|
LocalPort: 8080 + idx,
|
||||||
|
}
|
||||||
|
ui.AddForward(fmt.Sprintf("id-%d", idx), fwd)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Verify all adds succeeded
|
||||||
|
ui.mu.RLock()
|
||||||
|
assert.Len(t, ui.forwards, numGoroutines)
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
|
||||||
|
// Concurrent removes
|
||||||
|
for i := 0; i < numGoroutines; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
ui.Remove(fmt.Sprintf("id-%d", idx))
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Verify all removes succeeded
|
||||||
|
ui.mu.RLock()
|
||||||
|
assert.Len(t, ui.forwards, 0)
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrent_StatusUpdates tests concurrent status updates
|
||||||
|
func TestConcurrent_StatusUpdates(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
// Add forwards first
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: fmt.Sprintf("pod/app-%d", i),
|
||||||
|
Port: 8080 + i,
|
||||||
|
LocalPort: 8080 + i,
|
||||||
|
}
|
||||||
|
ui.AddForward(fmt.Sprintf("id-%d", i), fwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
numUpdates := 1000
|
||||||
|
statuses := []string{"Active", "Starting", "Reconnecting", "Error"}
|
||||||
|
|
||||||
|
// Concurrent status updates
|
||||||
|
for i := 0; i < numUpdates; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
forwardID := fmt.Sprintf("id-%d", idx%10)
|
||||||
|
status := statuses[idx%len(statuses)]
|
||||||
|
ui.UpdateStatus(forwardID, status)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Just verify no panics occurred - final state is non-deterministic
|
||||||
|
ui.mu.RLock()
|
||||||
|
assert.Len(t, ui.forwards, 10)
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrent_SetErrors tests concurrent error setting
|
||||||
|
func TestConcurrent_SetErrors(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
// Add forwards
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: fmt.Sprintf("pod/app-%d", i),
|
||||||
|
Port: 8080 + i,
|
||||||
|
LocalPort: 8080 + i,
|
||||||
|
}
|
||||||
|
ui.AddForward(fmt.Sprintf("id-%d", i), fwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
numErrors := 500
|
||||||
|
|
||||||
|
// Concurrent error setting
|
||||||
|
for i := 0; i < numErrors; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
forwardID := fmt.Sprintf("id-%d", idx%10)
|
||||||
|
ui.SetError(forwardID, fmt.Sprintf("error-%d", idx))
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Verify no panics
|
||||||
|
ui.mu.RLock()
|
||||||
|
assert.NotEmpty(t, ui.errors)
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrent_MoveSelection tests concurrent selection movement
|
||||||
|
func TestConcurrent_MoveSelection(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
// Add forwards
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: fmt.Sprintf("pod/app-%d", i),
|
||||||
|
Port: 8080 + i,
|
||||||
|
LocalPort: 8080 + i,
|
||||||
|
}
|
||||||
|
ui.AddForward(fmt.Sprintf("id-%d", i), fwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
numMoves := 1000
|
||||||
|
|
||||||
|
// Concurrent moves
|
||||||
|
for i := 0; i < numMoves; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
delta := 1
|
||||||
|
if idx%2 == 0 {
|
||||||
|
delta = -1
|
||||||
|
}
|
||||||
|
ui.moveSelection(delta)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Verify selection is within bounds
|
||||||
|
ui.mu.RLock()
|
||||||
|
assert.GreaterOrEqual(t, ui.selectedIndex, 0)
|
||||||
|
assert.Less(t, ui.selectedIndex, len(ui.forwardOrder))
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrent_AddRemoveAndUpdate tests mixed concurrent operations
|
||||||
|
func TestConcurrent_AddRemoveAndUpdate(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Concurrent adds
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: fmt.Sprintf("pod/app-%d", idx),
|
||||||
|
Port: 8080 + idx,
|
||||||
|
LocalPort: 8080 + idx,
|
||||||
|
}
|
||||||
|
ui.AddForward(fmt.Sprintf("id-%d", idx), fwd)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concurrent updates (some will be for non-existent forwards)
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
forwardID := fmt.Sprintf("id-%d", idx%60) // Some won't exist
|
||||||
|
ui.UpdateStatus(forwardID, "Active")
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concurrent removes (some will be for non-existent forwards)
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
ui.Remove(fmt.Sprintf("id-%d", idx))
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Just verify no panics - final state depends on execution order
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrent_HTTPLogEntries tests concurrent HTTP log entry additions
|
||||||
|
func TestConcurrent_HTTPLogEntries(t *testing.T) {
|
||||||
|
state := newHTTPLogState("fwd", "alias")
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex // Simulate the UI lock for entries
|
||||||
|
numEntries := 1000
|
||||||
|
|
||||||
|
for i := 0; i < numEntries; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
entry := HTTPLogEntry{
|
||||||
|
Method: "GET",
|
||||||
|
Path: fmt.Sprintf("/api/test/%d", idx),
|
||||||
|
StatusCode: 200,
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
state.entries = append(state.entries, entry)
|
||||||
|
mu.Unlock()
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
assert.Len(t, state.entries, numEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrent_FilterWhileAdding tests filtering while entries are being added
|
||||||
|
func TestConcurrent_FilterWhileAdding(t *testing.T) {
|
||||||
|
state := newHTTPLogState("fwd", "alias")
|
||||||
|
state.filterMode = HTTPLogFilterErrors
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
|
||||||
|
// Add entries concurrently
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
code := 200
|
||||||
|
if idx%5 == 0 {
|
||||||
|
code = 500
|
||||||
|
}
|
||||||
|
entry := HTTPLogEntry{
|
||||||
|
Method: "GET",
|
||||||
|
Path: fmt.Sprintf("/api/test/%d", idx),
|
||||||
|
StatusCode: code,
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
state.entries = append(state.entries, entry)
|
||||||
|
mu.Unlock()
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter concurrently
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
mu.Lock()
|
||||||
|
_ = state.getFilteredEntries()
|
||||||
|
mu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Verify filtering still works
|
||||||
|
mu.Lock()
|
||||||
|
filtered := state.getFilteredEntries()
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
assert.Len(t, state.entries, 100)
|
||||||
|
assert.Len(t, filtered, 20) // 20% are errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrent_ToggleCallback tests that toggle callback is called safely
|
||||||
|
func TestConcurrent_ToggleCallback(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
callCount := 0
|
||||||
|
|
||||||
|
callback := func(id string, enable bool) {
|
||||||
|
mu.Lock()
|
||||||
|
callCount++
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
ui := NewBubbleTeaUI(callback, "1.0.0")
|
||||||
|
|
||||||
|
// Add a forward
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: "pod/app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
}
|
||||||
|
ui.AddForward("test-id", fwd)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
// Toggle many times concurrently
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
ui.toggleSelected()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Give callbacks time to complete (they run in goroutines)
|
||||||
|
// This is a basic check - in real code you'd use proper synchronization
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrent_WizardDependencies tests setting dependencies concurrently
|
||||||
|
func TestConcurrent_WizardDependencies(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
ui.SetWizardDependencies(nil, nil, fmt.Sprintf("/path/%d", idx))
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Just verify no panics
|
||||||
|
ui.mu.RLock()
|
||||||
|
assert.NotEmpty(t, ui.configPath)
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrent_SetUpdateAvailable tests concurrent update availability setting
|
||||||
|
func TestConcurrent_SetUpdateAvailable(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
ui.SetUpdateAvailable(fmt.Sprintf("2.0.%d", idx), "https://example.com")
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Verify update is available
|
||||||
|
ui.mu.RLock()
|
||||||
|
assert.True(t, ui.updateAvailable)
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
}
|
||||||
@@ -0,0 +1,902 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/nvm/kportal/internal/config"
|
||||||
|
"github.com/nvm/kportal/internal/k8s"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper to create a model for testing
|
||||||
|
func newTestModel() model {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
return model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create a model with a forward
|
||||||
|
func newTestModelWithForward() model {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
Alias: "my-app",
|
||||||
|
}
|
||||||
|
ui.AddForward("test-id", fwd)
|
||||||
|
return model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleMainViewKeys_Quit tests quit key handling
|
||||||
|
func TestHandleMainViewKeys_Quit(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
key string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"q", true},
|
||||||
|
{"ctrl+c", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.key, func(t *testing.T) {
|
||||||
|
m := newTestModel()
|
||||||
|
_, cmd := m.handleMainViewKeys(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)})
|
||||||
|
|
||||||
|
if tt.key == "ctrl+c" {
|
||||||
|
keyMsg := tea.KeyMsg{Type: tea.KeyCtrlC}
|
||||||
|
_, cmd = m.handleMainViewKeys(keyMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tea.Quit returns a special command
|
||||||
|
if tt.expected {
|
||||||
|
assert.NotNil(t, cmd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleMainViewKeys_Navigation tests cursor navigation
|
||||||
|
func TestHandleMainViewKeys_Navigation(t *testing.T) {
|
||||||
|
m := newTestModelWithForward()
|
||||||
|
|
||||||
|
// Add more forwards for navigation testing
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: "pod/app",
|
||||||
|
Port: 8080 + i,
|
||||||
|
LocalPort: 8080 + i,
|
||||||
|
}
|
||||||
|
m.ui.AddForward(string(rune('a'+i)), fwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
keyType tea.KeyType
|
||||||
|
initialIndex int
|
||||||
|
expectedIndex int
|
||||||
|
}{
|
||||||
|
{"down arrow", "down", tea.KeyDown, 0, 1},
|
||||||
|
{"j key", "j", tea.KeyRunes, 0, 1},
|
||||||
|
{"up arrow", "up", tea.KeyUp, 2, 1},
|
||||||
|
{"k key", "k", tea.KeyRunes, 2, 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
m.ui.mu.Lock()
|
||||||
|
m.ui.selectedIndex = tt.initialIndex
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
|
||||||
|
var keyMsg tea.KeyMsg
|
||||||
|
if tt.keyType == tea.KeyRunes {
|
||||||
|
keyMsg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(tt.key)}
|
||||||
|
} else {
|
||||||
|
keyMsg = tea.KeyMsg{Type: tt.keyType}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.handleMainViewKeys(keyMsg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.Equal(t, tt.expectedIndex, m.ui.selectedIndex)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleMainViewKeys_Toggle tests space/enter toggle
|
||||||
|
func TestHandleMainViewKeys_Toggle(t *testing.T) {
|
||||||
|
toggleCallback := NewMockToggleCallback()
|
||||||
|
ui := NewBubbleTeaUI(toggleCallback.GetFunc(), "1.0.0")
|
||||||
|
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
}
|
||||||
|
ui.AddForward("test-id", fwd)
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
// Toggle with space
|
||||||
|
keyMsg := tea.KeyMsg{Type: tea.KeySpace}
|
||||||
|
m.handleMainViewKeys(keyMsg)
|
||||||
|
|
||||||
|
// Check disabled state changed
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
isDisabled := m.ui.disabledMap["test-id"]
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
|
||||||
|
assert.True(t, isDisabled)
|
||||||
|
|
||||||
|
// Give callback goroutine time to execute
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
// Verify callback was called
|
||||||
|
assert.GreaterOrEqual(t, toggleCallback.CallCount(), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleMainViewKeys_NewWizard tests 'n' key with dependencies
|
||||||
|
func TestHandleMainViewKeys_NewWizard(t *testing.T) {
|
||||||
|
mockDiscovery := NewMockDiscovery()
|
||||||
|
mockMutator := NewMockMutator()
|
||||||
|
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.SetWizardDependencies(nil, nil, "/path/to/config") // Real Discovery/Mutator needed
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
// Without dependencies, 'n' should do nothing
|
||||||
|
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
|
||||||
|
m.handleMainViewKeys(keyMsg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.Nil(t, m.ui.addWizard, "Wizard should not be created without dependencies")
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
|
||||||
|
// With mock (but we can't inject easily due to concrete types)
|
||||||
|
// This test documents the expected behavior
|
||||||
|
_ = mockDiscovery
|
||||||
|
_ = mockMutator
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleMainViewKeys_DeleteConfirmation tests 'd' key
|
||||||
|
func TestHandleMainViewKeys_DeleteConfirmation(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
|
||||||
|
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
Alias: "my-app",
|
||||||
|
}
|
||||||
|
ui.AddForward("test-id", fwd)
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
// Press 'd' to show delete confirmation
|
||||||
|
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}
|
||||||
|
m.handleMainViewKeys(keyMsg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.True(t, m.ui.deleteConfirming)
|
||||||
|
assert.Equal(t, "test-id", m.ui.deleteConfirmID)
|
||||||
|
assert.Equal(t, "my-app", m.ui.deleteConfirmAlias)
|
||||||
|
assert.Equal(t, 1, m.ui.deleteConfirmCursor) // Default to "No"
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleMainViewKeys_DeleteConfirmation_PreventsDuplicate tests that 'd' doesn't overwrite
|
||||||
|
func TestHandleMainViewKeys_DeleteConfirmation_PreventsDuplicate(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
|
||||||
|
|
||||||
|
fwd1 := &config.Forward{Resource: "pod/app1", Port: 8080, LocalPort: 8080, Alias: "app1"}
|
||||||
|
fwd2 := &config.Forward{Resource: "pod/app2", Port: 8081, LocalPort: 8081, Alias: "app2"}
|
||||||
|
ui.AddForward("id-1", fwd1)
|
||||||
|
ui.AddForward("id-2", fwd2)
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
// Press 'd' for first forward
|
||||||
|
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("d")}
|
||||||
|
m.handleMainViewKeys(keyMsg)
|
||||||
|
|
||||||
|
// Change selection
|
||||||
|
m.ui.mu.Lock()
|
||||||
|
m.ui.selectedIndex = 1
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
|
||||||
|
// Press 'd' again - should not change confirmation
|
||||||
|
m.handleMainViewKeys(keyMsg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.Equal(t, "id-1", m.ui.deleteConfirmID, "Delete confirmation should not be overwritten")
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleDeleteConfirmation_Cancel tests Esc cancels delete
|
||||||
|
func TestHandleDeleteConfirmation_Cancel(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
// Set up delete confirmation
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.deleteConfirming = true
|
||||||
|
ui.deleteConfirmID = "test-id"
|
||||||
|
ui.deleteConfirmAlias = "test-alias"
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
// Press Esc
|
||||||
|
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
|
||||||
|
m.handleDeleteConfirmation(keyMsg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.False(t, m.ui.deleteConfirming)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleDeleteConfirmation_NavigateAndConfirm tests cursor navigation in delete dialog
|
||||||
|
func TestHandleDeleteConfirmation_NavigateAndConfirm(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
// Note: We use SetWizardDependencies with a real (nil) mutator since
|
||||||
|
// the navigation test doesn't actually call mutator methods
|
||||||
|
ui.SetWizardDependencies(nil, nil, "/path/to/config")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.deleteConfirming = true
|
||||||
|
ui.deleteConfirmID = "test-id"
|
||||||
|
ui.deleteConfirmCursor = 1 // Start on "No"
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
// Navigate left to "Yes"
|
||||||
|
keyMsg := tea.KeyMsg{Type: tea.KeyLeft}
|
||||||
|
m.handleDeleteConfirmation(keyMsg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.Equal(t, 0, m.ui.deleteConfirmCursor)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
|
||||||
|
// Navigate right back to "No"
|
||||||
|
keyMsg = tea.KeyMsg{Type: tea.KeyRight}
|
||||||
|
m.handleDeleteConfirmation(keyMsg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.Equal(t, 1, m.ui.deleteConfirmCursor)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleDeleteConfirmation_ConfirmYes tests confirming deletion
|
||||||
|
func TestHandleDeleteConfirmation_ConfirmYes(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
// Note: The mutator needs to be set for the command to be generated,
|
||||||
|
// but we don't call the actual mutator method in this test (just generate the cmd)
|
||||||
|
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.deleteConfirming = true
|
||||||
|
ui.deleteConfirmID = "test-id"
|
||||||
|
ui.deleteConfirmCursor = 0 // On "Yes"
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
// Press Enter on "Yes"
|
||||||
|
keyMsg := tea.KeyMsg{Type: tea.KeyEnter}
|
||||||
|
_, cmd := m.handleDeleteConfirmation(keyMsg)
|
||||||
|
|
||||||
|
// Should return a command to remove the forward
|
||||||
|
assert.NotNil(t, cmd)
|
||||||
|
|
||||||
|
// Dialog should be closed
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.False(t, m.ui.deleteConfirming)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleDeleteConfirmation_QuickYKey tests 'y' key for quick confirm
|
||||||
|
func TestHandleDeleteConfirmation_QuickYKey(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
// Set up with a real mutator (empty but valid) since we're testing command generation
|
||||||
|
ui.SetWizardDependencies(nil, &config.Mutator{}, "/path/to/config")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.deleteConfirming = true
|
||||||
|
ui.deleteConfirmID = "test-id"
|
||||||
|
ui.deleteConfirmCursor = 1 // On "No"
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
// Press 'y' - should confirm regardless of cursor position
|
||||||
|
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}
|
||||||
|
_, cmd := m.handleDeleteConfirmation(keyMsg)
|
||||||
|
|
||||||
|
assert.NotNil(t, cmd)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.False(t, m.ui.deleteConfirming)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleDeleteConfirmation_QuickNKey tests 'n' key for quick cancel
|
||||||
|
func TestHandleDeleteConfirmation_QuickNKey(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.deleteConfirming = true
|
||||||
|
ui.deleteConfirmID = "test-id"
|
||||||
|
ui.deleteConfirmCursor = 0 // On "Yes"
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
// Press 'n' - should cancel regardless of cursor position
|
||||||
|
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}
|
||||||
|
m.handleDeleteConfirmation(keyMsg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.False(t, m.ui.deleteConfirming)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleBenchmarkKeys_Cancel tests benchmark cancellation
|
||||||
|
func TestHandleBenchmarkKeys_Cancel(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
cancelled := false
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeBenchmark
|
||||||
|
ui.benchmarkState = newBenchmarkState("fwd-id", "alias", 8080)
|
||||||
|
ui.benchmarkState.cancelFunc = func() { cancelled = true }
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
// Press Esc
|
||||||
|
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
|
||||||
|
m.handleBenchmarkKeys(keyMsg)
|
||||||
|
|
||||||
|
assert.True(t, cancelled, "Cancel function should be called")
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.Nil(t, m.ui.benchmarkState)
|
||||||
|
assert.Equal(t, ViewModeMain, m.ui.viewMode)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleBenchmarkKeys_Navigation tests benchmark config navigation
|
||||||
|
func TestHandleBenchmarkKeys_Navigation(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeBenchmark
|
||||||
|
ui.benchmarkState = newBenchmarkState("fwd-id", "alias", 8080)
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
// Initial cursor is 0
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.Equal(t, 0, m.ui.benchmarkState.cursor)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
|
||||||
|
// Move down
|
||||||
|
keyMsg := tea.KeyMsg{Type: tea.KeyDown}
|
||||||
|
m.handleBenchmarkKeys(keyMsg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.Equal(t, 1, m.ui.benchmarkState.cursor)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
|
||||||
|
// Move down again
|
||||||
|
m.handleBenchmarkKeys(keyMsg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.Equal(t, 2, m.ui.benchmarkState.cursor)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
|
||||||
|
// Move up
|
||||||
|
keyMsg = tea.KeyMsg{Type: tea.KeyUp}
|
||||||
|
m.handleBenchmarkKeys(keyMsg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.Equal(t, 1, m.ui.benchmarkState.cursor)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleHTTPLogKeys_Close tests HTTP log view closing
|
||||||
|
func TestHandleHTTPLogKeys_Close(t *testing.T) {
|
||||||
|
mockSubscriber := NewMockHTTPLogSubscriber()
|
||||||
|
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeHTTPLog
|
||||||
|
ui.httpLogState = newHTTPLogState("fwd-id", "alias")
|
||||||
|
ui.httpLogCleanup = mockSubscriber.Subscribe("fwd-id", func(entry HTTPLogEntry) {})
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
// Press Esc
|
||||||
|
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
|
||||||
|
m.handleHTTPLogKeys(keyMsg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.Nil(t, m.ui.httpLogState)
|
||||||
|
assert.Nil(t, m.ui.httpLogCleanup)
|
||||||
|
assert.Equal(t, ViewModeMain, m.ui.viewMode)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
|
||||||
|
// Verify cleanup was called
|
||||||
|
assert.Equal(t, 1, mockSubscriber.CleanupCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleHTTPLogKeys_FilterCycle tests filter mode cycling
|
||||||
|
func TestHandleHTTPLogKeys_FilterCycle(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeHTTPLog
|
||||||
|
ui.httpLogState = newHTTPLogState("fwd-id", "alias")
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
// Initial mode is None
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.Equal(t, HTTPLogFilterNone, m.ui.httpLogState.filterMode)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
|
||||||
|
// Press 'f' to cycle - should skip Text mode and go to Non200
|
||||||
|
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f")}
|
||||||
|
m.handleHTTPLogKeys(keyMsg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.Equal(t, HTTPLogFilterNon200, m.ui.httpLogState.filterMode)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
|
||||||
|
// Press 'f' again - should go to Errors
|
||||||
|
m.handleHTTPLogKeys(keyMsg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.Equal(t, HTTPLogFilterErrors, m.ui.httpLogState.filterMode)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
|
||||||
|
// Press 'f' again - should go back to None
|
||||||
|
m.handleHTTPLogKeys(keyMsg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.Equal(t, HTTPLogFilterNone, m.ui.httpLogState.filterMode)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleHTTPLogKeys_TextFilter tests '/' for text filter
|
||||||
|
func TestHandleHTTPLogKeys_TextFilter(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeHTTPLog
|
||||||
|
ui.httpLogState = newHTTPLogState("fwd-id", "alias")
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
// Press '/'
|
||||||
|
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}
|
||||||
|
m.handleHTTPLogKeys(keyMsg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.True(t, m.ui.httpLogState.filterActive)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleHTTPLogKeys_ClearFilters tests 'c' to clear filters
|
||||||
|
func TestHandleHTTPLogKeys_ClearFilters(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeHTTPLog
|
||||||
|
ui.httpLogState = newHTTPLogState("fwd-id", "alias")
|
||||||
|
ui.httpLogState.filterMode = HTTPLogFilterErrors
|
||||||
|
ui.httpLogState.filterText = "api"
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
// Press 'c'
|
||||||
|
keyMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")}
|
||||||
|
m.handleHTTPLogKeys(keyMsg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.Equal(t, HTTPLogFilterNone, m.ui.httpLogState.filterMode)
|
||||||
|
assert.Empty(t, m.ui.httpLogState.filterText)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleHTTPLogEntry tests HTTP log entry handling
|
||||||
|
func TestHandleHTTPLogEntry(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeHTTPLog
|
||||||
|
ui.httpLogState = newHTTPLogState("fwd-id", "alias")
|
||||||
|
ui.httpLogState.autoScroll = true
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
// Send an entry
|
||||||
|
msg := HTTPLogEntryMsg{
|
||||||
|
Entry: HTTPLogEntry{
|
||||||
|
Method: "GET",
|
||||||
|
Path: "/api/test",
|
||||||
|
StatusCode: 200,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
m.handleHTTPLogEntry(msg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.Len(t, m.ui.httpLogState.entries, 1)
|
||||||
|
assert.Equal(t, "/api/test", m.ui.httpLogState.entries[0].Path)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleContextsLoaded tests context loading handler
|
||||||
|
func TestHandleContextsLoaded(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeAddWizard
|
||||||
|
ui.addWizard = newAddWizardState()
|
||||||
|
ui.addWizard.loading = true
|
||||||
|
// Note: discovery is nil but the handler doesn't use it directly,
|
||||||
|
// it uses the message data instead. The current context reordering
|
||||||
|
// uses GetCurrentContext() which would fail with nil discovery,
|
||||||
|
// but we test the basic loading behavior here.
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
// Simulate contexts loaded
|
||||||
|
msg := ContextsLoadedMsg{
|
||||||
|
contexts: []string{"default", "production", "staging"},
|
||||||
|
}
|
||||||
|
m.handleContextsLoaded(msg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.False(t, m.ui.addWizard.loading)
|
||||||
|
// Contexts should be loaded (order depends on GetCurrentContext which may fail with nil discovery)
|
||||||
|
assert.Contains(t, m.ui.addWizard.contexts, "default")
|
||||||
|
assert.Contains(t, m.ui.addWizard.contexts, "production")
|
||||||
|
assert.Contains(t, m.ui.addWizard.contexts, "staging")
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleContextsLoaded_Error tests error handling
|
||||||
|
func TestHandleContextsLoaded_Error(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeAddWizard
|
||||||
|
ui.addWizard = newAddWizardState()
|
||||||
|
ui.addWizard.loading = true
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
// Simulate error
|
||||||
|
expectedErr := errors.New("failed to list contexts")
|
||||||
|
msg := ContextsLoadedMsg{
|
||||||
|
err: expectedErr,
|
||||||
|
}
|
||||||
|
m.handleContextsLoaded(msg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.False(t, m.ui.addWizard.loading)
|
||||||
|
assert.Equal(t, expectedErr, m.ui.addWizard.error)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleNamespacesLoaded tests namespace loading handler
|
||||||
|
func TestHandleNamespacesLoaded(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeAddWizard
|
||||||
|
ui.addWizard = newAddWizardState()
|
||||||
|
ui.addWizard.loading = true
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
msg := NamespacesLoadedMsg{
|
||||||
|
namespaces: []string{"default", "kube-system", "production"},
|
||||||
|
}
|
||||||
|
m.handleNamespacesLoaded(msg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.False(t, m.ui.addWizard.loading)
|
||||||
|
assert.Equal(t, []string{"default", "kube-system", "production"}, m.ui.addWizard.namespaces)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandlePodsLoaded tests pod loading handler
|
||||||
|
func TestHandlePodsLoaded(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeAddWizard
|
||||||
|
ui.addWizard = newAddWizardState()
|
||||||
|
ui.addWizard.loading = true
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
pods := []k8s.PodInfo{
|
||||||
|
{Name: "app-1", Namespace: "default"},
|
||||||
|
{Name: "app-2", Namespace: "default"},
|
||||||
|
}
|
||||||
|
msg := PodsLoadedMsg{pods: pods}
|
||||||
|
m.handlePodsLoaded(msg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.False(t, m.ui.addWizard.loading)
|
||||||
|
assert.Len(t, m.ui.addWizard.pods, 2)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleServicesLoaded tests service loading handler
|
||||||
|
func TestHandleServicesLoaded(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeAddWizard
|
||||||
|
ui.addWizard = newAddWizardState()
|
||||||
|
ui.addWizard.loading = true
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
services := []k8s.ServiceInfo{
|
||||||
|
{Name: "api", Namespace: "default", Ports: []k8s.PortInfo{{Port: 80}}},
|
||||||
|
{Name: "db", Namespace: "default", Ports: []k8s.PortInfo{{Port: 5432}}},
|
||||||
|
}
|
||||||
|
msg := ServicesLoadedMsg{services: services}
|
||||||
|
m.handleServicesLoaded(msg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.False(t, m.ui.addWizard.loading)
|
||||||
|
assert.Len(t, m.ui.addWizard.services, 2)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSelectorValidated tests selector validation handler
|
||||||
|
func TestHandleSelectorValidated(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeAddWizard
|
||||||
|
ui.addWizard = newAddWizardState()
|
||||||
|
ui.addWizard.loading = true
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
pods := []k8s.PodInfo{
|
||||||
|
{Name: "app-1", Namespace: "default"},
|
||||||
|
}
|
||||||
|
msg := SelectorValidatedMsg{
|
||||||
|
valid: true,
|
||||||
|
pods: pods,
|
||||||
|
}
|
||||||
|
m.handleSelectorValidated(msg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.False(t, m.ui.addWizard.loading)
|
||||||
|
assert.Len(t, m.ui.addWizard.matchingPods, 1)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandlePortChecked tests port availability check handler
|
||||||
|
func TestHandlePortChecked(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
available bool
|
||||||
|
expectStep AddWizardStep
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{"port available", true, StepConfirmation, false},
|
||||||
|
{"port in use", false, StepEnterLocalPort, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeAddWizard
|
||||||
|
ui.addWizard = newAddWizardState()
|
||||||
|
ui.addWizard.step = StepEnterLocalPort
|
||||||
|
ui.addWizard.loading = true
|
||||||
|
ui.addWizard.localPort = 8080
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
msg := PortCheckedMsg{
|
||||||
|
port: 8080,
|
||||||
|
available: tt.available,
|
||||||
|
message: "test message",
|
||||||
|
}
|
||||||
|
m.handlePortChecked(msg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.False(t, m.ui.addWizard.loading)
|
||||||
|
assert.Equal(t, tt.available, m.ui.addWizard.portAvailable)
|
||||||
|
if tt.expectError {
|
||||||
|
assert.NotNil(t, m.ui.addWizard.error)
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, tt.expectStep, m.ui.addWizard.step)
|
||||||
|
}
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleForwardSaved tests forward save handler
|
||||||
|
func TestHandleForwardSaved(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeAddWizard
|
||||||
|
ui.addWizard = newAddWizardState()
|
||||||
|
ui.addWizard.step = StepConfirmation
|
||||||
|
ui.addWizard.loading = true
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
msg := ForwardSavedMsg{success: true}
|
||||||
|
m.handleForwardSaved(msg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.False(t, m.ui.addWizard.loading)
|
||||||
|
assert.Equal(t, StepSuccess, m.ui.addWizard.step)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleForwardsRemoved tests forward removal handler
|
||||||
|
func TestHandleForwardsRemoved(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeRemoveWizard
|
||||||
|
ui.removeWizard = &RemoveWizardState{}
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
msg := ForwardsRemovedMsg{success: true, count: 2}
|
||||||
|
m.handleForwardsRemoved(msg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.Nil(t, m.ui.removeWizard)
|
||||||
|
assert.Equal(t, ViewModeMain, m.ui.viewMode)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleBenchmarkProgress tests benchmark progress handler
|
||||||
|
func TestHandleBenchmarkProgress(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeBenchmark
|
||||||
|
ui.benchmarkState = newBenchmarkState("fwd-id", "alias", 8080)
|
||||||
|
ui.benchmarkState.running = true
|
||||||
|
ui.benchmarkState.progressCh = make(chan BenchmarkProgressMsg, 1)
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
msg := BenchmarkProgressMsg{
|
||||||
|
ForwardID: "fwd-id",
|
||||||
|
Completed: 50,
|
||||||
|
Total: 100,
|
||||||
|
}
|
||||||
|
m.handleBenchmarkProgress(msg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.Equal(t, 50, m.ui.benchmarkState.progress)
|
||||||
|
assert.Equal(t, 100, m.ui.benchmarkState.total)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleBenchmarkComplete tests benchmark completion handler
|
||||||
|
func TestHandleBenchmarkComplete(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeBenchmark
|
||||||
|
ui.benchmarkState = newBenchmarkState("fwd-id", "alias", 8080)
|
||||||
|
ui.benchmarkState.running = true
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
// Note: This test documents expected behavior
|
||||||
|
// The actual BenchmarkCompleteMsg requires benchmark.Results which has CalculateStats
|
||||||
|
msg := BenchmarkCompleteMsg{
|
||||||
|
ForwardID: "fwd-id",
|
||||||
|
Error: errors.New("test error"),
|
||||||
|
}
|
||||||
|
m.handleBenchmarkComplete(msg)
|
||||||
|
|
||||||
|
m.ui.mu.RLock()
|
||||||
|
assert.False(t, m.ui.benchmarkState.running)
|
||||||
|
assert.Equal(t, BenchmarkStepResults, m.ui.benchmarkState.step)
|
||||||
|
assert.NotNil(t, m.ui.benchmarkState.error)
|
||||||
|
m.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModel_Update_MessageRouting tests message routing in Update
|
||||||
|
func TestModel_Update_MessageRouting(t *testing.T) {
|
||||||
|
m := newTestModelWithForward()
|
||||||
|
|
||||||
|
// Test window size message
|
||||||
|
sizeMsg := tea.WindowSizeMsg{Width: 100, Height: 50}
|
||||||
|
newModel, _ := m.Update(sizeMsg)
|
||||||
|
updatedModel := newModel.(model)
|
||||||
|
assert.Equal(t, 100, updatedModel.termWidth)
|
||||||
|
assert.Equal(t, 50, updatedModel.termHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModel_Update_ViewModeRouting tests that key messages are routed based on view mode
|
||||||
|
func TestModel_Update_ViewModeRouting(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
viewMode ViewMode
|
||||||
|
}{
|
||||||
|
{"main view", ViewModeMain},
|
||||||
|
{"add wizard", ViewModeAddWizard},
|
||||||
|
{"benchmark", ViewModeBenchmark},
|
||||||
|
{"http log", ViewModeHTTPLog},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = tt.viewMode
|
||||||
|
if tt.viewMode == ViewModeAddWizard {
|
||||||
|
ui.addWizard = newAddWizardState()
|
||||||
|
} else if tt.viewMode == ViewModeBenchmark {
|
||||||
|
ui.benchmarkState = newBenchmarkState("id", "alias", 8080)
|
||||||
|
} else if tt.viewMode == ViewModeHTTPLog {
|
||||||
|
ui.httpLogState = newHTTPLogState("id", "alias")
|
||||||
|
}
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
// Send a key message - should not panic
|
||||||
|
keyMsg := tea.KeyMsg{Type: tea.KeyEsc}
|
||||||
|
_, _ = m.Update(keyMsg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWizardCompleteMsg tests wizard completion message handling
|
||||||
|
func TestWizardCompleteMsg(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeAddWizard
|
||||||
|
ui.addWizard = newAddWizardState()
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
m := model{ui: ui, termWidth: 120, termHeight: 40}
|
||||||
|
|
||||||
|
msg := WizardCompleteMsg{}
|
||||||
|
newModel, _ := m.Update(msg)
|
||||||
|
updatedModel := newModel.(model)
|
||||||
|
|
||||||
|
updatedModel.ui.mu.RLock()
|
||||||
|
assert.Equal(t, ViewModeMain, updatedModel.ui.viewMode)
|
||||||
|
assert.Nil(t, updatedModel.ui.addWizard)
|
||||||
|
updatedModel.ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to check that model implements tea.Model
|
||||||
|
func TestModel_ImplementsTeaModel(t *testing.T) {
|
||||||
|
m := newTestModel()
|
||||||
|
var _ tea.Model = m
|
||||||
|
require.NotNil(t, m)
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewHTTPLogState tests the constructor
|
||||||
|
func TestNewHTTPLogState(t *testing.T) {
|
||||||
|
state := newHTTPLogState("forward-123", "my-service")
|
||||||
|
|
||||||
|
assert.Equal(t, "forward-123", state.forwardID)
|
||||||
|
assert.Equal(t, "my-service", state.forwardAlias)
|
||||||
|
assert.NotNil(t, state.entries)
|
||||||
|
assert.Empty(t, state.entries)
|
||||||
|
assert.True(t, state.autoScroll)
|
||||||
|
assert.Equal(t, HTTPLogFilterNone, state.filterMode)
|
||||||
|
assert.Empty(t, state.filterText)
|
||||||
|
assert.False(t, state.filterActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTTPLogState_GetFilteredEntries_NoFilter tests filtering with no filter
|
||||||
|
func TestHTTPLogState_GetFilteredEntries_NoFilter(t *testing.T) {
|
||||||
|
state := newHTTPLogState("fwd", "alias")
|
||||||
|
state.entries = []HTTPLogEntry{
|
||||||
|
{Method: "GET", Path: "/api/users", StatusCode: 200},
|
||||||
|
{Method: "POST", Path: "/api/orders", StatusCode: 201},
|
||||||
|
{Method: "GET", Path: "/health", StatusCode: 200},
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := state.getFilteredEntries()
|
||||||
|
|
||||||
|
assert.Len(t, filtered, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTTPLogState_GetFilteredEntries_FiltersZeroStatusCode tests that entries without status codes are filtered
|
||||||
|
func TestHTTPLogState_GetFilteredEntries_FiltersZeroStatusCode(t *testing.T) {
|
||||||
|
state := newHTTPLogState("fwd", "alias")
|
||||||
|
state.entries = []HTTPLogEntry{
|
||||||
|
{Method: "GET", Path: "/api/users", StatusCode: 200},
|
||||||
|
{Method: "GET", Path: "/streaming", StatusCode: 0}, // No status (in-progress or error)
|
||||||
|
{Method: "POST", Path: "/api/orders", StatusCode: 201},
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := state.getFilteredEntries()
|
||||||
|
|
||||||
|
assert.Len(t, filtered, 2)
|
||||||
|
assert.Equal(t, "/api/users", filtered[0].Path)
|
||||||
|
assert.Equal(t, "/api/orders", filtered[1].Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTTPLogState_GetFilteredEntries_Non200Filter tests non-2xx filter
|
||||||
|
func TestHTTPLogState_GetFilteredEntries_Non200Filter(t *testing.T) {
|
||||||
|
state := newHTTPLogState("fwd", "alias")
|
||||||
|
state.filterMode = HTTPLogFilterNon200
|
||||||
|
state.entries = []HTTPLogEntry{
|
||||||
|
{Method: "GET", Path: "/api/users", StatusCode: 200},
|
||||||
|
{Method: "GET", Path: "/api/error", StatusCode: 500},
|
||||||
|
{Method: "POST", Path: "/api/orders", StatusCode: 201},
|
||||||
|
{Method: "GET", Path: "/api/notfound", StatusCode: 404},
|
||||||
|
{Method: "PUT", Path: "/api/redirect", StatusCode: 301},
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := state.getFilteredEntries()
|
||||||
|
|
||||||
|
assert.Len(t, filtered, 3)
|
||||||
|
assert.Equal(t, 500, filtered[0].StatusCode)
|
||||||
|
assert.Equal(t, 404, filtered[1].StatusCode)
|
||||||
|
assert.Equal(t, 301, filtered[2].StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTTPLogState_GetFilteredEntries_ErrorsFilter tests 4xx/5xx filter
|
||||||
|
func TestHTTPLogState_GetFilteredEntries_ErrorsFilter(t *testing.T) {
|
||||||
|
state := newHTTPLogState("fwd", "alias")
|
||||||
|
state.filterMode = HTTPLogFilterErrors
|
||||||
|
state.entries = []HTTPLogEntry{
|
||||||
|
{Method: "GET", Path: "/api/users", StatusCode: 200},
|
||||||
|
{Method: "GET", Path: "/api/error", StatusCode: 500},
|
||||||
|
{Method: "POST", Path: "/api/orders", StatusCode: 201},
|
||||||
|
{Method: "GET", Path: "/api/notfound", StatusCode: 404},
|
||||||
|
{Method: "PUT", Path: "/api/redirect", StatusCode: 301},
|
||||||
|
{Method: "GET", Path: "/api/bad", StatusCode: 400},
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := state.getFilteredEntries()
|
||||||
|
|
||||||
|
assert.Len(t, filtered, 3)
|
||||||
|
assert.Equal(t, 500, filtered[0].StatusCode)
|
||||||
|
assert.Equal(t, 404, filtered[1].StatusCode)
|
||||||
|
assert.Equal(t, 400, filtered[2].StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTTPLogState_GetFilteredEntries_TextFilter tests text filtering
|
||||||
|
func TestHTTPLogState_GetFilteredEntries_TextFilter(t *testing.T) {
|
||||||
|
state := newHTTPLogState("fwd", "alias")
|
||||||
|
state.filterText = "users"
|
||||||
|
state.entries = []HTTPLogEntry{
|
||||||
|
{Method: "GET", Path: "/api/users", StatusCode: 200},
|
||||||
|
{Method: "GET", Path: "/api/users/123", StatusCode: 200},
|
||||||
|
{Method: "POST", Path: "/api/orders", StatusCode: 201},
|
||||||
|
{Method: "GET", Path: "/health", StatusCode: 200},
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := state.getFilteredEntries()
|
||||||
|
|
||||||
|
assert.Len(t, filtered, 2)
|
||||||
|
assert.Equal(t, "/api/users", filtered[0].Path)
|
||||||
|
assert.Equal(t, "/api/users/123", filtered[1].Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTTPLogState_GetFilteredEntries_TextFilterCaseInsensitive tests case-insensitive text filtering
|
||||||
|
func TestHTTPLogState_GetFilteredEntries_TextFilterCaseInsensitive(t *testing.T) {
|
||||||
|
state := newHTTPLogState("fwd", "alias")
|
||||||
|
state.filterText = "API"
|
||||||
|
state.entries = []HTTPLogEntry{
|
||||||
|
{Method: "GET", Path: "/api/users", StatusCode: 200},
|
||||||
|
{Method: "GET", Path: "/Api/Orders", StatusCode: 200},
|
||||||
|
{Method: "GET", Path: "/health", StatusCode: 200},
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := state.getFilteredEntries()
|
||||||
|
|
||||||
|
assert.Len(t, filtered, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTTPLogState_GetFilteredEntries_TextFilterByMethod tests filtering by HTTP method
|
||||||
|
func TestHTTPLogState_GetFilteredEntries_TextFilterByMethod(t *testing.T) {
|
||||||
|
state := newHTTPLogState("fwd", "alias")
|
||||||
|
state.filterText = "POST"
|
||||||
|
state.entries = []HTTPLogEntry{
|
||||||
|
{Method: "GET", Path: "/api/users", StatusCode: 200},
|
||||||
|
{Method: "POST", Path: "/api/orders", StatusCode: 201},
|
||||||
|
{Method: "POST", Path: "/api/items", StatusCode: 201},
|
||||||
|
{Method: "PUT", Path: "/api/update", StatusCode: 200},
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := state.getFilteredEntries()
|
||||||
|
|
||||||
|
assert.Len(t, filtered, 2)
|
||||||
|
assert.Equal(t, "POST", filtered[0].Method)
|
||||||
|
assert.Equal(t, "POST", filtered[1].Method)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTTPLogState_GetFilteredEntries_CombinedFilters tests combining mode and text filters
|
||||||
|
func TestHTTPLogState_GetFilteredEntries_CombinedFilters(t *testing.T) {
|
||||||
|
state := newHTTPLogState("fwd", "alias")
|
||||||
|
state.filterMode = HTTPLogFilterErrors
|
||||||
|
state.filterText = "api"
|
||||||
|
state.entries = []HTTPLogEntry{
|
||||||
|
{Method: "GET", Path: "/api/users", StatusCode: 200},
|
||||||
|
{Method: "GET", Path: "/api/error", StatusCode: 500},
|
||||||
|
{Method: "GET", Path: "/health", StatusCode: 500}, // Error but doesn't match text
|
||||||
|
{Method: "GET", Path: "/api/notfound", StatusCode: 404},
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := state.getFilteredEntries()
|
||||||
|
|
||||||
|
assert.Len(t, filtered, 2)
|
||||||
|
assert.Equal(t, "/api/error", filtered[0].Path)
|
||||||
|
assert.Equal(t, "/api/notfound", filtered[1].Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTTPLogState_GetFilteredEntries_EmptyResult tests when no entries match
|
||||||
|
func TestHTTPLogState_GetFilteredEntries_EmptyResult(t *testing.T) {
|
||||||
|
state := newHTTPLogState("fwd", "alias")
|
||||||
|
state.filterText = "nonexistent"
|
||||||
|
state.entries = []HTTPLogEntry{
|
||||||
|
{Method: "GET", Path: "/api/users", StatusCode: 200},
|
||||||
|
{Method: "POST", Path: "/api/orders", StatusCode: 201},
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := state.getFilteredEntries()
|
||||||
|
|
||||||
|
assert.Empty(t, filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTTPLogState_GetFilterModeLabel tests filter mode labels
|
||||||
|
func TestHTTPLogState_GetFilterModeLabel(t *testing.T) {
|
||||||
|
state := newHTTPLogState("fwd", "alias")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
mode HTTPLogFilterMode
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{HTTPLogFilterNone, "All"},
|
||||||
|
{HTTPLogFilterText, "Text"},
|
||||||
|
{HTTPLogFilterNon200, "Non-2xx"},
|
||||||
|
{HTTPLogFilterErrors, "Errors (4xx/5xx)"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
state.filterMode = tt.mode
|
||||||
|
assert.Equal(t, tt.expected, state.getFilterModeLabel())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTTPLogState_FilterModeValues tests filter mode constants are correct
|
||||||
|
func TestHTTPLogState_FilterModeValues(t *testing.T) {
|
||||||
|
// Ensure the modes are sequential for cycling to work correctly
|
||||||
|
assert.Equal(t, HTTPLogFilterMode(0), HTTPLogFilterNone)
|
||||||
|
assert.Equal(t, HTTPLogFilterMode(1), HTTPLogFilterText)
|
||||||
|
assert.Equal(t, HTTPLogFilterMode(2), HTTPLogFilterNon200)
|
||||||
|
assert.Equal(t, HTTPLogFilterMode(3), HTTPLogFilterErrors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTTPLogState_LargeEntrySet tests filtering performance with many entries
|
||||||
|
func TestHTTPLogState_LargeEntrySet(t *testing.T) {
|
||||||
|
state := newHTTPLogState("fwd", "alias")
|
||||||
|
|
||||||
|
// Add 1000 entries
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
code := 200
|
||||||
|
if i%10 == 0 {
|
||||||
|
code = 500
|
||||||
|
}
|
||||||
|
state.entries = append(state.entries, HTTPLogEntry{
|
||||||
|
Method: "GET",
|
||||||
|
Path: "/api/test",
|
||||||
|
StatusCode: code,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter should work correctly
|
||||||
|
state.filterMode = HTTPLogFilterErrors
|
||||||
|
filtered := state.getFilteredEntries()
|
||||||
|
|
||||||
|
assert.Len(t, filtered, 100) // 10% are errors
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/nvm/kportal/internal/config"
|
||||||
|
"github.com/nvm/kportal/internal/k8s"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DiscoveryInterface defines the interface for Kubernetes discovery operations
|
||||||
|
// This allows for mocking in tests
|
||||||
|
type DiscoveryInterface interface {
|
||||||
|
ListContexts() ([]string, error)
|
||||||
|
GetCurrentContext() (string, error)
|
||||||
|
ListNamespaces(ctx context.Context, contextName string) ([]string, error)
|
||||||
|
ListPods(ctx context.Context, contextName, namespace string) ([]k8s.PodInfo, error)
|
||||||
|
ListPodsWithSelector(ctx context.Context, contextName, namespace, selector string) ([]k8s.PodInfo, error)
|
||||||
|
ListServices(ctx context.Context, contextName, namespace string) ([]k8s.ServiceInfo, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MutatorInterface defines the interface for configuration mutation operations
|
||||||
|
// This allows for mocking in tests
|
||||||
|
type MutatorInterface interface {
|
||||||
|
AddForward(contextName, namespaceName string, fwd config.Forward) error
|
||||||
|
RemoveForwards(predicate func(ctx, ns string, fwd config.Forward) bool) error
|
||||||
|
RemoveForwardByID(id string) error
|
||||||
|
UpdateForward(oldID, newContextName, newNamespaceName string, newFwd config.Forward) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time checks to ensure real types implement interfaces
|
||||||
|
var _ DiscoveryInterface = (*k8s.Discovery)(nil)
|
||||||
|
var _ MutatorInterface = (*config.Mutator)(nil)
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/nvm/kportal/internal/config"
|
||||||
|
"github.com/nvm/kportal/internal/k8s"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockDiscovery is a mock implementation of DiscoveryInterface for testing
|
||||||
|
type MockDiscovery struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
// Return values
|
||||||
|
Contexts []string
|
||||||
|
CurrentContext string
|
||||||
|
Namespaces []string
|
||||||
|
Pods []k8s.PodInfo
|
||||||
|
PodsWithSelector []k8s.PodInfo
|
||||||
|
Services []k8s.ServiceInfo
|
||||||
|
|
||||||
|
// Errors to return
|
||||||
|
ListContextsErr error
|
||||||
|
GetCurrentContextErr error
|
||||||
|
ListNamespacesErr error
|
||||||
|
ListPodsErr error
|
||||||
|
ListPodsWithSelectorErr error
|
||||||
|
ListServicesErr error
|
||||||
|
|
||||||
|
// Call tracking
|
||||||
|
ListContextsCalls int
|
||||||
|
GetCurrentContextCalls int
|
||||||
|
ListNamespacesCalls int
|
||||||
|
ListPodsCalls int
|
||||||
|
ListPodsWithSelectorCalls int
|
||||||
|
ListServicesCalls int
|
||||||
|
|
||||||
|
// Captured arguments
|
||||||
|
LastContextName string
|
||||||
|
LastNamespace string
|
||||||
|
LastSelector string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMockDiscovery() *MockDiscovery {
|
||||||
|
return &MockDiscovery{
|
||||||
|
Contexts: []string{"default", "production", "staging"},
|
||||||
|
Namespaces: []string{"default", "kube-system"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockDiscovery) ListContexts() ([]string, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.ListContextsCalls++
|
||||||
|
return m.Contexts, m.ListContextsErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockDiscovery) GetCurrentContext() (string, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.GetCurrentContextCalls++
|
||||||
|
if m.CurrentContext == "" {
|
||||||
|
return "default", m.GetCurrentContextErr
|
||||||
|
}
|
||||||
|
return m.CurrentContext, m.GetCurrentContextErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockDiscovery) ListNamespaces(ctx context.Context, contextName string) ([]string, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.ListNamespacesCalls++
|
||||||
|
m.LastContextName = contextName
|
||||||
|
return m.Namespaces, m.ListNamespacesErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockDiscovery) ListPods(ctx context.Context, contextName, namespace string) ([]k8s.PodInfo, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.ListPodsCalls++
|
||||||
|
m.LastContextName = contextName
|
||||||
|
m.LastNamespace = namespace
|
||||||
|
return m.Pods, m.ListPodsErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockDiscovery) ListPodsWithSelector(ctx context.Context, contextName, namespace, selector string) ([]k8s.PodInfo, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.ListPodsWithSelectorCalls++
|
||||||
|
m.LastContextName = contextName
|
||||||
|
m.LastNamespace = namespace
|
||||||
|
m.LastSelector = selector
|
||||||
|
return m.PodsWithSelector, m.ListPodsWithSelectorErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockDiscovery) ListServices(ctx context.Context, contextName, namespace string) ([]k8s.ServiceInfo, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.ListServicesCalls++
|
||||||
|
m.LastContextName = contextName
|
||||||
|
m.LastNamespace = namespace
|
||||||
|
return m.Services, m.ListServicesErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockMutator is a mock implementation of MutatorInterface for testing
|
||||||
|
type MockMutator struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
// Errors to return
|
||||||
|
AddForwardErr error
|
||||||
|
RemoveForwardsErr error
|
||||||
|
RemoveForwardByIDErr error
|
||||||
|
UpdateForwardErr error
|
||||||
|
|
||||||
|
// Call tracking
|
||||||
|
AddForwardCalls int
|
||||||
|
RemoveForwardsCalls int
|
||||||
|
RemoveForwardByIDCalls int
|
||||||
|
UpdateForwardCalls int
|
||||||
|
|
||||||
|
// Captured arguments
|
||||||
|
LastContextName string
|
||||||
|
LastNamespaceName string
|
||||||
|
LastForward config.Forward
|
||||||
|
LastOldID string
|
||||||
|
LastRemovedID string
|
||||||
|
LastPredicate func(ctx, ns string, fwd config.Forward) bool
|
||||||
|
|
||||||
|
// Storage for testing
|
||||||
|
Forwards []struct {
|
||||||
|
Context string
|
||||||
|
Namespace string
|
||||||
|
Forward config.Forward
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMockMutator() *MockMutator {
|
||||||
|
return &MockMutator{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockMutator) AddForward(contextName, namespaceName string, fwd config.Forward) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.AddForwardCalls++
|
||||||
|
m.LastContextName = contextName
|
||||||
|
m.LastNamespaceName = namespaceName
|
||||||
|
m.LastForward = fwd
|
||||||
|
|
||||||
|
if m.AddForwardErr == nil {
|
||||||
|
m.Forwards = append(m.Forwards, struct {
|
||||||
|
Context string
|
||||||
|
Namespace string
|
||||||
|
Forward config.Forward
|
||||||
|
}{contextName, namespaceName, fwd})
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.AddForwardErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockMutator) RemoveForwards(predicate func(ctx, ns string, fwd config.Forward) bool) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.RemoveForwardsCalls++
|
||||||
|
m.LastPredicate = predicate
|
||||||
|
return m.RemoveForwardsErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockMutator) RemoveForwardByID(id string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.RemoveForwardByIDCalls++
|
||||||
|
m.LastRemovedID = id
|
||||||
|
return m.RemoveForwardByIDErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockMutator) UpdateForward(oldID, newContextName, newNamespaceName string, newFwd config.Forward) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.UpdateForwardCalls++
|
||||||
|
m.LastOldID = oldID
|
||||||
|
m.LastContextName = newContextName
|
||||||
|
m.LastNamespaceName = newNamespaceName
|
||||||
|
m.LastForward = newFwd
|
||||||
|
return m.UpdateForwardErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockHTTPLogSubscriber is a mock for HTTP log subscription
|
||||||
|
type MockHTTPLogSubscriber struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
// Subscription tracking
|
||||||
|
Subscriptions map[string]func(HTTPLogEntry)
|
||||||
|
CleanupCalls int
|
||||||
|
|
||||||
|
// Control
|
||||||
|
ShouldFail bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMockHTTPLogSubscriber() *MockHTTPLogSubscriber {
|
||||||
|
return &MockHTTPLogSubscriber{
|
||||||
|
Subscriptions: make(map[string]func(HTTPLogEntry)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe returns a cleanup function
|
||||||
|
func (m *MockHTTPLogSubscriber) Subscribe(forwardID string, callback func(HTTPLogEntry)) func() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
m.Subscriptions[forwardID] = callback
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.CleanupCalls++
|
||||||
|
delete(m.Subscriptions, forwardID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendEntry sends an entry to a subscribed callback (for testing)
|
||||||
|
func (m *MockHTTPLogSubscriber) SendEntry(forwardID string, entry HTTPLogEntry) {
|
||||||
|
m.mu.Lock()
|
||||||
|
callback, exists := m.Subscriptions[forwardID]
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if exists && callback != nil {
|
||||||
|
callback(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSubscriberFunc returns the function signature expected by the UI
|
||||||
|
func (m *MockHTTPLogSubscriber) GetSubscriberFunc() HTTPLogSubscriber {
|
||||||
|
return func(forwardID string, callback func(entry HTTPLogEntry)) func() {
|
||||||
|
return m.Subscribe(forwardID, callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockToggleCallback tracks toggle callback invocations
|
||||||
|
type MockToggleCallback struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
Calls []struct {
|
||||||
|
ID string
|
||||||
|
Enable bool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMockToggleCallback() *MockToggleCallback {
|
||||||
|
return &MockToggleCallback{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockToggleCallback) Callback(id string, enable bool) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.Calls = append(m.Calls, struct {
|
||||||
|
ID string
|
||||||
|
Enable bool
|
||||||
|
}{id, enable})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockToggleCallback) GetFunc() func(string, bool) {
|
||||||
|
return m.Callback
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockToggleCallback) CallCount() int {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return len(m.Calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockToggleCallback) LastCall() (string, bool, bool) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if len(m.Calls) == 0 {
|
||||||
|
return "", false, false
|
||||||
|
}
|
||||||
|
last := m.Calls[len(m.Calls)-1]
|
||||||
|
return last.ID, last.Enable, true
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/nvm/kportal/internal/benchmark"
|
||||||
"github.com/nvm/kportal/internal/config"
|
"github.com/nvm/kportal/internal/config"
|
||||||
"github.com/nvm/kportal/internal/k8s"
|
"github.com/nvm/kportal/internal/k8s"
|
||||||
)
|
)
|
||||||
@@ -144,8 +145,25 @@ func validateSelectorCmd(discovery *k8s.Discovery, contextName, namespace, selec
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkPortCmd checks if a local port is available
|
// 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 {
|
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)
|
available, processInfo, err := k8s.CheckPortAvailability(port)
|
||||||
|
|
||||||
msg := ""
|
msg := ""
|
||||||
@@ -220,3 +238,97 @@ func removeForwardByIDCmd(mutator *config.Mutator, id string) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BenchmarkCompleteMsg is sent when a benchmark run completes
|
||||||
|
type BenchmarkCompleteMsg struct {
|
||||||
|
ForwardID string
|
||||||
|
Results *benchmark.Results
|
||||||
|
Error error
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkProgressMsg is sent periodically during benchmark execution
|
||||||
|
type BenchmarkProgressMsg struct {
|
||||||
|
ForwardID string
|
||||||
|
Completed int
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPLogEntryMsg is sent when a new HTTP log entry is received
|
||||||
|
type HTTPLogEntryMsg struct {
|
||||||
|
Entry HTTPLogEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearCopyMessageMsg is sent to clear the copy confirmation message
|
||||||
|
type clearCopyMessageMsg struct{}
|
||||||
|
|
||||||
|
// listenBenchmarkProgressCmd listens for progress updates from the benchmark
|
||||||
|
func listenBenchmarkProgressCmd(progressCh <-chan BenchmarkProgressMsg) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
msg, ok := <-progressCh
|
||||||
|
if !ok {
|
||||||
|
// Channel closed, benchmark complete
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runBenchmarkCmd runs a benchmark against the given port forward
|
||||||
|
// It sends progress updates via tea.Batch until completion
|
||||||
|
// The ctx parameter allows the benchmark to be cancelled from outside
|
||||||
|
func runBenchmarkCmd(ctx context.Context, forwardID string, localPort int, urlPath, method string, concurrency, requests int, progressCh chan<- BenchmarkProgressMsg) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
runner := benchmark.NewRunner()
|
||||||
|
|
||||||
|
url := fmt.Sprintf("http://localhost:%d%s", localPort, urlPath)
|
||||||
|
cfg := benchmark.Config{
|
||||||
|
URL: url,
|
||||||
|
Method: method,
|
||||||
|
Concurrency: concurrency,
|
||||||
|
Requests: requests,
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
ProgressCallback: func(completed, total int) {
|
||||||
|
// Recover from panics in the callback
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
// Silently recover - progress callback failure shouldn't crash the benchmark
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// Non-blocking send to progress channel
|
||||||
|
select {
|
||||||
|
case progressCh <- BenchmarkProgressMsg{
|
||||||
|
ForwardID: forwardID,
|
||||||
|
Completed: completed,
|
||||||
|
Total: total,
|
||||||
|
}:
|
||||||
|
default:
|
||||||
|
// Drop if channel is full
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the provided context with a timeout as a safety limit
|
||||||
|
benchCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
results, err := runner.Run(benchCtx, forwardID, cfg)
|
||||||
|
|
||||||
|
// Close the progress channel when done
|
||||||
|
close(progressCh)
|
||||||
|
|
||||||
|
// Check if cancelled
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return BenchmarkCompleteMsg{
|
||||||
|
ForwardID: forwardID,
|
||||||
|
Results: nil,
|
||||||
|
Error: fmt.Errorf("benchmark cancelled"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return BenchmarkCompleteMsg{
|
||||||
|
ForwardID: forwardID,
|
||||||
|
Results: results,
|
||||||
|
Error: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nvm/kportal/internal/config"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWizardMutualExclusion_AddWizardBlocksOthers tests that having an add wizard active blocks other modals
|
||||||
|
func TestWizardMutualExclusion_AddWizardBlocksOthers(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
// Add a forward so we have something to select
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
}
|
||||||
|
ui.AddForward("test-id", fwd)
|
||||||
|
|
||||||
|
// Activate add wizard
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeAddWizard
|
||||||
|
ui.addWizard = newAddWizardState()
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
// Verify state
|
||||||
|
ui.mu.RLock()
|
||||||
|
assert.NotNil(t, ui.addWizard)
|
||||||
|
assert.Equal(t, ViewModeAddWizard, ui.viewMode)
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
|
||||||
|
// Check that other modals cannot be activated when add wizard is active
|
||||||
|
// This is enforced in the handlers, not in state - we're testing the state setup
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWizardMutualExclusion_BenchmarkBlocksOthers tests that having benchmark active blocks other modals
|
||||||
|
func TestWizardMutualExclusion_BenchmarkBlocksOthers(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
// Add a forward
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
}
|
||||||
|
ui.AddForward("test-id", fwd)
|
||||||
|
|
||||||
|
// Activate benchmark
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeBenchmark
|
||||||
|
ui.benchmarkState = newBenchmarkState("test-id", "my-app", 8080)
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
assert.NotNil(t, ui.benchmarkState)
|
||||||
|
assert.Equal(t, ViewModeBenchmark, ui.viewMode)
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWizardMutualExclusion_HTTPLogBlocksOthers tests that having HTTP log view active blocks other modals
|
||||||
|
func TestWizardMutualExclusion_HTTPLogBlocksOthers(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
// Add a forward
|
||||||
|
fwd := &config.Forward{
|
||||||
|
Resource: "pod/my-app",
|
||||||
|
Port: 8080,
|
||||||
|
LocalPort: 8080,
|
||||||
|
}
|
||||||
|
ui.AddForward("test-id", fwd)
|
||||||
|
|
||||||
|
// Activate HTTP log view
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeHTTPLog
|
||||||
|
ui.httpLogState = newHTTPLogState("test-id", "my-app")
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
assert.NotNil(t, ui.httpLogState)
|
||||||
|
assert.Equal(t, ViewModeHTTPLog, ui.viewMode)
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWizardMutualExclusion_CheckActiveModal tests the modal activity check logic
|
||||||
|
func TestWizardMutualExclusion_CheckActiveModal(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setupFunc func(*BubbleTeaUI)
|
||||||
|
expectActive bool
|
||||||
|
activeModalStr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no modal active",
|
||||||
|
setupFunc: func(ui *BubbleTeaUI) {},
|
||||||
|
expectActive: false,
|
||||||
|
activeModalStr: "none",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add wizard active",
|
||||||
|
setupFunc: func(ui *BubbleTeaUI) {
|
||||||
|
ui.addWizard = newAddWizardState()
|
||||||
|
},
|
||||||
|
expectActive: true,
|
||||||
|
activeModalStr: "addWizard",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove wizard active",
|
||||||
|
setupFunc: func(ui *BubbleTeaUI) {
|
||||||
|
ui.removeWizard = &RemoveWizardState{}
|
||||||
|
},
|
||||||
|
expectActive: true,
|
||||||
|
activeModalStr: "removeWizard",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "benchmark active",
|
||||||
|
setupFunc: func(ui *BubbleTeaUI) {
|
||||||
|
ui.benchmarkState = newBenchmarkState("id", "alias", 8080)
|
||||||
|
},
|
||||||
|
expectActive: true,
|
||||||
|
activeModalStr: "benchmark",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "http log active",
|
||||||
|
setupFunc: func(ui *BubbleTeaUI) {
|
||||||
|
ui.httpLogState = newHTTPLogState("id", "alias")
|
||||||
|
},
|
||||||
|
expectActive: true,
|
||||||
|
activeModalStr: "httpLog",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
ui.mu.Lock()
|
||||||
|
tt.setupFunc(ui)
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
hasActiveModal := ui.addWizard != nil ||
|
||||||
|
ui.removeWizard != nil ||
|
||||||
|
ui.benchmarkState != nil ||
|
||||||
|
ui.httpLogState != nil
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expectActive, hasActiveModal, "Modal activity check failed for: %s", tt.activeModalStr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWizardCleanup_AddWizardReset tests that add wizard state is properly cleaned up
|
||||||
|
func TestWizardCleanup_AddWizardReset(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
// Set up wizard with various state
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeAddWizard
|
||||||
|
ui.addWizard = newAddWizardState()
|
||||||
|
ui.addWizard.step = StepSelectNamespace
|
||||||
|
ui.addWizard.selectedContext = "prod"
|
||||||
|
ui.addWizard.contexts = []string{"prod", "staging"}
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
// Simulate cleanup (like pressing Esc)
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeMain
|
||||||
|
ui.addWizard = nil
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
assert.Nil(t, ui.addWizard)
|
||||||
|
assert.Equal(t, ViewModeMain, ui.viewMode)
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWizardCleanup_BenchmarkReset tests that benchmark state is properly cleaned up
|
||||||
|
func TestWizardCleanup_BenchmarkReset(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
cancelled := false
|
||||||
|
|
||||||
|
// Set up benchmark with cancel function
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeBenchmark
|
||||||
|
ui.benchmarkState = newBenchmarkState("id", "alias", 8080)
|
||||||
|
ui.benchmarkState.running = true
|
||||||
|
ui.benchmarkState.cancelFunc = func() { cancelled = true }
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
// Simulate cleanup with cancel
|
||||||
|
ui.mu.Lock()
|
||||||
|
if ui.benchmarkState.cancelFunc != nil {
|
||||||
|
ui.benchmarkState.cancelFunc()
|
||||||
|
}
|
||||||
|
ui.viewMode = ViewModeMain
|
||||||
|
ui.benchmarkState = nil
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
assert.True(t, cancelled, "Cancel function should have been called")
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
assert.Nil(t, ui.benchmarkState)
|
||||||
|
assert.Equal(t, ViewModeMain, ui.viewMode)
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWizardCleanup_HTTPLogReset tests that HTTP log state is properly cleaned up
|
||||||
|
func TestWizardCleanup_HTTPLogReset(t *testing.T) {
|
||||||
|
ui := NewBubbleTeaUI(nil, "1.0.0")
|
||||||
|
|
||||||
|
cleanupCalled := false
|
||||||
|
|
||||||
|
// Set up HTTP log with cleanup function
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.viewMode = ViewModeHTTPLog
|
||||||
|
ui.httpLogState = newHTTPLogState("id", "alias")
|
||||||
|
ui.httpLogState.entries = []HTTPLogEntry{{Method: "GET", Path: "/"}}
|
||||||
|
ui.httpLogCleanup = func() { cleanupCalled = true }
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
// Simulate cleanup
|
||||||
|
ui.mu.Lock()
|
||||||
|
if ui.httpLogCleanup != nil {
|
||||||
|
ui.httpLogCleanup()
|
||||||
|
ui.httpLogCleanup = nil
|
||||||
|
}
|
||||||
|
ui.viewMode = ViewModeMain
|
||||||
|
ui.httpLogState = nil
|
||||||
|
ui.mu.Unlock()
|
||||||
|
|
||||||
|
assert.True(t, cleanupCalled, "Cleanup function should have been called")
|
||||||
|
|
||||||
|
ui.mu.RLock()
|
||||||
|
assert.Nil(t, ui.httpLogState)
|
||||||
|
assert.Nil(t, ui.httpLogCleanup)
|
||||||
|
assert.Equal(t, ViewModeMain, ui.viewMode)
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestViewModeValues tests view mode constants
|
||||||
|
func TestViewModeValues(t *testing.T) {
|
||||||
|
assert.Equal(t, ViewMode(0), ViewModeMain)
|
||||||
|
assert.Equal(t, ViewMode(1), ViewModeAddWizard)
|
||||||
|
assert.Equal(t, ViewMode(2), ViewModeRemoveWizard)
|
||||||
|
assert.Equal(t, ViewMode(3), ViewModeBenchmark)
|
||||||
|
assert.Equal(t, ViewMode(4), ViewModeHTTPLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoveWizardState_Selection tests remove wizard selection logic
|
||||||
|
func TestRemoveWizardState_Selection(t *testing.T) {
|
||||||
|
wizard := &RemoveWizardState{
|
||||||
|
forwards: []RemovableForward{
|
||||||
|
{ID: "a", Alias: "app-a"},
|
||||||
|
{ID: "b", Alias: "app-b"},
|
||||||
|
{ID: "c", Alias: "app-c"},
|
||||||
|
},
|
||||||
|
selected: make(map[int]bool),
|
||||||
|
cursor: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle selection
|
||||||
|
wizard.toggleSelection()
|
||||||
|
assert.True(t, wizard.selected[0])
|
||||||
|
|
||||||
|
// Move and toggle
|
||||||
|
wizard.moveCursor(1)
|
||||||
|
wizard.toggleSelection()
|
||||||
|
assert.True(t, wizard.selected[1])
|
||||||
|
|
||||||
|
// Check selected count
|
||||||
|
assert.Equal(t, 2, wizard.getSelectedCount())
|
||||||
|
|
||||||
|
// Get selected forwards
|
||||||
|
selected := wizard.getSelectedForwards()
|
||||||
|
assert.Len(t, selected, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoveWizardState_SelectAll tests select all functionality
|
||||||
|
func TestRemoveWizardState_SelectAll(t *testing.T) {
|
||||||
|
wizard := &RemoveWizardState{
|
||||||
|
forwards: []RemovableForward{
|
||||||
|
{ID: "a"},
|
||||||
|
{ID: "b"},
|
||||||
|
{ID: "c"},
|
||||||
|
},
|
||||||
|
selected: make(map[int]bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
wizard.selectAll()
|
||||||
|
|
||||||
|
assert.Equal(t, 3, wizard.getSelectedCount())
|
||||||
|
assert.True(t, wizard.selected[0])
|
||||||
|
assert.True(t, wizard.selected[1])
|
||||||
|
assert.True(t, wizard.selected[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoveWizardState_SelectNone tests deselect all functionality
|
||||||
|
func TestRemoveWizardState_SelectNone(t *testing.T) {
|
||||||
|
wizard := &RemoveWizardState{
|
||||||
|
forwards: []RemovableForward{
|
||||||
|
{ID: "a"},
|
||||||
|
{ID: "b"},
|
||||||
|
{ID: "c"},
|
||||||
|
},
|
||||||
|
selected: map[int]bool{0: true, 1: true, 2: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
wizard.selectNone()
|
||||||
|
|
||||||
|
assert.Equal(t, 0, wizard.getSelectedCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoveWizardState_MoveCursor tests cursor movement in remove wizard
|
||||||
|
func TestRemoveWizardState_MoveCursor(t *testing.T) {
|
||||||
|
wizard := &RemoveWizardState{
|
||||||
|
forwards: []RemovableForward{
|
||||||
|
{ID: "a"},
|
||||||
|
{ID: "b"},
|
||||||
|
{ID: "c"},
|
||||||
|
},
|
||||||
|
selected: make(map[int]bool),
|
||||||
|
cursor: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move down
|
||||||
|
wizard.moveCursor(1)
|
||||||
|
assert.Equal(t, 1, wizard.cursor)
|
||||||
|
|
||||||
|
// Move down again
|
||||||
|
wizard.moveCursor(1)
|
||||||
|
assert.Equal(t, 2, wizard.cursor)
|
||||||
|
|
||||||
|
// Cannot go past end
|
||||||
|
wizard.moveCursor(1)
|
||||||
|
assert.Equal(t, 2, wizard.cursor)
|
||||||
|
|
||||||
|
// Move up
|
||||||
|
wizard.moveCursor(-1)
|
||||||
|
assert.Equal(t, 1, wizard.cursor)
|
||||||
|
|
||||||
|
// Cannot go below 0
|
||||||
|
wizard.moveCursor(-10)
|
||||||
|
assert.Equal(t, 0, wizard.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoveWizardState_ConfirmationMode tests confirmation mode cursor
|
||||||
|
func TestRemoveWizardState_ConfirmationMode(t *testing.T) {
|
||||||
|
wizard := &RemoveWizardState{
|
||||||
|
forwards: []RemovableForward{{ID: "a"}},
|
||||||
|
selected: map[int]bool{0: true},
|
||||||
|
confirming: true,
|
||||||
|
confirmCursor: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// In confirmation mode, cursor moves between Yes/No
|
||||||
|
wizard.moveCursor(1)
|
||||||
|
assert.Equal(t, 1, wizard.confirmCursor)
|
||||||
|
|
||||||
|
// Cannot go past 1
|
||||||
|
wizard.moveCursor(1)
|
||||||
|
assert.Equal(t, 1, wizard.confirmCursor)
|
||||||
|
|
||||||
|
// Move back
|
||||||
|
wizard.moveCursor(-1)
|
||||||
|
assert.Equal(t, 0, wizard.confirmCursor)
|
||||||
|
|
||||||
|
// Cannot go below 0
|
||||||
|
wizard.moveCursor(-1)
|
||||||
|
assert.Equal(t, 0, wizard.confirmCursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoveWizardState_ToggleInConfirmationMode tests that toggle is disabled in confirmation mode
|
||||||
|
func TestRemoveWizardState_ToggleInConfirmationMode(t *testing.T) {
|
||||||
|
wizard := &RemoveWizardState{
|
||||||
|
forwards: []RemovableForward{{ID: "a"}},
|
||||||
|
selected: make(map[int]bool),
|
||||||
|
confirming: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle should be no-op in confirmation mode
|
||||||
|
wizard.toggleSelection()
|
||||||
|
assert.Equal(t, 0, wizard.getSelectedCount())
|
||||||
|
}
|
||||||
+694
-43
@@ -1,9 +1,13 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/nvm/kportal/internal/config"
|
"github.com/nvm/kportal/internal/config"
|
||||||
@@ -40,11 +44,22 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "down", "j":
|
case "down", "j":
|
||||||
m.ui.moveSelection(1)
|
m.ui.moveSelection(1)
|
||||||
|
|
||||||
|
case "pgup", "ctrl+u":
|
||||||
|
m.ui.moveSelection(-10)
|
||||||
|
|
||||||
|
case "pgdown", "ctrl+d":
|
||||||
|
m.ui.moveSelection(10)
|
||||||
|
|
||||||
case " ", "enter":
|
case " ", "enter":
|
||||||
m.ui.toggleSelected()
|
m.ui.toggleSelected()
|
||||||
|
|
||||||
case "n": // Enter add wizard
|
case "n": // Enter add wizard
|
||||||
m.ui.mu.Lock()
|
m.ui.mu.Lock()
|
||||||
|
// Don't create a new wizard if one is already active
|
||||||
|
if m.ui.addWizard != nil || m.ui.removeWizard != nil || m.ui.benchmarkState != nil || m.ui.httpLogState != nil {
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
if m.ui.discovery == nil || m.ui.mutator == nil {
|
if m.ui.discovery == nil || m.ui.mutator == nil {
|
||||||
// Dependencies not set up
|
// Dependencies not set up
|
||||||
m.ui.mu.Unlock()
|
m.ui.mu.Unlock()
|
||||||
@@ -61,6 +76,11 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case "e": // Edit selected forward
|
case "e": // Edit selected forward
|
||||||
m.ui.mu.Lock()
|
m.ui.mu.Lock()
|
||||||
|
// Don't create a new wizard if one is already active
|
||||||
|
if m.ui.addWizard != nil || m.ui.removeWizard != nil || m.ui.benchmarkState != nil || m.ui.httpLogState != nil {
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
if len(m.ui.forwardOrder) == 0 {
|
if len(m.ui.forwardOrder) == 0 {
|
||||||
// No forwards to edit
|
// No forwards to edit
|
||||||
@@ -127,6 +147,12 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "d": // Delete currently selected forward - show confirmation
|
case "d": // Delete currently selected forward - show confirmation
|
||||||
m.ui.mu.Lock()
|
m.ui.mu.Lock()
|
||||||
|
|
||||||
|
// Don't overwrite existing confirmation dialog
|
||||||
|
if m.ui.deleteConfirming {
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
if len(m.ui.forwardOrder) == 0 {
|
if len(m.ui.forwardOrder) == 0 {
|
||||||
// No forwards to delete
|
// No forwards to delete
|
||||||
m.ui.mu.Unlock()
|
m.ui.mu.Unlock()
|
||||||
@@ -157,10 +183,104 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.ui.deleteConfirming = true
|
m.ui.deleteConfirming = true
|
||||||
m.ui.deleteConfirmID = selectedID
|
m.ui.deleteConfirmID = selectedID
|
||||||
m.ui.deleteConfirmAlias = selectedForward.Alias
|
m.ui.deleteConfirmAlias = selectedForward.Alias
|
||||||
m.ui.deleteConfirmCursor = 0 // Default to "No" for safety
|
m.ui.deleteConfirmCursor = 1 // Default to "No" for safety
|
||||||
|
|
||||||
m.ui.mu.Unlock()
|
m.ui.mu.Unlock()
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case "b": // Benchmark selected forward
|
||||||
|
m.ui.mu.Lock()
|
||||||
|
// Don't create benchmark view if another modal is active
|
||||||
|
if m.ui.addWizard != nil || m.ui.removeWizard != nil || m.ui.benchmarkState != nil || m.ui.httpLogState != nil {
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(m.ui.forwardOrder) == 0 {
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSelectedIndex := m.ui.selectedIndex
|
||||||
|
if currentSelectedIndex < 0 || currentSelectedIndex >= len(m.ui.forwardOrder) {
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedID := m.ui.forwardOrder[currentSelectedIndex]
|
||||||
|
selectedForward, ok := m.ui.forwards[selectedID]
|
||||||
|
if !ok {
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create benchmark state
|
||||||
|
m.ui.viewMode = ViewModeBenchmark
|
||||||
|
m.ui.benchmarkState = newBenchmarkState(selectedID, selectedForward.Alias, selectedForward.LocalPort)
|
||||||
|
// Initialize textInput with the first field's value
|
||||||
|
m.ui.benchmarkState.textInput = m.ui.benchmarkState.urlPath
|
||||||
|
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case "l": // View HTTP logs for selected forward
|
||||||
|
m.ui.mu.Lock()
|
||||||
|
// Don't create log view if another modal is active
|
||||||
|
if m.ui.addWizard != nil || m.ui.removeWizard != nil || m.ui.benchmarkState != nil || m.ui.httpLogState != nil {
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(m.ui.forwardOrder) == 0 {
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSelectedIndex := m.ui.selectedIndex
|
||||||
|
if currentSelectedIndex < 0 || currentSelectedIndex >= len(m.ui.forwardOrder) {
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedID := m.ui.forwardOrder[currentSelectedIndex]
|
||||||
|
selectedForward, ok := m.ui.forwards[selectedID]
|
||||||
|
if !ok {
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP log state
|
||||||
|
m.ui.viewMode = ViewModeHTTPLog
|
||||||
|
m.ui.httpLogState = newHTTPLogState(selectedID, selectedForward.Alias)
|
||||||
|
|
||||||
|
// Capture subscriber and UI reference for the callback
|
||||||
|
subscriber := m.ui.httpLogSubscriber
|
||||||
|
ui := m.ui
|
||||||
|
m.ui.mu.Unlock()
|
||||||
|
|
||||||
|
// Subscribe to HTTP logs if subscriber is available
|
||||||
|
// This is done outside the lock to prevent deadlocks in the callback
|
||||||
|
if subscriber != nil {
|
||||||
|
cleanup := subscriber(selectedID, func(entry HTTPLogEntry) {
|
||||||
|
// Recover from panics in the callback
|
||||||
|
defer safeRecover("HTTPLogSubscriber callback")
|
||||||
|
|
||||||
|
// Use RLock to safely access program
|
||||||
|
ui.mu.RLock()
|
||||||
|
program := ui.program
|
||||||
|
ui.mu.RUnlock()
|
||||||
|
|
||||||
|
// Send entry to program (thread-safe via Send)
|
||||||
|
if program != nil {
|
||||||
|
program.Send(HTTPLogEntryMsg{Entry: entry})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ui.mu.Lock()
|
||||||
|
ui.httpLogCleanup = cleanup
|
||||||
|
ui.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -173,12 +293,8 @@ func (m model) handleDeleteConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "ctrl+c", "esc":
|
case "ctrl+c", "esc":
|
||||||
// Cancel deletion
|
// Cancel deletion
|
||||||
m.ui.deleteConfirming = false
|
m.ui.resetDeleteConfirmation()
|
||||||
m.ui.deleteConfirmID = ""
|
|
||||||
m.ui.deleteConfirmAlias = ""
|
|
||||||
m.ui.deleteConfirmCursor = 0 // Reset cursor
|
|
||||||
m.ui.mu.Unlock()
|
m.ui.mu.Unlock()
|
||||||
// Force a repaint by returning the model
|
|
||||||
return m, tea.ClearScreen
|
return m, tea.ClearScreen
|
||||||
|
|
||||||
case "left", "h", "right", "l":
|
case "left", "h", "right", "l":
|
||||||
@@ -191,26 +307,18 @@ func (m model) handleDeleteConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
// Confirm deletion (either Enter on Yes or pressing 'y')
|
// Confirm deletion (either Enter on Yes or pressing 'y')
|
||||||
if m.ui.deleteConfirmCursor == 0 || msg.String() == "y" {
|
if m.ui.deleteConfirmCursor == 0 || msg.String() == "y" {
|
||||||
id := m.ui.deleteConfirmID
|
id := m.ui.deleteConfirmID
|
||||||
m.ui.deleteConfirming = false
|
m.ui.resetDeleteConfirmation()
|
||||||
m.ui.deleteConfirmID = ""
|
|
||||||
m.ui.deleteConfirmAlias = ""
|
|
||||||
m.ui.mu.Unlock()
|
m.ui.mu.Unlock()
|
||||||
return m, removeForwardByIDCmd(m.ui.mutator, id)
|
return m, removeForwardByIDCmd(m.ui.mutator, id)
|
||||||
}
|
}
|
||||||
// Enter on No = cancel
|
// Enter on No = cancel
|
||||||
m.ui.deleteConfirming = false
|
m.ui.resetDeleteConfirmation()
|
||||||
m.ui.deleteConfirmID = ""
|
|
||||||
m.ui.deleteConfirmAlias = ""
|
|
||||||
m.ui.deleteConfirmCursor = 0 // Reset cursor
|
|
||||||
m.ui.mu.Unlock()
|
m.ui.mu.Unlock()
|
||||||
return m, tea.ClearScreen
|
return m, tea.ClearScreen
|
||||||
|
|
||||||
case "n":
|
case "n":
|
||||||
// Quick 'n' for no
|
// Quick 'n' for no
|
||||||
m.ui.deleteConfirming = false
|
m.ui.resetDeleteConfirmation()
|
||||||
m.ui.deleteConfirmID = ""
|
|
||||||
m.ui.deleteConfirmAlias = ""
|
|
||||||
m.ui.deleteConfirmCursor = 0 // Reset cursor
|
|
||||||
m.ui.mu.Unlock()
|
m.ui.mu.Unlock()
|
||||||
return m, tea.ClearScreen
|
return m, tea.ClearScreen
|
||||||
}
|
}
|
||||||
@@ -259,10 +367,7 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
} else {
|
} else {
|
||||||
// Go back one step
|
// Go back one step
|
||||||
wizard.step--
|
wizard.step--
|
||||||
wizard.cursor = 0
|
wizard.resetInput()
|
||||||
wizard.clearTextInput()
|
|
||||||
wizard.clearSearchFilter()
|
|
||||||
wizard.error = nil
|
|
||||||
|
|
||||||
// Reset input mode based on the step we're going back to
|
// Reset input mode based on the step we're going back to
|
||||||
switch wizard.step {
|
switch wizard.step {
|
||||||
@@ -305,6 +410,14 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
wizard.moveCursor(1)
|
wizard.moveCursor(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "pgup", "ctrl+u":
|
||||||
|
// Page up - move 10 items
|
||||||
|
wizard.moveCursor(-10)
|
||||||
|
|
||||||
|
case "pgdown", "ctrl+d":
|
||||||
|
// Page down - move 10 items
|
||||||
|
wizard.moveCursor(10)
|
||||||
|
|
||||||
case "tab":
|
case "tab":
|
||||||
// Tab moves between alias field and buttons in confirmation
|
// Tab moves between alias field and buttons in confirmation
|
||||||
if wizard.step == StepConfirmation {
|
if wizard.step == StepConfirmation {
|
||||||
@@ -374,6 +487,11 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
||||||
wizard := m.ui.addWizard
|
wizard := m.ui.addWizard
|
||||||
|
|
||||||
|
// Don't process Enter if we're currently loading
|
||||||
|
if wizard.loading {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
switch wizard.step {
|
switch wizard.step {
|
||||||
case StepSelectContext:
|
case StepSelectContext:
|
||||||
filteredContexts := wizard.getFilteredContexts()
|
filteredContexts := wizard.getFilteredContexts()
|
||||||
@@ -452,12 +570,14 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
|||||||
filteredServices := wizard.getFilteredServices()
|
filteredServices := wizard.getFilteredServices()
|
||||||
if wizard.cursor >= 0 && wizard.cursor < len(filteredServices) {
|
if wizard.cursor >= 0 && wizard.cursor < len(filteredServices) {
|
||||||
wizard.resourceValue = filteredServices[wizard.cursor].Name
|
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.step = StepEnterRemotePort
|
||||||
wizard.clearTextInput()
|
wizard.clearTextInput()
|
||||||
wizard.clearSearchFilter()
|
wizard.clearSearchFilter()
|
||||||
|
|
||||||
// Get ports from selected service
|
|
||||||
wizard.detectedPorts = filteredServices[wizard.cursor].Ports
|
|
||||||
if len(wizard.detectedPorts) > 0 {
|
if len(wizard.detectedPorts) > 0 {
|
||||||
wizard.inputMode = InputModeList
|
wizard.inputMode = InputModeList
|
||||||
wizard.cursor = 0
|
wizard.cursor = 0
|
||||||
@@ -476,7 +596,14 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
|||||||
wizard.clearTextInput()
|
wizard.clearTextInput()
|
||||||
} else if wizard.cursor >= 0 && wizard.cursor < len(wizard.detectedPorts) {
|
} else if wizard.cursor >= 0 && wizard.cursor < len(wizard.detectedPorts) {
|
||||||
// Selected a detected port
|
// 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.step = StepEnterLocalPort
|
||||||
wizard.clearTextInput()
|
wizard.clearTextInput()
|
||||||
wizard.inputMode = InputModeText
|
wizard.inputMode = InputModeText
|
||||||
@@ -485,7 +612,7 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
|||||||
} else {
|
} else {
|
||||||
// Text mode - manual entry
|
// Text mode - manual entry
|
||||||
port, err := strconv.Atoi(wizard.textInput)
|
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")
|
wizard.error = fmt.Errorf("invalid port number")
|
||||||
} else {
|
} else {
|
||||||
wizard.remotePort = port
|
wizard.remotePort = port
|
||||||
@@ -497,17 +624,14 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case StepEnterLocalPort:
|
case StepEnterLocalPort:
|
||||||
port, err := strconv.Atoi(wizard.textInput)
|
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")
|
wizard.error = fmt.Errorf("invalid port number")
|
||||||
} else {
|
} else {
|
||||||
|
// Check port availability before proceeding
|
||||||
wizard.localPort = port
|
wizard.localPort = port
|
||||||
wizard.step = StepConfirmation
|
|
||||||
wizard.clearTextInput()
|
|
||||||
wizard.cursor = 0
|
|
||||||
wizard.inputMode = InputModeList
|
|
||||||
wizard.error = nil
|
|
||||||
wizard.loading = true
|
wizard.loading = true
|
||||||
return m, checkPortCmd(port)
|
wizard.error = nil
|
||||||
|
return m, checkPortCmd(port, m.ui.configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
case StepConfirmation:
|
case StepConfirmation:
|
||||||
@@ -520,6 +644,12 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
// Handle button selection
|
// Handle button selection
|
||||||
if wizard.cursor == 0 {
|
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
|
// Confirmed - save the forward
|
||||||
wizard.alias = wizard.textInput
|
wizard.alias = wizard.textInput
|
||||||
|
|
||||||
@@ -549,9 +679,10 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
return m, saveForwardCmd(m.ui.mutator, wizard.selectedContext, wizard.selectedNamespace, fwd)
|
return m, saveForwardCmd(m.ui.mutator, wizard.selectedContext, wizard.selectedNamespace, fwd)
|
||||||
} else {
|
} else {
|
||||||
// Cancelled
|
// Cancelled - return to main view with screen clear
|
||||||
m.ui.viewMode = ViewModeMain
|
m.ui.viewMode = ViewModeMain
|
||||||
m.ui.addWizard = nil
|
m.ui.addWizard = nil
|
||||||
|
return m, tea.ClearScreen
|
||||||
}
|
}
|
||||||
|
|
||||||
case StepSuccess:
|
case StepSuccess:
|
||||||
@@ -561,9 +692,10 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
|||||||
m.ui.addWizard.loading = true
|
m.ui.addWizard.loading = true
|
||||||
return m, loadContextsCmd(m.ui.discovery)
|
return m, loadContextsCmd(m.ui.discovery)
|
||||||
} else {
|
} else {
|
||||||
// Return to main view
|
// Return to main view with screen clear
|
||||||
m.ui.viewMode = ViewModeMain
|
m.ui.viewMode = ViewModeMain
|
||||||
m.ui.addWizard = nil
|
m.ui.addWizard = nil
|
||||||
|
return m, tea.ClearScreen
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,6 +737,12 @@ func (m model) handleRemoveWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "down", "j":
|
case "down", "j":
|
||||||
wizard.moveCursor(1)
|
wizard.moveCursor(1)
|
||||||
|
|
||||||
|
case "pgup", "ctrl+u":
|
||||||
|
wizard.moveCursor(-10)
|
||||||
|
|
||||||
|
case "pgdown", "ctrl+d":
|
||||||
|
wizard.moveCursor(10)
|
||||||
|
|
||||||
case " ":
|
case " ":
|
||||||
if !wizard.confirming {
|
if !wizard.confirming {
|
||||||
wizard.toggleSelection()
|
wizard.toggleSelection()
|
||||||
@@ -651,17 +789,21 @@ func (m model) handleContextsLoaded(msg ContextsLoadedMsg) (tea.Model, tea.Cmd)
|
|||||||
m.ui.addWizard.loading = false
|
m.ui.addWizard.loading = false
|
||||||
m.ui.addWizard.error = msg.err
|
m.ui.addWizard.error = msg.err
|
||||||
if msg.err == nil {
|
if msg.err == nil {
|
||||||
// Get current context and move it to the top
|
// Get current context and move it to the top (if discovery is available)
|
||||||
currentCtx, err := m.ui.discovery.GetCurrentContext()
|
if m.ui.discovery != nil {
|
||||||
if err == nil && currentCtx != "" {
|
currentCtx, err := m.ui.discovery.GetCurrentContext()
|
||||||
// Reorder contexts with current first
|
if err == nil && currentCtx != "" {
|
||||||
reordered := []string{currentCtx}
|
// Reorder contexts with current first
|
||||||
for _, ctx := range msg.contexts {
|
reordered := []string{currentCtx}
|
||||||
if ctx != currentCtx {
|
for _, ctx := range msg.contexts {
|
||||||
reordered = append(reordered, ctx)
|
if ctx != currentCtx {
|
||||||
|
reordered = append(reordered, ctx)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
m.ui.addWizard.contexts = reordered
|
||||||
|
} else {
|
||||||
|
m.ui.addWizard.contexts = msg.contexts
|
||||||
}
|
}
|
||||||
m.ui.addWizard.contexts = reordered
|
|
||||||
} else {
|
} else {
|
||||||
m.ui.addWizard.contexts = msg.contexts
|
m.ui.addWizard.contexts = msg.contexts
|
||||||
}
|
}
|
||||||
@@ -771,6 +913,17 @@ func (m model) handlePortChecked(msg PortCheckedMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.ui.addWizard.loading = false
|
m.ui.addWizard.loading = false
|
||||||
m.ui.addWizard.portAvailable = msg.available
|
m.ui.addWizard.portAvailable = msg.available
|
||||||
m.ui.addWizard.portCheckMsg = msg.message
|
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
|
return m, nil
|
||||||
@@ -807,5 +960,503 @@ 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
|
// 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)
|
// The config watcher will either reload (success) or keep old config (failure)
|
||||||
|
|
||||||
|
return m, tea.ClearScreen
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBenchmarkKeys handles keyboard input in the benchmark view
|
||||||
|
func (m model) handleBenchmarkKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
m.ui.mu.Lock()
|
||||||
|
defer m.ui.mu.Unlock()
|
||||||
|
|
||||||
|
state := m.ui.benchmarkState
|
||||||
|
if state == nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg.String() {
|
||||||
|
case "ctrl+c", "esc":
|
||||||
|
// Cancel the running benchmark if active
|
||||||
|
if state.cancelFunc != nil {
|
||||||
|
state.cancelFunc()
|
||||||
|
}
|
||||||
|
// Return to main view
|
||||||
|
m.ui.viewMode = ViewModeMain
|
||||||
|
m.ui.benchmarkState = nil
|
||||||
|
return m, tea.ClearScreen
|
||||||
|
|
||||||
|
case "up", "k":
|
||||||
|
if state.step == BenchmarkStepConfig && state.cursor > 0 {
|
||||||
|
state.cursor--
|
||||||
|
// Load current field value into textInput
|
||||||
|
state.textInput = m.getBenchmarkFieldValue(state.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "down", "j":
|
||||||
|
if state.step == BenchmarkStepConfig && state.cursor < 3 {
|
||||||
|
state.cursor++
|
||||||
|
// Load current field value into textInput
|
||||||
|
state.textInput = m.getBenchmarkFieldValue(state.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "tab":
|
||||||
|
// Tab also cycles through fields
|
||||||
|
if state.step == BenchmarkStepConfig {
|
||||||
|
state.cursor = (state.cursor + 1) % 4
|
||||||
|
state.textInput = m.getBenchmarkFieldValue(state.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "enter":
|
||||||
|
switch state.step {
|
||||||
|
case BenchmarkStepConfig:
|
||||||
|
// Start running the benchmark
|
||||||
|
state.step = BenchmarkStepRunning
|
||||||
|
state.running = true
|
||||||
|
state.progress = 0
|
||||||
|
state.total = state.requests
|
||||||
|
// Create progress channel with buffer for non-blocking sends
|
||||||
|
state.progressCh = make(chan BenchmarkProgressMsg, 10)
|
||||||
|
// Create cancellable context for the benchmark
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
state.cancelFunc = cancel
|
||||||
|
// Return batch command to run benchmark and listen for progress
|
||||||
|
return m, tea.Batch(
|
||||||
|
runBenchmarkCmd(ctx, state.forwardID, state.localPort, state.urlPath, state.method, state.concurrency, state.requests, state.progressCh),
|
||||||
|
listenBenchmarkProgressCmd(state.progressCh),
|
||||||
|
)
|
||||||
|
case BenchmarkStepResults:
|
||||||
|
// Return to main view
|
||||||
|
m.ui.viewMode = ViewModeMain
|
||||||
|
m.ui.benchmarkState = nil
|
||||||
|
return m, tea.ClearScreen
|
||||||
|
}
|
||||||
|
|
||||||
|
case "backspace":
|
||||||
|
if state.step == BenchmarkStepConfig {
|
||||||
|
if len(state.textInput) > 0 {
|
||||||
|
state.textInput = state.textInput[:len(state.textInput)-1]
|
||||||
|
m.applyBenchmarkTextInput()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Handle text input in config step
|
||||||
|
if state.step == BenchmarkStepConfig && len(msg.String()) == 1 {
|
||||||
|
char := rune(msg.String()[0])
|
||||||
|
if char >= 32 && char < 127 {
|
||||||
|
state.textInput += string(char)
|
||||||
|
m.applyBenchmarkTextInput()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getBenchmarkFieldValue returns the current value of the selected benchmark field
|
||||||
|
func (m model) getBenchmarkFieldValue(cursor int) string {
|
||||||
|
state := m.ui.benchmarkState
|
||||||
|
if state == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
switch cursor {
|
||||||
|
case 0:
|
||||||
|
return state.urlPath
|
||||||
|
case 1:
|
||||||
|
return state.method
|
||||||
|
case 2:
|
||||||
|
return fmt.Sprintf("%d", state.concurrency)
|
||||||
|
case 3:
|
||||||
|
return fmt.Sprintf("%d", state.requests)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyBenchmarkTextInput applies the current text input to the selected field
|
||||||
|
func (m model) applyBenchmarkTextInput() {
|
||||||
|
state := m.ui.benchmarkState
|
||||||
|
if state == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch state.cursor {
|
||||||
|
case 0: // URL path
|
||||||
|
state.urlPath = state.textInput
|
||||||
|
case 1: // Method
|
||||||
|
state.method = strings.ToUpper(state.textInput)
|
||||||
|
case 2: // Concurrency
|
||||||
|
if val, err := strconv.Atoi(state.textInput); err == nil && val > 0 {
|
||||||
|
state.concurrency = val
|
||||||
|
// Cap concurrency at requests
|
||||||
|
if state.concurrency > state.requests {
|
||||||
|
state.concurrency = state.requests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 3: // Requests
|
||||||
|
if val, err := strconv.Atoi(state.textInput); err == nil && val > 0 {
|
||||||
|
state.requests = val
|
||||||
|
// Cap concurrency at requests
|
||||||
|
if state.concurrency > state.requests {
|
||||||
|
state.concurrency = state.requests
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHTTPLogKeys handles keyboard input in the HTTP log view
|
||||||
|
func (m model) handleHTTPLogKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
m.ui.mu.Lock()
|
||||||
|
defer m.ui.mu.Unlock()
|
||||||
|
|
||||||
|
state := m.ui.httpLogState
|
||||||
|
if state == nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If filter input is active, handle text input
|
||||||
|
if state.filterActive {
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc":
|
||||||
|
// Cancel filter input, clear text
|
||||||
|
state.filterActive = false
|
||||||
|
state.filterText = ""
|
||||||
|
state.cursor = 0
|
||||||
|
state.scrollOffset = 0
|
||||||
|
return m, nil
|
||||||
|
case "enter":
|
||||||
|
// Confirm filter
|
||||||
|
state.filterActive = false
|
||||||
|
state.cursor = 0
|
||||||
|
state.scrollOffset = 0
|
||||||
|
return m, nil
|
||||||
|
case "backspace":
|
||||||
|
if len(state.filterText) > 0 {
|
||||||
|
state.filterText = state.filterText[:len(state.filterText)-1]
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
default:
|
||||||
|
// Add character to filter
|
||||||
|
if len(msg.String()) == 1 {
|
||||||
|
char := rune(msg.String()[0])
|
||||||
|
if char >= 32 && char < 127 {
|
||||||
|
state.filterText += string(char)
|
||||||
|
state.cursor = 0
|
||||||
|
state.scrollOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredEntries := state.getFilteredEntries()
|
||||||
|
|
||||||
|
// If viewing detail, handle detail view keys
|
||||||
|
if state.showingDetail {
|
||||||
|
switch msg.String() {
|
||||||
|
case "esc", "q", "enter":
|
||||||
|
// Return to list view
|
||||||
|
state.showingDetail = false
|
||||||
|
state.detailScroll = 0
|
||||||
|
state.copyMessage = ""
|
||||||
|
return m, nil
|
||||||
|
case "up", "k":
|
||||||
|
if state.detailScroll > 0 {
|
||||||
|
state.detailScroll--
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case "down", "j":
|
||||||
|
state.detailScroll++
|
||||||
|
return m, nil
|
||||||
|
case "pgup", "ctrl+u":
|
||||||
|
state.detailScroll -= 20
|
||||||
|
if state.detailScroll < 0 {
|
||||||
|
state.detailScroll = 0
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case "pgdown", "ctrl+d":
|
||||||
|
state.detailScroll += 20
|
||||||
|
return m, nil
|
||||||
|
case "g":
|
||||||
|
state.detailScroll = 0
|
||||||
|
return m, nil
|
||||||
|
case "c":
|
||||||
|
// Copy response body to clipboard
|
||||||
|
if state.cursor >= 0 && state.cursor < len(filteredEntries) {
|
||||||
|
entry := filteredEntries[state.cursor]
|
||||||
|
if entry.ResponseBody != "" {
|
||||||
|
// Decompress if needed before copying
|
||||||
|
body := decompressContent(entry.ResponseBody, entry.ResponseHeaders)
|
||||||
|
if err := copyToClipboard(body); err == nil {
|
||||||
|
state.copyMessage = "Copied!"
|
||||||
|
} else {
|
||||||
|
state.copyMessage = "Clipboard unavailable"
|
||||||
|
}
|
||||||
|
// Clear the message after 2 seconds
|
||||||
|
return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
|
||||||
|
return clearCopyMessageMsg{}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg.String() {
|
||||||
|
case "ctrl+c", "esc", "q":
|
||||||
|
// Cleanup subscription before closing
|
||||||
|
if m.ui.httpLogCleanup != nil {
|
||||||
|
m.ui.httpLogCleanup()
|
||||||
|
m.ui.httpLogCleanup = nil
|
||||||
|
}
|
||||||
|
// Return to main view
|
||||||
|
m.ui.viewMode = ViewModeMain
|
||||||
|
m.ui.httpLogState = nil
|
||||||
|
return m, tea.ClearScreen
|
||||||
|
|
||||||
|
case "enter":
|
||||||
|
// Show detail view for selected entry
|
||||||
|
if len(filteredEntries) > 0 && state.cursor >= 0 && state.cursor < len(filteredEntries) {
|
||||||
|
state.showingDetail = true
|
||||||
|
state.detailScroll = 0
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case "up", "k":
|
||||||
|
if state.cursor > 0 {
|
||||||
|
state.cursor--
|
||||||
|
state.autoScroll = false
|
||||||
|
}
|
||||||
|
|
||||||
|
case "down", "j":
|
||||||
|
if state.cursor < len(filteredEntries)-1 {
|
||||||
|
state.cursor++
|
||||||
|
}
|
||||||
|
// If at bottom, enable auto-scroll
|
||||||
|
if state.cursor >= len(filteredEntries)-1 {
|
||||||
|
state.autoScroll = true
|
||||||
|
}
|
||||||
|
|
||||||
|
case "pgup", "ctrl+u":
|
||||||
|
// Page up - move 20 entries
|
||||||
|
state.cursor -= 20
|
||||||
|
if state.cursor < 0 {
|
||||||
|
state.cursor = 0
|
||||||
|
}
|
||||||
|
state.autoScroll = false
|
||||||
|
|
||||||
|
case "pgdown", "ctrl+d":
|
||||||
|
// Page down - move 20 entries
|
||||||
|
state.cursor += 20
|
||||||
|
if state.cursor >= len(filteredEntries) {
|
||||||
|
state.cursor = len(filteredEntries) - 1
|
||||||
|
}
|
||||||
|
if state.cursor < 0 {
|
||||||
|
state.cursor = 0
|
||||||
|
}
|
||||||
|
// If at bottom, enable auto-scroll
|
||||||
|
if state.cursor >= len(filteredEntries)-1 {
|
||||||
|
state.autoScroll = true
|
||||||
|
}
|
||||||
|
|
||||||
|
case "g":
|
||||||
|
// Go to top
|
||||||
|
state.cursor = 0
|
||||||
|
state.scrollOffset = 0
|
||||||
|
state.autoScroll = false
|
||||||
|
|
||||||
|
case "G":
|
||||||
|
// Go to bottom
|
||||||
|
if len(filteredEntries) > 0 {
|
||||||
|
state.cursor = len(filteredEntries) - 1
|
||||||
|
state.autoScroll = true
|
||||||
|
}
|
||||||
|
|
||||||
|
case "a":
|
||||||
|
// Toggle auto-scroll
|
||||||
|
state.autoScroll = !state.autoScroll
|
||||||
|
|
||||||
|
case "f":
|
||||||
|
// Cycle filter mode (skip Text mode when cycling - use '/' for text filter)
|
||||||
|
state.filterMode = (state.filterMode + 1) % 4
|
||||||
|
if state.filterMode == HTTPLogFilterText {
|
||||||
|
// Skip Text mode when using 'f' - it's only accessible via '/'
|
||||||
|
state.filterMode = HTTPLogFilterNon200
|
||||||
|
}
|
||||||
|
state.cursor = 0
|
||||||
|
state.scrollOffset = 0
|
||||||
|
|
||||||
|
case "/":
|
||||||
|
// Enter text filter mode
|
||||||
|
state.filterActive = true
|
||||||
|
state.filterText = ""
|
||||||
|
|
||||||
|
case "c":
|
||||||
|
// Clear all filters
|
||||||
|
state.filterMode = HTTPLogFilterNone
|
||||||
|
state.filterText = ""
|
||||||
|
state.cursor = 0
|
||||||
|
state.scrollOffset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHTTPLogEntry handles incoming HTTP log entries
|
||||||
|
func (m model) handleHTTPLogEntry(msg HTTPLogEntryMsg) (tea.Model, tea.Cmd) {
|
||||||
|
m.ui.mu.Lock()
|
||||||
|
defer m.ui.mu.Unlock()
|
||||||
|
|
||||||
|
if m.ui.httpLogState == nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
state := m.ui.httpLogState
|
||||||
|
entry := msg.Entry
|
||||||
|
|
||||||
|
// If this is a response, try to find and merge with the matching request
|
||||||
|
if entry.Direction == "response" && entry.RequestID != "" {
|
||||||
|
// Search backwards (responses follow requests closely)
|
||||||
|
for i := len(state.entries) - 1; i >= 0 && i >= len(state.entries)-100; i-- {
|
||||||
|
if state.entries[i].RequestID == entry.RequestID && state.entries[i].Direction == "request" {
|
||||||
|
// Merge response data into the existing request entry
|
||||||
|
state.entries[i].Direction = "response"
|
||||||
|
state.entries[i].StatusCode = entry.StatusCode
|
||||||
|
state.entries[i].LatencyMs = entry.LatencyMs
|
||||||
|
state.entries[i].BodySize = entry.BodySize
|
||||||
|
state.entries[i].ResponseHeaders = entry.ResponseHeaders
|
||||||
|
state.entries[i].ResponseBody = entry.ResponseBody
|
||||||
|
state.entries[i].Error = entry.Error
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For requests or unmatched responses, append as new entry
|
||||||
|
state.entries = append(state.entries, entry)
|
||||||
|
|
||||||
|
// Cap entries to prevent memory growth (keep last 10000 entries)
|
||||||
|
const maxEntries = 10000
|
||||||
|
if len(state.entries) > maxEntries {
|
||||||
|
// Remove oldest entries
|
||||||
|
state.entries = state.entries[len(state.entries)-maxEntries:]
|
||||||
|
// Adjust cursor if needed
|
||||||
|
if state.cursor >= len(state.entries) {
|
||||||
|
state.cursor = len(state.entries) - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-scroll to bottom if enabled
|
||||||
|
if state.autoScroll && len(state.entries) > 0 {
|
||||||
|
filteredEntries := state.getFilteredEntries()
|
||||||
|
state.cursor = len(filteredEntries) - 1
|
||||||
|
if state.cursor < 0 {
|
||||||
|
state.cursor = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBenchmarkProgress handles progress updates during benchmark execution
|
||||||
|
func (m model) handleBenchmarkProgress(msg BenchmarkProgressMsg) (tea.Model, tea.Cmd) {
|
||||||
|
m.ui.mu.Lock()
|
||||||
|
defer m.ui.mu.Unlock()
|
||||||
|
|
||||||
|
if m.ui.benchmarkState == nil || !m.ui.benchmarkState.running {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
state := m.ui.benchmarkState
|
||||||
|
state.progress = msg.Completed
|
||||||
|
state.total = msg.Total
|
||||||
|
|
||||||
|
// Continue listening for more progress updates
|
||||||
|
if state.progressCh != nil {
|
||||||
|
return m, listenBenchmarkProgressCmd(state.progressCh)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBenchmarkComplete handles the benchmark completion message
|
||||||
|
func (m model) handleBenchmarkComplete(msg BenchmarkCompleteMsg) (tea.Model, tea.Cmd) {
|
||||||
|
m.ui.mu.Lock()
|
||||||
|
defer m.ui.mu.Unlock()
|
||||||
|
|
||||||
|
if m.ui.benchmarkState == nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
state := m.ui.benchmarkState
|
||||||
|
state.running = false
|
||||||
|
state.step = BenchmarkStepResults
|
||||||
|
state.progressCh = nil // Clear progress channel since benchmark is complete
|
||||||
|
|
||||||
|
if msg.Error != nil {
|
||||||
|
state.error = msg.Error
|
||||||
|
state.results = nil
|
||||||
|
} else if msg.Results != nil {
|
||||||
|
stats := msg.Results.CalculateStats()
|
||||||
|
state.results = &BenchmarkResults{
|
||||||
|
TotalRequests: msg.Results.TotalRequests,
|
||||||
|
Successful: msg.Results.Successful,
|
||||||
|
Failed: msg.Results.Failed,
|
||||||
|
MinLatency: float64(stats.MinLatency.Milliseconds()),
|
||||||
|
MaxLatency: float64(stats.MaxLatency.Milliseconds()),
|
||||||
|
AvgLatency: float64(stats.AvgLatency.Milliseconds()),
|
||||||
|
P50Latency: float64(stats.P50Latency.Milliseconds()),
|
||||||
|
P95Latency: float64(stats.P95Latency.Milliseconds()),
|
||||||
|
P99Latency: float64(stats.P99Latency.Milliseconds()),
|
||||||
|
Throughput: stats.Throughput,
|
||||||
|
BytesRead: msg.Results.BytesRead,
|
||||||
|
StatusCodes: msg.Results.StatusCodes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyToClipboard copies text to the system clipboard using OS-specific commands.
|
||||||
|
// This avoids CGO dependencies that cause issues in CI environments.
|
||||||
|
func copyToClipboard(text string) error {
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "darwin":
|
||||||
|
cmd = exec.Command("pbcopy")
|
||||||
|
case "linux":
|
||||||
|
// Try xclip first, fall back to xsel
|
||||||
|
if _, err := exec.LookPath("xclip"); err == nil {
|
||||||
|
cmd = exec.Command("xclip", "-selection", "clipboard")
|
||||||
|
} else if _, err := exec.LookPath("xsel"); err == nil {
|
||||||
|
cmd = exec.Command("xsel", "--clipboard", "--input")
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("no clipboard tool found (install xclip or xsel)")
|
||||||
|
}
|
||||||
|
case "windows":
|
||||||
|
cmd = exec.Command("clip")
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := stdin.Write([]byte(text)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := stdin.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd.Wait()
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ const (
|
|||||||
ViewModeMain ViewMode = iota
|
ViewModeMain ViewMode = iota
|
||||||
ViewModeAddWizard
|
ViewModeAddWizard
|
||||||
ViewModeRemoveWizard
|
ViewModeRemoveWizard
|
||||||
|
ViewModeBenchmark
|
||||||
|
ViewModeHTTPLog
|
||||||
)
|
)
|
||||||
|
|
||||||
// InputMode represents whether the wizard is in list selection or text input mode
|
// InputMode represents whether the wizard is in list selection or text input mode
|
||||||
@@ -363,3 +365,196 @@ func (w *AddWizardState) clearSearchFilter() {
|
|||||||
w.cursor = 0
|
w.cursor = 0
|
||||||
w.scrollOffset = 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkStep represents the current step in the benchmark wizard
|
||||||
|
type BenchmarkStep int
|
||||||
|
|
||||||
|
const (
|
||||||
|
BenchmarkStepConfig BenchmarkStep = iota
|
||||||
|
BenchmarkStepRunning
|
||||||
|
BenchmarkStepResults
|
||||||
|
)
|
||||||
|
|
||||||
|
// BenchmarkState maintains the state for the benchmark wizard
|
||||||
|
type BenchmarkState struct {
|
||||||
|
step BenchmarkStep
|
||||||
|
forwardID string
|
||||||
|
forwardAlias string
|
||||||
|
localPort int
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
urlPath string
|
||||||
|
method string
|
||||||
|
concurrency int
|
||||||
|
requests int
|
||||||
|
cursor int // Current field being edited
|
||||||
|
textInput string
|
||||||
|
|
||||||
|
// Running state
|
||||||
|
running bool
|
||||||
|
progress int
|
||||||
|
total int
|
||||||
|
progressCh chan BenchmarkProgressMsg // Channel for progress updates
|
||||||
|
cancelFunc func() // Function to cancel the running benchmark
|
||||||
|
|
||||||
|
// Results
|
||||||
|
results *BenchmarkResults
|
||||||
|
error error
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkResults holds benchmark results for display
|
||||||
|
type BenchmarkResults struct {
|
||||||
|
TotalRequests int
|
||||||
|
Successful int
|
||||||
|
Failed int
|
||||||
|
MinLatency float64 // milliseconds
|
||||||
|
MaxLatency float64
|
||||||
|
AvgLatency float64
|
||||||
|
P50Latency float64
|
||||||
|
P95Latency float64
|
||||||
|
P99Latency float64
|
||||||
|
Throughput float64 // requests per second
|
||||||
|
BytesRead int64
|
||||||
|
StatusCodes map[int]int
|
||||||
|
}
|
||||||
|
|
||||||
|
// newBenchmarkState creates a new benchmark state for a forward
|
||||||
|
func newBenchmarkState(forwardID, alias string, localPort int) *BenchmarkState {
|
||||||
|
return &BenchmarkState{
|
||||||
|
step: BenchmarkStepConfig,
|
||||||
|
forwardID: forwardID,
|
||||||
|
forwardAlias: alias,
|
||||||
|
localPort: localPort,
|
||||||
|
urlPath: "/",
|
||||||
|
method: "GET",
|
||||||
|
concurrency: 10,
|
||||||
|
requests: 100,
|
||||||
|
cursor: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPLogFilterMode represents the active filter type
|
||||||
|
type HTTPLogFilterMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
HTTPLogFilterNone HTTPLogFilterMode = iota
|
||||||
|
HTTPLogFilterText
|
||||||
|
HTTPLogFilterNon200
|
||||||
|
HTTPLogFilterErrors // 4xx and 5xx only
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTPLogState maintains the state for HTTP log viewing
|
||||||
|
type HTTPLogState struct {
|
||||||
|
forwardID string
|
||||||
|
forwardAlias string
|
||||||
|
entries []HTTPLogEntry
|
||||||
|
cursor int
|
||||||
|
scrollOffset int
|
||||||
|
autoScroll bool
|
||||||
|
|
||||||
|
// Filtering
|
||||||
|
filterMode HTTPLogFilterMode
|
||||||
|
filterText string
|
||||||
|
filterActive bool // true when typing in filter input
|
||||||
|
|
||||||
|
// Detail view
|
||||||
|
showingDetail bool // true when viewing full entry details
|
||||||
|
detailScroll int // scroll position in detail view
|
||||||
|
copyMessage string // temporary message after copying (e.g., "Copied!")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPLogEntry represents a single HTTP log entry for display
|
||||||
|
type HTTPLogEntry struct {
|
||||||
|
RequestID string // Used to match request/response pairs
|
||||||
|
Timestamp string
|
||||||
|
Direction string
|
||||||
|
Method string
|
||||||
|
Path string
|
||||||
|
StatusCode int
|
||||||
|
LatencyMs int64
|
||||||
|
BodySize int
|
||||||
|
|
||||||
|
// Detail fields - for viewing full request/response
|
||||||
|
RequestHeaders map[string]string
|
||||||
|
ResponseHeaders map[string]string
|
||||||
|
RequestBody string
|
||||||
|
ResponseBody string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// newHTTPLogState creates a new HTTP log viewing state
|
||||||
|
func newHTTPLogState(forwardID, alias string) *HTTPLogState {
|
||||||
|
return &HTTPLogState{
|
||||||
|
forwardID: forwardID,
|
||||||
|
forwardAlias: alias,
|
||||||
|
entries: make([]HTTPLogEntry, 0),
|
||||||
|
autoScroll: true,
|
||||||
|
filterMode: HTTPLogFilterNone,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFilteredEntries returns entries matching the current filter
|
||||||
|
// Only returns entries with status codes (responses) since requests don't have useful info
|
||||||
|
func (s *HTTPLogState) getFilteredEntries() []HTTPLogEntry {
|
||||||
|
filtered := make([]HTTPLogEntry, 0, len(s.entries))
|
||||||
|
filterLower := strings.ToLower(s.filterText)
|
||||||
|
|
||||||
|
for _, entry := range s.entries {
|
||||||
|
// Only show entries with status codes (completed responses)
|
||||||
|
// Requests, streaming connections, and errors without status are filtered out
|
||||||
|
if entry.StatusCode == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filter mode
|
||||||
|
switch s.filterMode {
|
||||||
|
case HTTPLogFilterNon200:
|
||||||
|
if entry.StatusCode >= 200 && entry.StatusCode < 300 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case HTTPLogFilterErrors:
|
||||||
|
if entry.StatusCode < 400 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply text filter
|
||||||
|
if s.filterText != "" {
|
||||||
|
matchPath := strings.Contains(strings.ToLower(entry.Path), filterLower)
|
||||||
|
matchMethod := strings.Contains(strings.ToLower(entry.Method), filterLower)
|
||||||
|
if !matchPath && !matchMethod {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered = append(filtered, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFilterModeLabel returns a label for the current filter mode
|
||||||
|
func (s *HTTPLogState) getFilterModeLabel() string {
|
||||||
|
switch s.filterMode {
|
||||||
|
case HTTPLogFilterNone:
|
||||||
|
return "All"
|
||||||
|
case HTTPLogFilterText:
|
||||||
|
return "Text"
|
||||||
|
case HTTPLogFilterNon200:
|
||||||
|
return "Non-2xx"
|
||||||
|
case HTTPLogFilterErrors:
|
||||||
|
return "Errors (4xx/5xx)"
|
||||||
|
default:
|
||||||
|
return "All"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+133
-43
@@ -16,6 +16,13 @@ var (
|
|||||||
mutedColor = lipgloss.Color("241") // Gray
|
mutedColor = lipgloss.Color("241") // Gray
|
||||||
accentColor = lipgloss.Color("63") // Purple
|
accentColor = lipgloss.Color("63") // Purple
|
||||||
highlightColor = lipgloss.Color("117") // Light blue
|
highlightColor = lipgloss.Color("117") // Light blue
|
||||||
|
|
||||||
|
// JSON syntax highlighting colors
|
||||||
|
jsonKeyColor = lipgloss.Color("81") // Cyan
|
||||||
|
jsonStringColor = lipgloss.Color("180") // Light orange/tan
|
||||||
|
jsonNumberColor = lipgloss.Color("141") // Light purple
|
||||||
|
jsonBoolColor = lipgloss.Color("209") // Orange
|
||||||
|
jsonNullColor = lipgloss.Color("243") // Dark gray
|
||||||
)
|
)
|
||||||
|
|
||||||
// Text styles
|
// Text styles
|
||||||
@@ -59,6 +66,10 @@ var (
|
|||||||
spinnerStyle = lipgloss.NewStyle().
|
spinnerStyle = lipgloss.NewStyle().
|
||||||
Foreground(accentColor).
|
Foreground(accentColor).
|
||||||
Bold(true)
|
Bold(true)
|
||||||
|
|
||||||
|
accentStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(accentColor).
|
||||||
|
Bold(true)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Input styles
|
// Input styles
|
||||||
@@ -80,13 +91,31 @@ var (
|
|||||||
Foreground(mutedColor)
|
Foreground(mutedColor)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// JSON syntax highlighting styles
|
||||||
|
var (
|
||||||
|
jsonKeyStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(jsonKeyColor)
|
||||||
|
|
||||||
|
jsonStringStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(jsonStringColor)
|
||||||
|
|
||||||
|
jsonNumberStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(jsonNumberColor)
|
||||||
|
|
||||||
|
jsonBoolStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(jsonBoolColor)
|
||||||
|
|
||||||
|
jsonNullStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(jsonNullColor)
|
||||||
|
)
|
||||||
|
|
||||||
// Container styles
|
// Container styles
|
||||||
var (
|
var (
|
||||||
|
// wizardBoxStyle creates a bordered modal box
|
||||||
wizardBoxStyle = lipgloss.NewStyle().
|
wizardBoxStyle = lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(accentColor).
|
BorderForeground(accentColor).
|
||||||
Padding(1, 2).
|
Padding(1, 2)
|
||||||
Width(60)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Helper functions for rendering
|
// Helper functions for rendering
|
||||||
@@ -165,47 +194,108 @@ func renderTextInput(label, value string, valid bool) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// overlayContent overlays modal content centered on the base view
|
// wizardHelpWidth returns an appropriate width for wizard help text
|
||||||
func overlayContent(base, modal string, termWidth, termHeight int) string {
|
// based on terminal width. For modals, we use a sensible maximum.
|
||||||
baseLines := strings.Split(base, "\n")
|
func wizardHelpWidth(termWidth int) int {
|
||||||
modalLines := strings.Split(modal, "\n")
|
if termWidth == 0 {
|
||||||
|
termWidth = 80
|
||||||
// Ensure base has enough lines
|
|
||||||
for len(baseLines) < termHeight {
|
|
||||||
baseLines = append(baseLines, "")
|
|
||||||
}
|
}
|
||||||
|
// Wizard modals shouldn't be wider than 70 chars typically
|
||||||
modalHeight := len(modalLines)
|
// but on narrow terminals, use available space minus padding
|
||||||
modalWidth := 0
|
maxWidth := 70
|
||||||
for _, line := range modalLines {
|
available := termWidth - 10 // account for modal borders and padding
|
||||||
w := lipgloss.Width(line)
|
if available < maxWidth {
|
||||||
if w > modalWidth {
|
return available
|
||||||
modalWidth = w
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return maxWidth
|
||||||
// Calculate center position
|
}
|
||||||
startRow := (termHeight - modalHeight) / 2
|
|
||||||
if startRow < 0 {
|
// wrapHelpText wraps help text to fit within the given width.
|
||||||
startRow = 0
|
// Help text is expected to be in the format "key: action key: action ..."
|
||||||
}
|
// separated by double spaces. On smaller screens, it wraps to multiple lines.
|
||||||
|
func wrapHelpText(text string, width int) string {
|
||||||
// Create result with modal overlaid
|
if width <= 0 {
|
||||||
result := make([]string, len(baseLines))
|
width = 80 // Default width
|
||||||
copy(result, baseLines)
|
}
|
||||||
|
|
||||||
for i, modalLine := range modalLines {
|
// Account for some padding/margin
|
||||||
row := startRow + i
|
availableWidth := width - 4
|
||||||
if row >= 0 && row < len(result) {
|
if availableWidth < 20 {
|
||||||
// Center the modal line
|
availableWidth = 20
|
||||||
padding := (termWidth - lipgloss.Width(modalLine)) / 2
|
}
|
||||||
if padding < 0 {
|
|
||||||
padding = 0
|
// If text fits, return as-is
|
||||||
}
|
if len(text) <= availableWidth {
|
||||||
|
return helpStyle.Render(text)
|
||||||
result[row] = strings.Repeat(" ", padding) + modalLine
|
}
|
||||||
}
|
|
||||||
}
|
// Split by double-space separator (common in help text)
|
||||||
|
parts := strings.Split(text, " ")
|
||||||
return strings.Join(result, "\n")
|
if len(parts) <= 1 {
|
||||||
|
// No double-space separators, just truncate
|
||||||
|
if len(text) > availableWidth-3 {
|
||||||
|
return helpStyle.Render(text[:availableWidth-3] + "...")
|
||||||
|
}
|
||||||
|
return helpStyle.Render(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
var currentLine strings.Builder
|
||||||
|
|
||||||
|
for i, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if adding this part would exceed width
|
||||||
|
addition := part
|
||||||
|
if currentLine.Len() > 0 {
|
||||||
|
addition = " " + part
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentLine.Len()+len(addition) > availableWidth && currentLine.Len() > 0 {
|
||||||
|
// Start new line
|
||||||
|
lines = append(lines, currentLine.String())
|
||||||
|
currentLine.Reset()
|
||||||
|
currentLine.WriteString(part)
|
||||||
|
} else {
|
||||||
|
if currentLine.Len() > 0 {
|
||||||
|
currentLine.WriteString(" ")
|
||||||
|
}
|
||||||
|
currentLine.WriteString(part)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle last part
|
||||||
|
if i == len(parts)-1 && currentLine.Len() > 0 {
|
||||||
|
lines = append(lines, currentLine.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join with newlines and apply style to each line
|
||||||
|
var result strings.Builder
|
||||||
|
for i, line := range lines {
|
||||||
|
if i > 0 {
|
||||||
|
result.WriteString("\n")
|
||||||
|
}
|
||||||
|
result.WriteString(helpStyle.Render(line))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// overlayContent overlays modal content centered on the base view
|
||||||
|
// Note: base parameter is kept for API compatibility but not used since
|
||||||
|
// lipgloss.Place provides cleaner centering without background artifacts
|
||||||
|
func overlayContent(_, modal string, termWidth, termHeight int) string {
|
||||||
|
// Use lipgloss.Place to center the modal in the terminal viewport
|
||||||
|
// This handles all alignment properly and respects ANSI styling
|
||||||
|
return lipgloss.Place(
|
||||||
|
termWidth,
|
||||||
|
termHeight,
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
modal,
|
||||||
|
lipgloss.WithWhitespaceChars(" "),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+936
-27
File diff suppressed because it is too large
Load Diff
@@ -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