Compare commits

...

41 Commits

Author SHA1 Message Date
lukaszraczylo 96ae1d45e0 style: Extract UI constants and refactor main view rendering (#30)
- [x] Add golangci-lint configuration with gocritic ifElseChain disabled
- [x] Rename error variables to avoid shadowing (createErr, watcherErr, watchErr, etc.)
- [x] Replace `interface{}` with `any` type alias throughout codebase
- [x] Add package-level documentation comments to all internal packages
- [x] Reorder struct fields alphabetically for consistency
- [x] Extract UI constants (terminal dimensions, column widths, colors) to constants.go
- [x] Refactor BubbleTeaUI main view rendering into smaller helper functions
- [x] Simplify nested conditionals and improve code clarity
- [x] Add `isForwardDisabled()` helper method to BubbleTeaUI
- [x] Update file permissions from 0644 to 0600 in config tests
- [x] Add `#nosec` comments and error suppression where appropriate
- [x] Improve test table struct field ordering for readability
- [x] Fix resource parsing in AddForward using strings.SplitN
- [x] Add comprehensive tests for new UI helper functions and constants
2026-01-13 09:37:45 +00:00
lukaszraczylo 3d71f64901 Update go.mod and go.sum (#29) 2026-01-13 03:39:00 +00:00
lukaszraczylo 38b7a06c53 Update go.mod and go.sum (#28) 2026-01-12 03:41:55 +00:00
lukaszraczylo 7ad96e3f72 Update go.mod and go.sum (#27) 2026-01-10 03:37:23 +00:00
lukaszraczylo ac7c855de5 Update go.mod and go.sum (#26) 2026-01-09 03:39:39 +00:00
lukaszraczylo 4074a7186c Update go.mod and go.sum (#25) 2026-01-07 03:39:19 +00:00
lukaszraczylo a5cc95a26e Update go.mod and go.sum (#24) 2025-12-23 03:39:14 +00:00
lukaszraczylo 0f977683cd Update go.mod and go.sum (#23) 2025-12-21 03:39:29 +00:00
lukaszraczylo dcebdf718a Update go.mod and go.sum (#22) 2025-12-20 03:32:25 +00:00
lukaszraczylo 5967f26c21 Update go.mod and go.sum (#21) 2025-12-19 03:37:51 +00:00
lukaszraczylo 285ced6755 fixup! Update go.mod and go.sum (#20) 2025-12-18 09:38:02 +00:00
lukaszraczylo 9fe076acb2 Update go.mod and go.sum (#20) 2025-12-18 03:37:26 +00:00
lukaszraczylo 92746efcf5 Update go.mod and go.sum (#19) 2025-12-15 03:40:40 +00:00
lukaszraczylo 391bce366d fixup! fixup! Add artifacts signing. 2025-12-15 00:16:16 +00:00
lukaszraczylo 9fd8f9b03b fixup! Add artifacts signing. 2025-12-14 23:56:42 +00:00
lukaszraczylo 7032bb5bee Add artifacts signing. 2025-12-14 23:29:27 +00:00
lukaszraczylo 6cb4f91ece Cleanup and refactor. 2025-12-14 18:17:20 +00:00
lukaszraczylo 5d600043f0 Update go.mod and go.sum (#18) 2025-12-13 03:32:36 +00:00
lukaszraczylo 9bb6fbc48d Update go.mod and go.sum (#17) 2025-12-12 03:38:34 +00:00
lukaszraczylo f4334ebdc9 Update go.mod and go.sum (#16) 2025-12-11 03:38:45 +00:00
lukaszraczylo 50f94bda87 Update go.mod and go.sum (#15) 2025-12-09 01:13:24 +00:00
lukaszraczylo d9888f1a56 Cleanup (#14)
* Codebase cleanup
2025-12-09 01:06:38 +00:00
lukaszraczylo 7dec532e18 Use shared PR workflow 2025-12-08 01:32:30 +00:00
lukaszraczylo aa7695b3be Trigger autoupdate. 2025-12-08 01:10:11 +00:00
lukaszraczylo 1bacd31f27 fixup! Add verified param to the releaser. 2025-12-07 16:40:37 +00:00
lukaszraczylo bfecbdf056 Add verified param to the releaser. 2025-12-07 14:40:12 +00:00
lukaszraczylo 754108474c fixup! fixup! fixup! Update gorelease config and docs - moving to cask due to depreciation 2025-12-07 14:36:45 +00:00
lukaszraczylo 690c587c0a fixup! fixup! Update gorelease config and docs - moving to cask due to depreciation 2025-12-07 14:16:43 +00:00
lukaszraczylo 0d03f228f9 fixup! Update gorelease config and docs - moving to cask due to depreciation 2025-12-07 14:04:38 +00:00
lukaszraczylo 2a44c6ff9c Update gorelease config and docs - moving to cask due to depreciation 2025-12-07 13:59:51 +00:00
lukaszraczylo 8672d932bb Move build and release to shared workflow 2025-12-07 13:34:14 +00:00
lukaszraczylo 87317adb91 Move to shared workflow. 2025-12-07 13:16:41 +00:00
lukaszraczylo ced7e80a06 Update the autoupdate workflow 2025-12-07 12:35:56 +00:00
lukaszraczylo 13723733df Update autoupdate workflow. LOL. 2025-12-03 00:47:06 +00:00
lukaszraczylo 9538623bcb Remove unused struct 2025-12-02 18:13:05 +00:00
lukaszraczylo 8bb377909c Add startupSettleTime to zeroconf to avoid race conditions. 2025-12-02 18:06:11 +00:00
lukaszraczylo 263a0370d3 fixup! fixup! Fix autoupdate workflow. 2025-12-01 14:39:54 +00:00
lukaszraczylo 62eca4a9a1 Update go.mod and go.sum
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-01 14:34:23 +00:00
lukaszraczylo ea20a037b9 fixup! Fix autoupdate workflow. 2025-12-01 14:31:06 +00:00
lukaszraczylo 46db732f87 Fix autoupdate workflow. 2025-12-01 14:24:34 +00:00
lukaszraczylo a297ba7073 Enhancement: Empty config
When user starts kportal for the first time, and there is no config file,
kportal should create an empty config file with default values and empty
forwarding rules, so that user can easily edit the config file and add their
own rules.
2025-11-29 12:44:33 +00:00
67 changed files with 1959 additions and 1180 deletions
+11 -64
View File
@@ -5,69 +5,16 @@ on:
schedule:
- cron: "0 3 * * *"
env:
GO_VERSION: ">=1.21"
permissions:
contents: write
actions: write
pull-requests: write
security-events: write
jobs:
# This job is responsible for preparation of the build
# environment variables.
prepare:
name: Preparing build context
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@v5
id: cache
with:
go-version: ${{env.GO_VERSION}}
cache-dependency-path: "**/*.sum"
- name: Go get dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: |
go get ./...
# This job is responsible for running tests and linting the codebase
test:
name: "Unit testing"
runs-on: ubuntu-latest
container: golang:1
needs: [prepare]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Ensure full history is checked out
token: ${{ secrets.GHCR_TOKEN }}
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: ${{env.GO_VERSION}}
cache-dependency-path: "**/*.sum"
- name: Install dependencies
run: |
apt-get update
apt-get install ca-certificates make -y
update-ca-certificates
go mod tidy
go get -u -v ./...
go mod tidy -v
- name: Run unit tests
run: |
CI_RUN=${CI} make test
git config --global --add safe.directory /__w/kportal/kportal
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "Update go.mod and go.sum"
commit_options: "--no-verify --signoff"
file_pattern: "go.mod go.sum"
autoupdate:
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
with:
go-version: ">=1.24"
release-workflow: "release.yml"
secrets: inherit
+22
View File
@@ -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"
+9 -80
View File
@@ -5,90 +5,19 @@ on:
branches:
- main
paths:
- '**.go'
- 'go.mod'
- 'go.sum'
- "**.go"
- "go.mod"
- "go.sum"
workflow_dispatch:
permissions:
contents: write
packages: write
id-token: write
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:
name: Release with GoReleaser
needs: version
runs-on: ubuntu-latest
steps:
- 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 }}
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
with:
go-version: ">=1.24"
secrets: inherit
+2 -2
View File
@@ -6,7 +6,7 @@ on:
push:
branches: ["main"]
paths:
- 'docs/**'
- "docs/**"
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
@@ -39,7 +39,7 @@ jobs:
uses: actions/upload-pages-artifact@v3
with:
# Upload entire repository
path: 'docs/'
path: "docs/"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+29
View File
@@ -0,0 +1,29 @@
# golangci-lint configuration
# https://golangci-lint.run/usage/configuration/
run:
timeout: 5m
tests: true
linters:
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused
- gosec
- gocritic
- gofmt
linters-settings:
govet:
enable:
- fieldalignment
gosec:
excludes:
- G304 # File path provided as taint input - handled with #nosec comments where needed
gocritic:
disabled-checks:
- ifElseChain # Complex conditionals are clearer as if-else than switch true
+24 -9
View File
@@ -23,11 +23,11 @@ builds:
archives:
- id: kportal
format: tar.gz
formats: [tar.gz]
name_template: "kportal-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
format_overrides:
- goos: windows
format: zip
formats: [zip]
files:
- LICENSE
- README.md
@@ -53,17 +53,32 @@ release:
draft: false
prerelease: auto
brews:
homebrew_casks:
- repository:
owner: lukaszraczylo
name: homebrew-taps
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
directory: Formula
directory: Casks
homepage: https://lukaszraczylo.github.io/kportal
description: "Modern Kubernetes port-forward manager with interactive TUI"
license: MIT
test: |
system "#{bin}/kportal", "--version"
dependencies:
- name: kubernetes-cli
type: optional
url:
verified: github.com/lukaszraczylo/kportal
hooks:
post:
install: |
if OS.mac?
system_command "/usr/bin/xattr",
args: ["-dr", "com.apple.quarantine", "#{staged_path}/kportal"]
end
signs:
- cmd: cosign
signature: "${artifact}.sigstore.json"
args:
- sign-blob
- "--bundle=${signature}"
- "${artifact}"
- "--yes"
artifacts: checksum
output: true
+20 -2
View File
@@ -54,12 +54,17 @@ kportal manages multiple Kubernetes port-forwards with an interactive terminal i
## 📦 Installation
### Homebrew (macOS/Linux)
### Homebrew (macOS)
```bash
brew install lukaszraczylo/taps/kportal
brew install --cask lukaszraczylo/taps/kportal
```
> **Note**: If you previously installed via `brew install lukaszraczylo/taps/kportal` (formula), uninstall first:
> ```bash
> brew uninstall kportal
> ```
### Quick Install
```bash
@@ -78,6 +83,19 @@ cd kportal
make build && make install
```
### Verifying Release Signatures
All release checksums are signed with [cosign](https://github.com/sigstore/cosign) using keyless signing. To verify:
```bash
# Download the checksum file and its sigstore bundle from the release
cosign verify-blob \
--certificate-identity-regexp "https://github.com/lukaszraczylo/kportal/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
--bundle "kportal-<version>-checksums.txt.sigstore.json" \
kportal-<version>-checksums.txt
```
## 🚀 Quick Start
Create `.kportal.yaml`:
+88 -26
View File
@@ -1,6 +1,7 @@
package main
import (
"bufio"
"context"
"flag"
"fmt"
@@ -49,6 +50,23 @@ var (
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() {
flag.Parse()
@@ -173,14 +191,38 @@ func main() {
// Load configuration
cfg, err := config.LoadConfig(*configFile)
configIsNew := false
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
if err == config.ErrConfigNotFound {
// Config file doesn't exist - offer to create it
if !promptCreateConfig(*configFile) {
os.Exit(0)
}
// Create empty config file
if createErr := config.CreateEmptyConfigFile(*configFile); createErr != nil {
fmt.Fprintf(os.Stderr, "Error creating config file: %v\n", createErr)
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()
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))
os.Exit(1)
}
@@ -253,9 +295,9 @@ func main() {
// Interactive mode with bubbletea
bubbleTeaUI = ui.NewBubbleTeaUI(func(id string, enable bool) {
if enable {
manager.EnableForward(id)
_ = manager.EnableForward(id)
} else {
manager.DisableForward(id)
_ = manager.DisableForward(id)
}
}, appVersion)
@@ -267,16 +309,26 @@ func main() {
bubbleTeaUI.SetHTTPLogSubscriber(func(forwardID string, callback func(entry ui.HTTPLogEntry)) func() {
worker := manager.GetWorker(forwardID)
if worker == nil {
logger.Debug("HTTP log subscription failed: worker not found", map[string]any{
"forward_id": forwardID,
})
return func() {} // No-op cleanup
}
proxy := worker.GetHTTPProxy()
if proxy == nil {
// This is expected for forwards without httpLog enabled - not an error
logger.Debug("HTTP log subscription skipped: proxy not enabled", map[string]any{
"forward_id": forwardID,
})
return func() {} // HTTP logging not enabled for this forward
}
proxyLogger := proxy.GetLogger()
if proxyLogger == nil {
logger.Debug("HTTP log subscription failed: logger not available", map[string]any{
"forward_id": forwardID,
})
return func() {}
}
@@ -327,8 +379,8 @@ func main() {
}
// Start forwards
if err := manager.Start(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Error starting forwards: %v\n", err)
if startErr := manager.Start(cfg); startErr != nil {
fmt.Fprintf(os.Stderr, "Error starting forwards: %v\n", startErr)
os.Exit(1)
}
@@ -339,17 +391,18 @@ func main() {
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 {
watcher, watcherErr := config.NewWatcher(*configFile, func(newCfg *config.Config) error {
return manager.Reload(newCfg)
}, *verbose)
if err != nil {
watcherStarted := false
if watcherErr != nil {
if *verbose {
log.Printf("Warning: Failed to setup config watcher: %v", err)
log.Printf("Warning: Failed to setup config watcher: %v", watcherErr)
log.Printf("Hot-reload will not be available")
}
} else {
watcher.Start()
defer watcher.Stop()
watcherStarted = true
}
if *verbose {
@@ -364,10 +417,10 @@ func main() {
if *verbose {
log.Printf("Received SIGHUP, reloading configuration...")
}
newCfg, err := config.LoadConfig(*configFile)
if err != nil {
newCfg, loadErr := config.LoadConfig(*configFile)
if loadErr != nil {
if *verbose {
log.Printf("Failed to reload config: %v", err)
log.Printf("Failed to reload config: %v", loadErr)
}
continue
}
@@ -380,9 +433,9 @@ func main() {
continue
}
if err := manager.Reload(newCfg); err != nil {
if reloadErr := manager.Reload(newCfg); reloadErr != nil {
if *verbose {
log.Printf("Failed to reload: %v", err)
log.Printf("Failed to reload: %v", reloadErr)
}
}
@@ -412,6 +465,10 @@ func main() {
log.Printf("Received second signal (%v), forcing exit...", sig)
}
}
// Stop the watcher before exiting (defers won't run after os.Exit)
if watcherStarted {
watcher.Stop()
}
os.Exit(0)
}
}
@@ -433,15 +490,16 @@ func main() {
}()
// Setup config watcher for hot-reload
watcher, err := config.NewWatcher(*configFile, func(newCfg *config.Config) error {
watcher, watchErr := config.NewWatcher(*configFile, func(newCfg *config.Config) error {
return manager.Reload(newCfg)
}, *verbose)
if err != nil {
log.Printf("Warning: Failed to setup config watcher: %v", err)
watcherActive := false
if watchErr != nil {
log.Printf("Warning: Failed to setup config watcher: %v", watchErr)
log.Printf("Hot-reload will not be available")
} else {
watcher.Start()
defer watcher.Stop()
watcherActive = true
}
log.Printf("Press Ctrl+C to stop")
@@ -452,9 +510,9 @@ func main() {
switch sig {
case syscall.SIGHUP:
log.Printf("Received SIGHUP, reloading configuration...")
newCfg, err := config.LoadConfig(*configFile)
if err != nil {
log.Printf("Failed to reload config: %v", err)
newCfg, loadErr := config.LoadConfig(*configFile)
if loadErr != nil {
log.Printf("Failed to reload config: %v", loadErr)
continue
}
@@ -464,8 +522,8 @@ func main() {
continue
}
if err := manager.Reload(newCfg); err != nil {
log.Printf("Failed to reload: %v", err)
if reloadErr := manager.Reload(newCfg); reloadErr != nil {
log.Printf("Failed to reload: %v", reloadErr)
}
case os.Interrupt, syscall.SIGTERM:
@@ -487,6 +545,10 @@ func main() {
// Second signal received - force exit immediately
log.Printf("Received second signal (%v), forcing exit...", sig)
}
// Stop the watcher before exiting (defers won't run after os.Exit)
if watcherActive {
watcher.Stop()
}
os.Exit(0)
}
}
+3 -3
View File
@@ -556,11 +556,11 @@
<i class="fas fa-beer text-orange-500 dark:text-orange-400 text-2xl mr-3"></i>
<div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Homebrew</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm">macOS & Linux</p>
<p class="text-gray-600 dark:text-gray-400 text-sm">macOS</p>
</div>
</div>
<div onclick="copyToClipboard('brew install lukaszraczylo/taps/kportal', this)" class="relative bg-gradient-to-br from-gray-900 to-gray-800 dark:from-gray-950 dark:to-black text-gray-100 p-4 rounded-lg text-sm cursor-pointer group overflow-x-auto border border-gray-700 hover:border-orange-500 transition-all duration-300">
<code class="block whitespace-nowrap font-mono">brew install lukaszraczylo/taps/kportal</code>
<div onclick="copyToClipboard('brew install --cask lukaszraczylo/taps/kportal', this)" class="relative bg-gradient-to-br from-gray-900 to-gray-800 dark:from-gray-950 dark:to-black text-gray-100 p-4 rounded-lg text-sm cursor-pointer group overflow-x-auto border border-gray-700 hover:border-orange-500 transition-all duration-300">
<code class="block whitespace-nowrap font-mono">brew install --cask lukaszraczylo/taps/kportal</code>
<div class="absolute top-3 right-3"><i class="fas fa-copy text-gray-500 group-hover:text-orange-400 transition-colors duration-300"></i></div>
</div>
</div>
+20 -21
View File
@@ -1,6 +1,6 @@
module github.com/nvm/kportal
go 1.24.2
go 1.25.0
require (
github.com/charmbracelet/bubbletea v1.3.10
@@ -10,28 +10,28 @@ require (
github.com/grandcat/zeroconf v1.0.0
github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.34.2
k8s.io/apimachinery v0.34.2
k8s.io/client-go v0.34.2
k8s.io/api v0.35.0
k8s.io/apimachinery v0.35.0
k8s.io/client-go v0.35.0
k8s.io/klog/v2 v2.130.1
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/charmbracelet/colorprofile v0.3.3 // indirect
github.com/charmbracelet/x/ansi v0.11.1 // indirect
github.com/charmbracelet/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/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.6.0 // indirect
github.com/clipperhouse/displaywidth v0.7.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-openapi/jsonpointer v0.22.3 // indirect
github.com/go-openapi/jsonreference v0.21.3 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/jsonreference v0.21.4 // indirect
github.com/go-openapi/swag v0.25.4 // indirect
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
@@ -44,7 +44,6 @@ require (
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
@@ -54,7 +53,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/miekg/dns v1.1.68 // indirect
github.com/miekg/dns v1.1.70 // indirect
github.com/moby/spdystream v0.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
@@ -70,20 +69,20 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.39.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect
+48 -68
View File
@@ -1,3 +1,5 @@
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -8,20 +10,20 @@ github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEe
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
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/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.1 h1:iXAC8SyMQDJgtcz9Jnw+HU8WMEctHzoTAETIeA3JXMk=
github.com/charmbracelet/x/ansi v0.11.1/go.mod h1:M49wjzpIujwPceJ+t5w3qh2i87+HRtHohgb5iTyepL0=
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
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/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/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE=
github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
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/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
@@ -40,10 +42,10 @@ 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/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
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.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=
@@ -76,15 +78,13 @@ github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
@@ -93,8 +93,6 @@ github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6
github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -108,8 +106,8 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
github.com/miekg/dns v1.1.70 h1:DZ4u2AV35VJxdD9Fo9fIWm119BsQL5cZU1cQ9s0LkqA=
github.com/miekg/dns v1.1.70/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -128,18 +126,18 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -152,66 +150,48 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
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-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -221,18 +201,18 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw=
k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4=
k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M=
k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE=
k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY=
k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA=
k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE=
k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e h1:iW9ChlU0cU16w8MpVYjXk12dqQ4BPFBEgif+ap7/hqQ=
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY=
k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
+17 -6
View File
@@ -1,3 +1,14 @@
// Package benchmark provides HTTP benchmarking capabilities for port forwards.
// It measures latency, throughput, and reliability of forwarded connections.
//
// The benchmark runner sends configurable numbers of concurrent requests
// and collects statistics including:
// - Latency percentiles (P50, P95, P99)
// - Request success/failure rates
// - Throughput (requests/second)
// - Status code distribution
//
// Results can be displayed in the UI or exported for analysis.
package benchmark
import (
@@ -7,17 +18,17 @@ import (
// 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"`
StatusCodes map[int]int `json:"status_codes"`
Errors map[string]int `json:"errors,omitempty"`
Method string `json:"method"`
URL string `json:"url"`
ForwardID string `json:"forward_id"`
Latencies []time.Duration `json:"-"`
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"`
}
+9 -26
View File
@@ -16,25 +16,15 @@ 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,
}
Headers map[string]string
ProgressCallback ProgressCallback
URL string
Method string
Body []byte
Concurrency int
Requests int
Duration time.Duration
Timeout time.Duration
}
// Runner executes HTTP benchmarks
@@ -221,10 +211,3 @@ func (r *Runner) makeRequest(ctx context.Context, cfg Config) (statusCode int, b
return resp.StatusCode, int64(len(respBody)), bytesWritten, nil
}
// Progress represents the current progress of a benchmark run
type Progress struct {
Completed int
Total int
Elapsed time.Duration
}
+3 -12
View File
@@ -106,7 +106,7 @@ func TestRunner(t *testing.T) {
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"}`))
_, _ = w.Write([]byte(`{"status":"ok"}`))
}))
defer server.Close()
@@ -132,7 +132,7 @@ func TestRunner(t *testing.T) {
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`))
_, _ = w.Write([]byte(`ok`))
}))
defer server.Close()
@@ -206,20 +206,11 @@ func TestRunnerWithBody(t *testing.T) {
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`))
_, _ = w.Write([]byte(`ok`))
}))
defer server.Close()
+101 -19
View File
@@ -1,7 +1,29 @@
// Package config provides configuration loading, validation, watching, and
// mutation for kportal. It handles parsing the .kportal.yaml configuration
// file and provides hot-reload support via file watching.
//
// The configuration structure supports multiple Kubernetes contexts, each
// with namespaces containing port-forward definitions. Additional settings
// for health checks, reliability, and mDNS hostname publishing are also
// supported.
//
// Basic usage:
//
// cfg, err := config.Load("~/.kportal.yaml")
// if err != nil {
// log.Fatal(err)
// }
//
// For hot-reload support, use the ConfigWatcher:
//
// watcher, err := config.NewConfigWatcher(path, func(cfg *config.Config) {
// // Handle configuration changes
// })
package config
import (
"bytes"
"errors"
"fmt"
"os"
"strings"
@@ -10,6 +32,9 @@ import (
"gopkg.in/yaml.v3"
)
// ErrConfigNotFound is returned when the configuration file does not exist
var ErrConfigNotFound = fmt.Errorf("config file not found")
const (
// maxConfigSize is the maximum allowed configuration file size (10MB)
maxConfigSize = 10 * 1024 * 1024
@@ -32,10 +57,10 @@ const (
// Config represents the root configuration structure from .kportal.yaml
type Config struct {
Contexts []Context `yaml:"contexts"`
HealthCheck *HealthCheckSpec `yaml:"healthCheck,omitempty"`
Reliability *ReliabilitySpec `yaml:"reliability,omitempty"`
MDNS *MDNSSpec `yaml:"mdns,omitempty"`
Contexts []Context `yaml:"contexts"`
}
// MDNSSpec configures mDNS (multicast DNS) hostname publishing
@@ -55,10 +80,10 @@ type HealthCheckSpec struct {
// ReliabilitySpec configures connection reliability features
type ReliabilitySpec struct {
TCPKeepalive string `yaml:"tcpKeepalive,omitempty"` // e.g., "30s" - OS-level keepalive
DialTimeout string `yaml:"dialTimeout,omitempty"` // e.g., "30s" - connection dial timeout
RetryOnStale bool `yaml:"retryOnStale,omitempty"` // Auto-reconnect on stale detection
WatchdogPeriod string `yaml:"watchdogPeriod,omitempty"` // e.g., "30s" - goroutine watchdog interval
TCPKeepalive string `yaml:"tcpKeepalive,omitempty"`
DialTimeout string `yaml:"dialTimeout,omitempty"`
WatchdogPeriod string `yaml:"watchdogPeriod,omitempty"`
RetryOnStale bool `yaml:"retryOnStale,omitempty"`
}
// parseDurationOrDefault parses a duration string and returns the default if empty or invalid.
@@ -163,11 +188,11 @@ type Namespace struct {
// 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
LogFile string `yaml:"logFile,omitempty"`
FilterPath string `yaml:"filterPath,omitempty"`
MaxBodySize int `yaml:"maxBodySize,omitempty"`
Enabled bool `yaml:"enabled"`
IncludeHeaders bool `yaml:"includeHeaders,omitempty"`
}
// UnmarshalYAML implements custom unmarshaling to support both bool and struct formats
@@ -192,17 +217,15 @@ func (h *HTTPLogSpec) UnmarshalYAML(unmarshal func(interface{}) error) error {
// Forward represents a single port-forward configuration
type Forward struct {
Resource string `yaml:"resource"` // e.g., "pod/my-app", "service/postgres", "pod"
Selector string `yaml:"selector"` // Label selector for pod resolution (e.g., "app=nginx,env=prod")
Protocol string `yaml:"protocol"` // tcp or udp
Port int `yaml:"port"` // Remote port
LocalPort int `yaml:"localPort"` // Local port
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)
HTTPLog *HTTPLogSpec `yaml:"httpLog,omitempty"`
Resource string `yaml:"resource"`
Selector string `yaml:"selector"`
Protocol string `yaml:"protocol"`
Alias string `yaml:"alias,omitempty"`
contextName string
namespaceName string
Port int `yaml:"port"`
LocalPort int `yaml:"localPort"`
}
// ID returns a unique identifier for this forward configuration.
@@ -282,6 +305,9 @@ func LoadConfig(path string) (*Config, error) {
// Validate file size before reading
fileInfo, err := os.Stat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrConfigNotFound
}
return nil, fmt.Errorf("failed to stat config file: %w", err)
}
@@ -289,6 +315,7 @@ func LoadConfig(path string) (*Config, error) {
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)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
@@ -337,3 +364,58 @@ func (c *Config) GetAllForwards() []Forward {
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
}
+12 -12
View File
@@ -40,8 +40,8 @@ func TestParseDurationOrDefault(t *testing.T) {
// TestConfig_GetHealthCheckIntervalOrDefault tests health check interval getter
func TestConfig_GetHealthCheckIntervalOrDefault(t *testing.T) {
tests := []struct {
name string
config *Config
name string
expected time.Duration
}{
{
@@ -83,8 +83,8 @@ func TestConfig_GetHealthCheckIntervalOrDefault(t *testing.T) {
// TestConfig_GetHealthCheckTimeoutOrDefault tests health check timeout getter
func TestConfig_GetHealthCheckTimeoutOrDefault(t *testing.T) {
tests := []struct {
name string
config *Config
name string
expected time.Duration
}{
{
@@ -162,8 +162,8 @@ func TestConfig_GetHealthCheckMethod(t *testing.T) {
// TestConfig_GetMaxConnectionAge tests max connection age getter
func TestConfig_GetMaxConnectionAge(t *testing.T) {
tests := []struct {
name string
config *Config
name string
expected time.Duration
}{
{
@@ -198,8 +198,8 @@ func TestConfig_GetMaxConnectionAge(t *testing.T) {
// TestConfig_GetMaxIdleTime tests max idle time getter
func TestConfig_GetMaxIdleTime(t *testing.T) {
tests := []struct {
name string
config *Config
name string
expected time.Duration
}{
{
@@ -234,8 +234,8 @@ func TestConfig_GetMaxIdleTime(t *testing.T) {
// TestConfig_GetTCPKeepalive tests TCP keepalive getter
func TestConfig_GetTCPKeepalive(t *testing.T) {
tests := []struct {
name string
config *Config
name string
expected time.Duration
}{
{
@@ -270,8 +270,8 @@ func TestConfig_GetTCPKeepalive(t *testing.T) {
// TestConfig_GetRetryOnStale tests retry on stale getter
func TestConfig_GetRetryOnStale(t *testing.T) {
tests := []struct {
name string
config *Config
name string
expected bool
}{
{
@@ -306,8 +306,8 @@ func TestConfig_GetRetryOnStale(t *testing.T) {
// TestConfig_GetWatchdogPeriod tests watchdog period getter
func TestConfig_GetWatchdogPeriod(t *testing.T) {
tests := []struct {
name string
config *Config
name string
expected time.Duration
}{
{
@@ -342,8 +342,8 @@ func TestConfig_GetWatchdogPeriod(t *testing.T) {
// TestConfig_GetDialTimeout tests dial timeout getter
func TestConfig_GetDialTimeout(t *testing.T) {
tests := []struct {
name string
config *Config
name string
expected time.Duration
}{
{
@@ -378,8 +378,8 @@ func TestConfig_GetDialTimeout(t *testing.T) {
// TestConfig_IsMDNSEnabled tests mDNS enabled getter
func TestConfig_IsMDNSEnabled(t *testing.T) {
tests := []struct {
name string
config *Config
name string
expected bool
}{
{
@@ -509,8 +509,8 @@ func TestForward_GetHTTPLogMaxBodySize(t *testing.T) {
func TestForward_GetMDNSAlias(t *testing.T) {
tests := []struct {
name string
forward Forward
expected string
forward Forward
}{
{
name: "explicit alias",
@@ -591,7 +591,7 @@ func TestLoadConfig_FileTooLarge(t *testing.T) {
largeData[i] = 'a'
}
err := os.WriteFile(configPath, largeData, 0644)
err := os.WriteFile(configPath, largeData, 0600)
require.NoError(t, err)
cfg, err := LoadConfig(configPath)
@@ -628,7 +628,7 @@ mdns:
enabled: true
`
err := os.WriteFile(configPath, []byte(yaml), 0644)
err := os.WriteFile(configPath, []byte(yaml), 0600)
require.NoError(t, err)
cfg, err := LoadConfig(configPath)
+127 -9
View File
@@ -39,7 +39,7 @@ func TestLoadConfig_ValidYAML(t *testing.T) {
localPort: 8081
`
err := os.WriteFile(configPath, []byte(validYAML), 0644)
err := os.WriteFile(configPath, []byte(validYAML), 0600)
assert.NoError(t, err, "should write temp config file")
// Load the config
@@ -82,7 +82,7 @@ func TestLoadConfig_InvalidYAML(t *testing.T) {
forwards: [this is invalid yaml syntax
`
err := os.WriteFile(configPath, []byte(invalidYAML), 0644)
err := os.WriteFile(configPath, []byte(invalidYAML), 0600)
assert.NoError(t, err, "should write temp config file")
// Load the config
@@ -97,14 +97,14 @@ func TestLoadConfig_FileNotFound(t *testing.T) {
cfg, err := LoadConfig("/non/existent/path/.kportal.yaml")
assert.Error(t, err, "LoadConfig should fail with non-existent file")
assert.Nil(t, cfg, "config should be nil on error")
assert.Contains(t, err.Error(), "failed to stat config file", "error should mention stat failure")
assert.Equal(t, ErrConfigNotFound, err, "should return ErrConfigNotFound")
}
func TestForward_ID(t *testing.T) {
tests := []struct {
name string
forward Forward
expectedID string
forward Forward
}{
{
name: "pod with explicit name",
@@ -165,8 +165,8 @@ func TestForward_ID(t *testing.T) {
func TestForward_String(t *testing.T) {
tests := []struct {
name string
forward Forward
expectedString string
forward Forward
}{
{
name: "pod without selector",
@@ -389,11 +389,129 @@ func TestHTTPLogSpec_UnmarshalYAML(t *testing.T) {
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")
}
} 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 {
config *Config
name string
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"), 0600)
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))
}
+2 -2
View File
@@ -264,8 +264,8 @@ func (m *Mutator) writeAtomic(cfg *Config) error {
// Atomic rename
if err := os.Rename(tmpFile, m.configPath); err != nil {
// Clean up temp file on failure
os.Remove(tmpFile)
// Clean up temp file on failure - error ignored as we're already handling the rename error
_ = os.Remove(tmpFile)
return fmt.Errorf("failed to rename temp file: %w", err)
}
+1 -1
View File
@@ -648,7 +648,7 @@ func TestMutator_Concurrent(t *testing.T) {
}
// Some will succeed, some will fail due to validation
// The important thing is no race condition
mutator.AddForward("dev", "default", fwd)
_ = mutator.AddForward("dev", "default", fwd)
}(i)
}
+21 -15
View File
@@ -17,14 +17,9 @@ func IsValidPort(port int) bool {
// ValidationError represents a configuration validation error with context.
type ValidationError struct {
Field string // The field that failed validation
Message string // Error message
Context map[string]string // Additional context information
}
// Error implements the error interface.
func (e ValidationError) Error() string {
return e.Message
Context map[string]string
Field string
Message string
}
// Validator validates configuration files.
@@ -37,6 +32,13 @@ func NewValidator() *Validator {
// ValidateConfig validates the entire configuration and returns all errors found.
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
if cfg == nil {
@@ -46,6 +48,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
errs = append(errs, v.validateStructure(cfg)...)
@@ -191,14 +199,12 @@ func (v *Validator) validateResource(fwd *Forward) []ValidationError {
Message: fmt.Sprintf("Pod name cannot be empty for forward %s", fwd.ID()),
})
}
} else {
} else if fwd.Selector == "" {
// pod (no name) - must have selector
if fwd.Selector == "" {
errs = append(errs, ValidationError{
Field: "selector",
Message: fmt.Sprintf("Forward %s uses generic 'pod' resource and must have a selector", fwd.ID()),
})
}
errs = append(errs, ValidationError{
Field: "selector",
Message: fmt.Sprintf("Forward %s uses generic 'pod' resource and must have a selector", fwd.ID()),
})
}
}
+148 -19
View File
@@ -11,10 +11,10 @@ func TestValidator_ValidateConfig(t *testing.T) {
validator := NewValidator()
tests := []struct {
name string
config *Config
expectErrors bool
name string
errorContains []string
expectErrors bool
}{
{
name: "valid config",
@@ -227,9 +227,9 @@ func TestValidator_ValidateResourceFormat(t *testing.T) {
tests := []struct {
name string
errorContains []string
forward Forward
expectErrors bool
errorContains []string
}{
{
name: "valid pod with name",
@@ -370,10 +370,10 @@ func TestValidator_CheckDuplicatePorts(t *testing.T) {
validator := NewValidator()
tests := []struct {
name string
config *Config
expectErrors bool
name string
errorContains []string
expectErrors bool
}{
{
name: "no duplicate ports",
@@ -552,8 +552,8 @@ func TestFormatValidationErrors(t *testing.T) {
tests := []struct {
name string
errors []ValidationError
expectEmpty bool
expectContains []string
expectEmpty bool
}{
{
name: "no errors",
@@ -621,23 +621,14 @@ func TestFormatValidationErrors(t *testing.T) {
}
}
func TestValidationError_Error(t *testing.T) {
err := ValidationError{
Field: "port",
Message: "Invalid port 0",
}
assert.Equal(t, "Invalid port 0", err.Error(), "Error() should return the message")
}
func TestValidator_ValidateStructure(t *testing.T) {
validator := NewValidator()
tests := []struct {
name string
config *Config
expectErrors bool
name string
errorContains []string
expectErrors bool
}{
{
name: "empty context name",
@@ -706,10 +697,10 @@ func TestValidator_ValidateMDNS(t *testing.T) {
validator := NewValidator()
tests := []struct {
name string
config *Config
expectErrors bool
name string
errorContains []string
expectErrors bool
}{
{
name: "mDNS disabled - no validation",
@@ -972,3 +963,141 @@ func TestIsAlphanumeric(t *testing.T) {
})
}
}
func TestValidator_ValidateConfigWithOptions(t *testing.T) {
validator := NewValidator()
tests := []struct {
config *Config
name string
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)
}
})
}
}
+10 -6
View File
@@ -16,12 +16,13 @@ type ReloadCallback func(*Config) error
// Watcher watches a configuration file for changes and triggers hot-reload.
type Watcher struct {
configPath string
callback ReloadCallback
watcher *fsnotify.Watcher
done chan struct{}
configPath string
wg sync.WaitGroup
stopOnce sync.Once
verbose bool
wg sync.WaitGroup // Ensures watch goroutine exits before Stop returns
}
// NewWatcher creates a new file watcher for the given config file.
@@ -33,7 +34,7 @@ func NewWatcher(configPath string, callback ReloadCallback, verbose bool) (*Watc
absPath, err := filepath.Abs(configPath)
if err != nil {
watcher.Close()
_ = watcher.Close() // Cleanup on error path; already returning error
return nil, fmt.Errorf("failed to resolve absolute path: %w", err)
}
@@ -41,7 +42,7 @@ func NewWatcher(configPath string, callback ReloadCallback, verbose bool) (*Watc
// (many editors delete and recreate files on save)
dir := filepath.Dir(absPath)
if err := watcher.Add(dir); err != nil {
watcher.Close()
_ = watcher.Close() // Cleanup on error path; already returning error
return nil, fmt.Errorf("failed to watch directory %s: %w", dir, err)
}
@@ -61,9 +62,12 @@ func (w *Watcher) Start() {
}
// Stop stops watching the configuration file and waits for the watch goroutine to exit.
// Safe to call multiple times.
func (w *Watcher) Stop() {
close(w.done)
w.watcher.Close()
w.stopOnce.Do(func() {
close(w.done)
_ = w.watcher.Close() // Best-effort cleanup during shutdown
})
w.wg.Wait() // Wait for watch goroutine to exit
}
+20 -18
View File
@@ -27,7 +27,7 @@ func TestNewWatcher(t *testing.T) {
port: 8080
localPort: 8080
`
err := os.WriteFile(configPath, []byte(initial), 0644)
err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err)
callback := func(cfg *Config) error { return nil }
@@ -57,7 +57,7 @@ func TestNewWatcher_Verbose(t *testing.T) {
port: 8080
localPort: 8080
`
err := os.WriteFile(configPath, []byte(initial), 0644)
err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err)
callback := func(cfg *Config) error { return nil }
@@ -85,13 +85,15 @@ func TestNewWatcher_RelativePath(t *testing.T) {
port: 8080
localPort: 8080
`
err := os.WriteFile(configPath, []byte(initial), 0644)
err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err)
// Change to tmpDir and use relative path
originalDir, _ := os.Getwd()
defer os.Chdir(originalDir)
os.Chdir(tmpDir)
originalDir, err := os.Getwd()
require.NoError(t, err)
defer func() { _ = os.Chdir(originalDir) }()
err = os.Chdir(tmpDir)
require.NoError(t, err)
callback := func(cfg *Config) error { return nil }
@@ -119,7 +121,7 @@ func TestWatcher_StartStop(t *testing.T) {
port: 8080
localPort: 8080
`
err := os.WriteFile(configPath, []byte(initial), 0644)
err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err)
callback := func(cfg *Config) error { return nil }
@@ -161,7 +163,7 @@ func TestWatcher_DetectsFileChange(t *testing.T) {
port: 8080
localPort: 8080
`
err := os.WriteFile(configPath, []byte(initial), 0644)
err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err)
var mu sync.Mutex
@@ -199,7 +201,7 @@ func TestWatcher_DetectsFileChange(t *testing.T) {
port: 9090
localPort: 9090
`
err = os.WriteFile(configPath, []byte(updated), 0644)
err = os.WriteFile(configPath, []byte(updated), 0600)
require.NoError(t, err)
// Wait for callback with timeout
@@ -239,7 +241,7 @@ func TestWatcher_IgnoresInvalidConfig(t *testing.T) {
port: 8080
localPort: 8080
`
err := os.WriteFile(configPath, []byte(initial), 0644)
err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err)
callbackCount := 0
@@ -267,7 +269,7 @@ func TestWatcher_IgnoresInvalidConfig(t *testing.T) {
- name: default
forwards: [this is invalid
`
err = os.WriteFile(configPath, []byte(invalid), 0644)
err = os.WriteFile(configPath, []byte(invalid), 0600)
require.NoError(t, err)
// Wait a bit
@@ -294,7 +296,7 @@ func TestWatcher_IgnoresValidationErrors(t *testing.T) {
port: 8080
localPort: 8080
`
err := os.WriteFile(configPath, []byte(initial), 0644)
err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err)
callbackCount := 0
@@ -328,7 +330,7 @@ func TestWatcher_IgnoresValidationErrors(t *testing.T) {
port: 9090
localPort: 8080
`
err = os.WriteFile(configPath, []byte(invalid), 0644)
err = os.WriteFile(configPath, []byte(invalid), 0600)
require.NoError(t, err)
// Wait a bit
@@ -356,7 +358,7 @@ func TestWatcher_IgnoresOtherFiles(t *testing.T) {
port: 8080
localPort: 8080
`
err := os.WriteFile(configPath, []byte(initial), 0644)
err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err)
callbackCount := 0
@@ -378,7 +380,7 @@ func TestWatcher_IgnoresOtherFiles(t *testing.T) {
time.Sleep(100 * time.Millisecond)
// Write to a different file
err = os.WriteFile(otherPath, []byte("some content"), 0644)
err = os.WriteFile(otherPath, []byte("some content"), 0600)
require.NoError(t, err)
// Wait a bit
@@ -405,7 +407,7 @@ func TestWatcher_HandleReload_LoadError(t *testing.T) {
port: 8080
localPort: 8080
`
err := os.WriteFile(configPath, []byte(initial), 0644)
err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err)
callbackCalled := false
@@ -445,7 +447,7 @@ func TestWatcher_DoubleStop(t *testing.T) {
port: 8080
localPort: 8080
`
err := os.WriteFile(configPath, []byte(initial), 0644)
err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err)
callback := func(cfg *Config) error { return nil }
@@ -479,7 +481,7 @@ func TestWatcher_StopWithoutStart(t *testing.T) {
port: 8080
localPort: 8080
`
err := os.WriteFile(configPath, []byte(initial), 0644)
err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err)
callback := func(cfg *Config) error { return nil }
+16 -5
View File
@@ -1,3 +1,12 @@
// Package converter provides configuration migration from other port-forwarding
// tools to kportal's YAML format. Currently supports kftray JSON format.
//
// Basic usage:
//
// err := converter.ConvertKFTrayToKPortal("kftray.json", ".kportal.yaml")
// if err != nil {
// log.Fatal(err)
// }
package converter
import (
@@ -14,25 +23,26 @@ import (
type KFTrayConfig struct {
Service string `json:"service"`
Namespace string `json:"namespace"`
LocalPort int `json:"local_port"`
RemotePort int `json:"remote_port"`
Context string `json:"context"`
WorkloadType string `json:"workload_type"`
Protocol string `json:"protocol"`
Alias string `json:"alias"`
LocalPort int `json:"local_port"`
RemotePort int `json:"remote_port"`
}
// ConvertKFTrayToKPortal converts kftray JSON configuration to kportal YAML format
func ConvertKFTrayToKPortal(inputFile, outputFile string) error {
// Read kftray JSON config
// #nosec G304 -- inputFile is from command line argument for explicit conversion
data, err := os.ReadFile(inputFile)
if err != nil {
return fmt.Errorf("failed to read input file: %w", err)
}
var kftrayConfigs []KFTrayConfig
if err := json.Unmarshal(data, &kftrayConfigs); err != nil {
return fmt.Errorf("failed to parse JSON: %w", err)
if unmarshalErr := json.Unmarshal(data, &kftrayConfigs); unmarshalErr != nil {
return fmt.Errorf("failed to parse JSON: %w", unmarshalErr)
}
// Convert to kportal format
@@ -57,6 +67,7 @@ func ConvertKFTrayToKPortal(inputFile, outputFile string) error {
// GetConversionSummary returns statistics about the kftray configuration
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)
if err != nil {
return nil, 0, fmt.Errorf("failed to read input file: %w", err)
@@ -167,9 +178,9 @@ type namespaceEntry struct {
type forwardEntry struct {
Resource string `yaml:"resource"`
Protocol string `yaml:"protocol"`
Alias string `yaml:"alias,omitempty"`
Port int `yaml:"port"`
LocalPort int `yaml:"localPort"`
Alias string `yaml:"alias,omitempty"`
}
// Convert internal types to config package types
+20 -11
View File
@@ -1,3 +1,21 @@
// Package events provides a publish-subscribe event bus for decoupled
// communication between kportal components. Events are typed and carry
// contextual data about forward lifecycle, health status, and configuration
// changes.
//
// Event types include:
// - Forward lifecycle: starting, connected, disconnected, reconnecting, stopped, error
// - Health: status_changed, stale
// - Watchdog: worker_hung
// - Config: reloaded
//
// Basic usage:
//
// bus := events.NewBus()
// bus.Subscribe(events.EventForwardConnected, func(e events.Event) {
// fmt.Printf("Forward %s connected\n", e.ForwardID)
// })
// bus.Publish(events.Event{Type: events.EventForwardConnected, ForwardID: "..."})
package events
import (
@@ -29,9 +47,9 @@ const (
// Event represents a system event
type Event struct {
Data map[string]interface{}
Type EventType
ForwardID string
Data map[string]interface{}
}
// Handler is a function that handles events
@@ -39,8 +57,8 @@ 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
mu sync.RWMutex
closed bool
}
@@ -135,15 +153,6 @@ func (b *Bus) Close() {
// 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{
-10
View File
@@ -149,16 +149,6 @@ func TestBus_ConcurrentAccess(t *testing.T) {
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", "")
+25 -35
View File
@@ -1,3 +1,17 @@
// Package forward provides the core port-forwarding orchestration for kportal.
// It manages the lifecycle of port-forward workers, handles hot-reload of
// configuration changes, and coordinates with the health checker and watchdog.
//
// The Manager is the central orchestrator that:
// - Creates and manages ForwardWorker instances for each configured forward
// - Handles graceful startup, shutdown, and reconfiguration
// - Coordinates with the HealthChecker for connection monitoring
// - Integrates with mDNS for hostname publishing
//
// ForwardWorker handles individual port-forward connections with:
// - Automatic retry with exponential backoff (1s → 2s → 4s → 8s → 10s max)
// - Pod restart detection and re-resolution
// - Graceful shutdown support
package forward
import (
@@ -24,19 +38,19 @@ type StatusUpdater interface {
// Manager orchestrates all port-forward workers.
// It handles starting, stopping, and hot-reloading forwards.
type Manager struct {
workers map[string]*ForwardWorker // key: forward.ID()
workersMu sync.RWMutex
statusUI StatusUpdater
healthChecker *healthcheck.Checker
clientPool *k8s.ClientPool
resolver *k8s.ResourceResolver
portForwarder *k8s.PortForwarder
portChecker *PortChecker
healthChecker *healthcheck.Checker
workers map[string]*ForwardWorker
watchdog *Watchdog
mdnsPublisher *mdns.Publisher
eventBus *events.Bus // Event bus for decoupled communication
verbose bool
eventBus *events.Bus
currentConfig *config.Config
statusUI StatusUpdater
workersMu sync.RWMutex
verbose bool
}
// NewManager creates a new forward Manager.
@@ -139,11 +153,6 @@ 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.
func (m *Manager) Start(cfg *config.Config) error {
if cfg == nil {
@@ -169,8 +178,10 @@ func (m *Manager) Start(cfg *config.Config) error {
// Get all forwards from config
forwards := cfg.GetAllForwards()
// Empty config is valid - user can add forwards later via TUI
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
@@ -417,11 +428,11 @@ func (m *Manager) startWorker(fwd config.Forward) error {
// Find and notify the worker to reconnect
m.workersMu.RLock()
worker, exists := m.workers[forwardID]
staleWorker, exists := m.workers[forwardID]
m.workersMu.RUnlock()
if exists {
worker.TriggerReconnect("stale connection")
staleWorker.TriggerReconnect("stale connection")
}
}
})
@@ -491,27 +502,6 @@ func (m *Manager) stopWorkerInternal(id string, removeFromUI bool) error {
return nil
}
// GetActiveForwards returns a list of all active forward IDs.
func (m *Manager) GetActiveForwards() []string {
m.workersMu.RLock()
defer m.workersMu.RUnlock()
ids := make([]string, 0, len(m.workers))
for id := range m.workers {
ids = append(ids, id)
}
return ids
}
// GetWorkerCount returns the number of active workers.
func (m *Manager) GetWorkerCount() int {
m.workersMu.RLock()
defer m.workersMu.RUnlock()
return len(m.workers)
}
// GetWorker returns a worker by ID, or nil if not found.
func (m *Manager) GetWorker(id string) *ForwardWorker {
m.workersMu.RLock()
+4 -44
View File
@@ -7,7 +7,6 @@ import (
"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
@@ -53,41 +52,6 @@ func TestManager_SetStatusUI(t *testing.T) {
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)
@@ -124,8 +88,8 @@ func TestManager_Start_EmptyForwards(t *testing.T) {
cfg := &config.Config{}
err = manager.Start(cfg)
assert.Error(t, err)
assert.Contains(t, err.Error(), "no forwards configured")
// Empty config is now valid - allows users to add forwards via TUI
assert.NoError(t, err)
}
// TestManager_Reload_NilConfig tests reloading with nil config
@@ -221,8 +185,8 @@ type StatusUpdate struct {
}
type ForwardAdd struct {
ID string
Fwd *config.Forward
ID string
}
type ErrorSet struct {
@@ -362,12 +326,8 @@ func TestManager_EventBusIntegration(t *testing.T) {
// 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) {
manager.eventBus.SubscribeAll(func(event events.Event) {
// Handler
})
}
+6 -4
View File
@@ -77,6 +77,7 @@ func getProcessNameByPID(pid string) string {
// 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 {
@@ -98,9 +99,9 @@ func getProcessNameByPIDWindows(pid string) string {
// PortConflict represents a local port that is already in use.
type PortConflict struct {
Port int // The conflicting port number
Resource string // The forward resource that needs this port
UsedBy string // Process information (PID, command) using the port
Resource string
UsedBy string
Port int
}
// PortChecker checks port availability on the local system.
@@ -145,7 +146,7 @@ func (pc *PortChecker) isPortAvailable(port int) bool {
if err != nil {
return false
}
listener.Close()
_ = listener.Close() // Best-effort cleanup; port check succeeded, Close error is non-critical
return true
}
@@ -166,6 +167,7 @@ func (pc *PortChecker) getProcessUsingPort(port int) string {
func (pc *PortChecker) getProcessUsingPortUnix(port int) string {
// Use lsof to find the process
// 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")
output, err := cmd.Output()
if err != nil {
+9 -5
View File
@@ -40,8 +40,8 @@ func TestIsValidPID(t *testing.T) {
func TestFormatProcessInfo(t *testing.T) {
tests := []struct {
name string
info processInfo
expected string
info processInfo
}{
{
name: "invalid process",
@@ -72,8 +72,8 @@ func TestFormatProcessInfo(t *testing.T) {
func TestFormatProcessList(t *testing.T) {
tests := []struct {
name string
processes []processInfo
expected string
processes []processInfo
}{
{
name: "empty list",
@@ -206,7 +206,8 @@ func TestPortChecker_CheckAvailability_EmptyPorts(t *testing.T) {
func TestPortChecker_CheckAvailability_ExcludeMap(t *testing.T) {
pc := NewPortChecker()
// Create a listener to occupy a port
// Create a listener to occupy a port on all interfaces (matching production behavior)
// #nosec G102 -- test intentionally binds to all interfaces to match production port checking
listener, err := net.Listen("tcp", ":0")
assert.NoError(t, err, "should create listener")
defer listener.Close()
@@ -231,11 +232,13 @@ func TestPortChecker_CheckAvailability_ExcludeMap(t *testing.T) {
func TestPortChecker_CheckAvailability_MultipleSkipPorts(t *testing.T) {
pc := NewPortChecker()
// Create multiple listeners
// Create multiple listeners on all interfaces (matching production behavior)
// #nosec G102 -- test intentionally binds to all interfaces to match production port checking
listener1, err := net.Listen("tcp", ":0")
assert.NoError(t, err)
defer listener1.Close()
// #nosec G102 -- test intentionally binds to all interfaces to match production port checking
listener2, err := net.Listen("tcp", ":0")
assert.NoError(t, err)
defer listener2.Close()
@@ -353,7 +356,8 @@ func TestNewPortChecker(t *testing.T) {
func TestPortChecker_PortAvailability_Integration(t *testing.T) {
pc := NewPortChecker()
// Create a listener to occupy a port
// Create a listener to occupy a port on all interfaces (matching production behavior)
// #nosec G102 -- test intentionally binds to all interfaces to match production port checking
listener, err := net.Listen("tcp", ":0")
assert.NoError(t, err, "should create listener")
defer listener.Close()
+10 -10
View File
@@ -19,25 +19,25 @@ const (
// the watchdog polls workers periodically. This reduces goroutine count and
// simplifies worker implementation.
type Watchdog struct {
mu sync.RWMutex
workers map[string]*workerState // key: forward ID
checkInterval time.Duration
hangThreshold time.Duration // How long without heartbeat before considered hung
heartbeatInterval time.Duration // How often to poll workers for heartbeat
ctx context.Context
workers map[string]*workerState
cancel context.CancelFunc
eventBus *events.Bus
wg sync.WaitGroup
eventBus *events.Bus // Optional event bus for decoupled communication
checkInterval time.Duration
hangThreshold time.Duration
heartbeatInterval time.Duration
mu sync.RWMutex
}
// workerState tracks the health of a single worker
type workerState struct {
forwardID string
lastHeartbeat time.Time
worker HeartbeatResponder
onHungCallback func(forwardID string)
forwardID string
heartbeatCount uint64
isHung bool
onHungCallback func(forwardID string)
worker HeartbeatResponder // Reference to worker for heartbeat polling
}
// HeartbeatResponder is an interface for workers that can respond to heartbeat checks
@@ -204,8 +204,8 @@ func (w *Watchdog) pollHeartbeats() {
// hungWorkerInfo stores information about a hung worker for deferred callback execution
type hungWorkerInfo struct {
forwardID string
callback func(string)
forwardID string
}
// checkWorkers checks all registered workers for hung state
+35 -22
View File
@@ -23,23 +23,23 @@ const (
// ForwardWorker manages a single port-forward connection with automatic retry.
type ForwardWorker struct {
forward config.Forward
portForwarder *k8s.PortForwarder
ctx context.Context
cancel context.CancelFunc
stopChan chan struct{}
doneChan chan struct{}
reconnectChan chan string // Channel to trigger reconnection
successChan chan struct{} // Channel to signal successful connection (for backoff reset)
verbose bool
lastPod string // Track the last pod we connected to
startTime time.Time
statusUI StatusUpdater
healthChecker *healthcheck.Checker
ctx context.Context
reconnectChan chan string
httpProxy *httplog.Proxy
watchdog *Watchdog
startTime time.Time // Track when the worker started
forwardCancel context.CancelFunc // Cancel function for current forward attempt
forwardCancelMu sync.Mutex // Protects forwardCancel
httpProxy *httplog.Proxy // HTTP logging proxy (nil if not enabled)
cancel context.CancelFunc
doneChan chan struct{}
portForwarder *k8s.PortForwarder
successChan chan struct{}
healthChecker *healthcheck.Checker
forwardCancel context.CancelFunc
stopChan chan struct{}
lastPod string
forward config.Forward
forwardCancelMu sync.Mutex
verbose bool
}
// NewForwardWorker creates a new ForwardWorker for a single forward configuration.
@@ -142,7 +142,7 @@ func (w *ForwardWorker) run() {
// Start HTTP logging proxy if enabled
if err := w.startHTTPProxy(); err != nil {
logger.Error("Failed to start HTTP logging proxy", map[string]interface{}{
logger.Error("Failed to start HTTP logging proxy", map[string]any{
"forward_id": w.forward.ID(),
"error": err.Error(),
})
@@ -175,7 +175,7 @@ func (w *ForwardWorker) run() {
)
if err != nil {
logger.Error("Failed to resolve resource", map[string]interface{}{
logger.Error("Failed to resolve resource", map[string]any{
"forward_id": w.forward.ID(),
"context": w.forward.GetContext(),
"namespace": w.forward.GetNamespace(),
@@ -191,7 +191,7 @@ func (w *ForwardWorker) run() {
if w.healthChecker != nil {
w.healthChecker.MarkReconnecting(w.forward.ID())
}
logger.Info("Pod restart detected, switching to new pod", map[string]interface{}{
logger.Info("Pod restart detected, switching to new pod", map[string]any{
"forward_id": w.forward.ID(),
"old_pod": w.lastPod,
"new_pod": podName,
@@ -199,7 +199,7 @@ func (w *ForwardWorker) run() {
"namespace": w.forward.GetNamespace(),
})
} else if w.lastPod == "" {
logger.Info("Starting port forward", map[string]interface{}{
logger.Info("Starting port forward", map[string]any{
"forward_id": w.forward.ID(),
"target": w.forward.String(),
"local_port": w.forward.LocalPort,
@@ -228,7 +228,7 @@ func (w *ForwardWorker) run() {
}
// Log the error
logger.Warn("Port-forward connection failed, will retry", map[string]interface{}{
logger.Warn("Port-forward connection failed, will retry", map[string]any{
"forward_id": w.forward.ID(),
"context": w.forward.GetContext(),
"namespace": w.forward.GetNamespace(),
@@ -336,6 +336,11 @@ func (w *ForwardWorker) establishForward(podName string) error {
// Start port forwarding in a goroutine
errChan := make(chan error, 1)
go func() {
defer func() {
if r := recover(); r != nil {
errChan <- fmt.Errorf("port forward panicked: %v", r)
}
}()
errChan <- w.portForwarder.Forward(forwardCtx, req)
}()
@@ -409,6 +414,14 @@ func (w *ForwardWorker) startHTTPProxy() error {
// Calculate internal port for k8s tunnel
targetPort := w.forward.LocalPort + httpLogPortOffset
// Validate that the target port is available before attempting to bind
portChecker := NewPortChecker()
if !portChecker.isPortAvailable(targetPort) {
usedBy := portChecker.getProcessUsingPort(targetPort)
return fmt.Errorf("HTTP proxy target port %d is already in use by %s (forward port %d + offset %d)",
targetPort, usedBy, w.forward.LocalPort, httpLogPortOffset)
}
proxy, err := httplog.NewProxy(&w.forward, targetPort)
if err != nil {
return fmt.Errorf("failed to create HTTP proxy: %w", err)
@@ -420,7 +433,7 @@ func (w *ForwardWorker) startHTTPProxy() error {
w.httpProxy = proxy
logger.Info("HTTP logging proxy started", map[string]interface{}{
logger.Info("HTTP logging proxy started", map[string]any{
"forward_id": w.forward.ID(),
"local_port": w.forward.LocalPort,
"target_port": targetPort,
@@ -433,7 +446,7 @@ func (w *ForwardWorker) startHTTPProxy() error {
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{}{
logger.Warn("Failed to stop HTTP proxy", map[string]any{
"forward_id": w.forward.ID(),
"error": err.Error(),
})
+5 -5
View File
@@ -55,8 +55,8 @@ func TestLogWriter_Write(t *testing.T) {
func TestForwardWorker_GetForward(t *testing.T) {
tests := []struct {
name string
forward config.Forward
description string
forward config.Forward
}{
{
name: "get pod forward",
@@ -141,9 +141,9 @@ func TestForwardWorker_IsRunning(t *testing.T) {
func TestForwardID(t *testing.T) {
tests := []struct {
name string
description string
forward config.Forward
expectUnique bool
description string
}{
{
name: "unique IDs for different forwards",
@@ -183,9 +183,9 @@ func TestForwardID(t *testing.T) {
func TestForwardString(t *testing.T) {
tests := []struct {
name string
forward config.Forward
expectedContains []string
description string
expectedContains []string
forward config.Forward
}{
{
name: "pod forward string",
@@ -259,8 +259,8 @@ func TestSleepWithBackoffConcept(t *testing.T) {
func TestWorkerVerboseMode(t *testing.T) {
tests := []struct {
name string
verbose bool
description string
verbose bool
}{
{
name: "verbose mode enabled",
+39 -23
View File
@@ -1,3 +1,17 @@
// Package healthcheck provides connection health monitoring for port-forwards.
// It detects stale, hung, or broken connections and triggers reconnection.
//
// The Checker supports two health check methods:
// - tcp-dial: Simple TCP connection test (fast but less reliable)
// - data-transfer: Attempts to read data from the connection (more reliable)
//
// Stale connection detection prevents issues during long-running operations
// like database dumps by monitoring:
// - Connection age (default: 25 minutes, before k8s 30-minute timeout)
// - Idle time (default: 10 minutes, detects hung tunnels)
//
// The package uses a sync.Pool for buffer reuse to minimize GC pressure
// during frequent health checks.
package healthcheck
import (
@@ -47,13 +61,13 @@ const (
// PortHealth represents the health status of a single port
type PortHealth struct {
Port int
LastCheck time.Time
RegisteredAt time.Time
ConnectionTime time.Time
LastActivity time.Time
Status Status
ErrorMessage string
RegisteredAt time.Time // When this port was registered
ConnectionTime time.Time // When current connection was established
LastActivity time.Time // Last time data was transferred
Port int
}
// StatusCallback is called when a port's health status changes
@@ -63,26 +77,26 @@ type StatusCallback func(forwardID string, status Status, errorMsg string)
// Uses a single goroutine to check all registered ports, reducing overhead
// compared to one goroutine per port.
type Checker struct {
mu sync.RWMutex
ports map[string]*PortHealth // key: forward ID
callbacks map[string]StatusCallback
interval time.Duration
timeout time.Duration
method CheckMethod
maxConnectionAge time.Duration
maxIdleTime time.Duration
ctx context.Context
ports map[string]*PortHealth
callbacks map[string]StatusCallback
eventBus *events.Bus
cancel context.CancelFunc
method CheckMethod
wg sync.WaitGroup
interval time.Duration
maxIdleTime time.Duration
maxConnectionAge time.Duration
timeout time.Duration
mu sync.RWMutex
started bool
eventBus *events.Bus // Optional event bus for decoupled communication
}
// CheckerOptions configures the health checker
type CheckerOptions struct {
Method CheckMethod
Interval time.Duration
Timeout time.Duration
Method CheckMethod
MaxConnectionAge time.Duration
MaxIdleTime time.Duration
}
@@ -339,7 +353,10 @@ func (c *Checker) checkPort(forwardID string) {
connectionAge.Round(time.Second), c.maxConnectionAge, idleTime.Round(time.Second))
} else if c.maxIdleTime > 0 && idleTime > c.maxIdleTime {
newStatus = StatusStale
errorMsg = fmt.Sprintf("idle time %v exceeds max %v", idleTime.Round(time.Second), c.maxIdleTime)
// Round up to next second to ensure displayed time is always > max
// (avoids confusing "10m0s exceeds max 10m0s" when actual is 10m0.1s)
displayIdle := idleTime.Truncate(time.Second) + time.Second
errorMsg = fmt.Sprintf("idle time %v exceeds max %v", displayIdle, c.maxIdleTime)
} else {
// Perform connectivity check
var checkErr error
@@ -365,7 +382,8 @@ func (c *Checker) checkPort(forwardID string) {
}
}
// Update health status
// Update health status and capture eventBus while holding lock
var bus *events.Bus
c.mu.Lock()
if health, exists := c.ports[forwardID]; exists {
health.Status = newStatus
@@ -378,17 +396,15 @@ func (c *Checker) checkPort(forwardID string) {
health.LastActivity = now
}
}
// Capture eventBus while we have the lock to avoid race condition
bus = c.eventBus
c.mu.Unlock()
// Notify if status changed
if oldStatus != newStatus {
c.notifyStatusChange(forwardID, newStatus, errorMsg)
// Publish to event bus if available
c.mu.RLock()
bus := c.eventBus
c.mu.RUnlock()
// Publish to event bus if available (captured while holding lock above)
if bus != nil {
if newStatus == StatusStale {
bus.Publish(events.NewStaleEvent(forwardID, errorMsg))
@@ -409,7 +425,7 @@ func (c *Checker) checkTCPDial(port int) error {
if err != nil {
return err
}
conn.Close()
_ = conn.Close() // Best-effort cleanup; health check succeeded
return nil
}
@@ -427,7 +443,7 @@ func (c *Checker) checkDataTransfer(port int) error {
// Set a short read deadline to detect hung connections
// We don't expect to receive data, but we want to verify the connection isn't hung
conn.SetReadDeadline(time.Now().Add(c.timeout))
_ = conn.SetReadDeadline(time.Now().Add(c.timeout))
// Try to read a small amount of data
// Most servers will either:
+3 -8
View File
@@ -88,9 +88,9 @@ func (s *HealthCheckTestSuite) TestRegisterAndUnregister() {
func (s *HealthCheckTestSuite) TestTCPDialMethod() {
tests := []struct {
name string
setupPort bool
expectedStatus Status
description string
setupPort bool
}{
{
name: "port available - healthy",
@@ -109,10 +109,9 @@ func (s *HealthCheckTestSuite) TestTCPDialMethod() {
for _, tt := range tests {
s.Run(tt.name, func() {
var testPort int
var testListener net.Listener
if tt.setupPort {
// Use the existing listener
// Use the existing listener from suite setup
testPort = s.port
} else {
// Use a port that's not listening
@@ -143,10 +142,6 @@ func (s *HealthCheckTestSuite) TestTCPDialMethod() {
status, exists := checker.GetStatus("test-forward")
assert.True(s.T(), exists)
assert.Equal(s.T(), tt.expectedStatus, status, tt.description)
if testListener != nil {
testListener.Close()
}
})
}
}
@@ -201,7 +196,7 @@ func (s *HealthCheckTestSuite) TestDataTransferMethod() {
}
switch tt.serverBehavior {
case "banner":
conn.Write([]byte("220 Welcome\r\n"))
_, _ = conn.Write([]byte("220 Welcome\r\n"))
time.Sleep(50 * time.Millisecond)
conn.Close()
case "close":
+21 -8
View File
@@ -1,3 +1,15 @@
// Package httplog provides HTTP request/response logging for port forwards.
// It captures HTTP traffic passing through the forward proxy and stores
// entries for viewing in the UI.
//
// The logger supports:
// - Request and response capture with headers and bodies
// - Configurable body size limits to prevent memory issues
// - Callback-based notifications for real-time log viewing
// - Thread-safe operation for concurrent forwards
//
// Bodies are truncated if they exceed the configured maximum size
// (default: 1MB) and marked as truncated in the log entry.
package httplog
import (
@@ -11,17 +23,17 @@ import (
// Entry represents a single HTTP log entry
type Entry struct {
Timestamp time.Time `json:"timestamp"`
Headers map[string]string `json:"headers,omitempty"`
ForwardID string `json:"forward_id"`
RequestID string `json:"request_id"`
Direction string `json:"direction"` // "request" or "response"
Direction string `json:"direction"`
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"`
StatusCode int `json:"status_code,omitempty"`
BodySize int `json:"body_size"`
LatencyMs int64 `json:"latency_ms,omitempty"`
}
// LogCallback is a function that receives log entries
@@ -29,12 +41,12 @@ 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
file *os.File
forwardID string
maxBodyLen int
callbacks []LogCallback
maxBodyLen int
mu sync.Mutex
}
// NewLogger creates a new HTTP logger
@@ -51,6 +63,7 @@ func NewLogger(forwardID, logFile string, maxBodyLen int) (*Logger, error) {
// 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
+15 -15
View File
@@ -166,15 +166,15 @@ func TestLogger_Log_Error(t *testing.T) {
func TestLogger_BodyTruncation(t *testing.T) {
tests := []struct {
name string
maxBodyLen int
body string
maxBodyLen int
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},
{name: "body under limit", maxBodyLen: 100, body: "short", expectTrunc: false},
{name: "body at limit", maxBodyLen: 5, body: "exact", expectTrunc: false},
{name: "body over limit", maxBodyLen: 5, body: "this is too long", expectTrunc: true},
{name: "empty body", maxBodyLen: 100, body: "", expectTrunc: false},
{name: "zero max", maxBodyLen: 0, body: "any", expectTrunc: true},
}
for _, tt := range tests {
@@ -186,10 +186,10 @@ func TestLogger_BodyTruncation(t *testing.T) {
output: &buf,
}
l.Log(Entry{Body: tt.body})
_ = l.Log(Entry{Body: tt.body})
var entry Entry
json.Unmarshal(buf.Bytes(), &entry)
_ = json.Unmarshal(buf.Bytes(), &entry)
if tt.expectTrunc {
assert.Contains(t, entry.Body, "...(truncated)")
@@ -219,9 +219,9 @@ func TestLogger_Callbacks(t *testing.T) {
})
// 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"})
_ = 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)
@@ -244,7 +244,7 @@ func TestLogger_MultipleCallbacks(t *testing.T) {
l.AddCallback(func(entry Entry) { count1++ })
l.AddCallback(func(entry Entry) { count2++ })
l.Log(Entry{})
_ = l.Log(Entry{})
assert.Equal(t, 1, count1)
assert.Equal(t, 1, count2)
@@ -261,12 +261,12 @@ func TestLogger_ClearCallbacks(t *testing.T) {
count := 0
l.AddCallback(func(entry Entry) { count++ })
l.Log(Entry{})
_ = l.Log(Entry{})
assert.Equal(t, 1, count)
l.ClearCallbacks()
l.Log(Entry{})
_ = l.Log(Entry{})
assert.Equal(t, 1, count) // Still 1 - callback was cleared
}
@@ -321,7 +321,7 @@ func TestLogger_Concurrent(t *testing.T) {
wg.Add(1)
go func(n int) {
defer wg.Done()
l.Log(Entry{
_ = l.Log(Entry{
Direction: "request",
Path: "/api/" + string(rune('a'+n%26)),
})
+15 -13
View File
@@ -15,20 +15,21 @@ import (
"time"
"github.com/nvm/kportal/internal/config"
"github.com/nvm/kportal/internal/logger"
)
// 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)
listener net.Listener
logger *Logger
server *http.Server
forwardID string
filterPath string // Glob pattern for path filtering
includeHdrs bool
listener net.Listener
filterPath string
localPort int
targetPort int
requestCount uint64
mu sync.Mutex
includeHdrs bool
running bool
}
@@ -85,12 +86,13 @@ func (p *Proxy) Start() error {
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()))
_, _ = w.Write([]byte("Proxy error: " + err.Error()))
},
}
p.server = &http.Server{
Handler: proxy,
Handler: proxy,
ReadHeaderTimeout: 10 * time.Second,
}
p.running = true
@@ -99,7 +101,7 @@ func (p *Proxy) Start() error {
// 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
logger.Debug("HTTP proxy serve error (will be replaced on reconnect)", map[string]any{"error": err.Error()})
}
}()
@@ -122,8 +124,8 @@ func (p *Proxy) Stop() error {
defer cancel()
if err := p.server.Shutdown(ctx); err != nil {
// Force close
p.server.Close()
// Force close - error ignored as we're already shutting down
_ = p.server.Close()
}
if err := p.logger.Close(); err != nil {
@@ -173,7 +175,7 @@ func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error)
reqEntry.Headers = flattenHeaders(req.Header)
}
t.proxy.logger.Log(reqEntry)
_ = t.proxy.logger.Log(reqEntry)
// Make the request
resp, err := t.transport.RoundTrip(req)
@@ -207,7 +209,7 @@ func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error)
respEntry.Headers = flattenHeaders(resp.Header)
}
t.proxy.logger.Log(respEntry)
_ = t.proxy.logger.Log(respEntry)
return resp, nil
}
@@ -269,7 +271,7 @@ func (p *Proxy) logError(req *http.Request, err error) {
Path: req.URL.Path,
Error: err.Error(),
}
p.logger.Log(entry)
_ = p.logger.Log(entry)
}
// flattenHeaders converts http.Header to map[string]string
+2 -2
View File
@@ -331,7 +331,7 @@ func TestProxy_Start_PortInUse(t *testing.T) {
}
err := proxy1.Start()
require.NoError(t, err)
defer proxy1.Stop()
defer func() { _ = proxy1.Stop() }()
// Get the actual port
addr := proxy1.listener.Addr().(*net.TCPAddr)
@@ -353,9 +353,9 @@ func TestProxy_Start_PortInUse(t *testing.T) {
// 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 string
}{
{
name: "empty headers",
+17 -6
View File
@@ -1,3 +1,14 @@
// Package k8s provides Kubernetes client management, resource resolution,
// and port-forwarding capabilities for kportal.
//
// Key components:
// - ClientPool: Thread-safe management of Kubernetes clients per context
// - ResourceResolver: Resolves pod/service/selector targets to actual pods
// - PortForwarder: Establishes and manages port-forward connections
// - Discovery: Provides resource discovery for the UI wizards
//
// The package handles automatic pod restart detection through re-resolution,
// caching with 30-second TTL, and graceful connection management.
package k8s
import (
@@ -12,10 +23,10 @@ import (
// ClientPool manages Kubernetes clients per context with thread-safe access.
type ClientPool struct {
mu sync.RWMutex
loader clientcmd.ClientConfig
clients map[string]*kubernetes.Clientset
configs map[string]*rest.Config
loader clientcmd.ClientConfig
mu sync.RWMutex
}
// NewClientPool creates a new ClientPool instance.
@@ -51,8 +62,8 @@ func (p *ClientPool) GetClient(contextName string) (*kubernetes.Clientset, error
defer p.mu.Unlock()
// Double-check in case another goroutine created it while we waited
if client, exists := p.clients[contextName]; exists {
return client, nil
if cachedClient, ok := p.clients[contextName]; ok {
return cachedClient, nil
}
// Create new client
@@ -91,8 +102,8 @@ func (p *ClientPool) GetRestConfig(contextName string) (*rest.Config, error) {
defer p.mu.Unlock()
// Double-check in case another goroutine created it while we waited
if config, exists := p.configs[contextName]; exists {
return config, nil
if cachedConfig, ok := p.configs[contextName]; ok {
return cachedConfig, nil
}
// Create new config
+2 -2
View File
@@ -146,8 +146,8 @@ func TestClientPool_ThreadSafety(t *testing.T) {
go func() {
pool.ClearCache()
pool.RemoveContext("test-context")
pool.GetCurrentContext()
pool.ListContexts()
_, _ = pool.GetCurrentContext()
_, _ = pool.ListContexts()
done <- true
}()
}
+6 -6
View File
@@ -28,11 +28,11 @@ func NewDiscovery(pool *ClientPool) *Discovery {
// PodInfo contains information about a pod relevant for port forwarding.
type PodInfo struct {
Created metav1.Time
Name string
Namespace string
Containers []ContainerInfo
Status string
Created metav1.Time
Containers []ContainerInfo
}
// ContainerInfo contains information about a container within a pod.
@@ -44,17 +44,17 @@ type ContainerInfo struct {
// PortInfo describes a port exposed by a container or service.
type PortInfo struct {
Name string
Port int32
TargetPort int32 // For services: the actual pod port to forward to
Protocol string
Port int32
TargetPort int32
}
// ServiceInfo contains information about a service.
type ServiceInfo struct {
Name string
Namespace string
Ports []PortInfo
Type string
Ports []PortInfo
}
// ListContexts returns all available Kubernetes contexts from kubeconfig.
@@ -356,6 +356,6 @@ func CheckPortAvailability(port int) (bool, string, error) {
}
// Port is available, close the listener
listener.Close()
_ = listener.Close()
return true, "", nil
}
+4 -4
View File
@@ -14,12 +14,12 @@ import (
func TestResolveTargetPort(t *testing.T) {
tests := []struct {
name string
servicePort corev1.ServicePort
service *corev1.Service
name string
description string
servicePort corev1.ServicePort
pods []corev1.Pod
expectedPort int32
description string
}{
{
name: "numeric targetPort",
@@ -228,7 +228,7 @@ func TestResolveTargetPort(t *testing.T) {
for i := range tt.pods {
objects = append(objects, &tt.pods[i])
}
fakeClient := fake.NewSimpleClientset(objects...)
fakeClient := fake.NewClientset(objects...)
// Create discovery instance (we only need it to call resolveTargetPort)
d := &Discovery{}
+8 -8
View File
@@ -49,16 +49,16 @@ func (pf *PortForwarder) SetDialTimeout(timeout time.Duration) {
// ForwardRequest contains the parameters for a port-forward request.
type ForwardRequest struct {
ContextName string // Kubernetes context name
Namespace string // Namespace
Resource string // Resource (pod/name or service/name)
Selector string // Label selector (for pod resolution)
LocalPort int // Local port
RemotePort int // Remote port
Out io.Writer
ErrOut io.Writer
StopChan chan struct{}
ReadyChan chan struct{}
Out io.Writer // Output writer for logs
ErrOut io.Writer // Error output writer
ContextName string
Namespace string
Resource string
Selector string
LocalPort int
RemotePort int
}
// Forward establishes a port-forward connection to a Kubernetes resource.
+5 -5
View File
@@ -19,15 +19,15 @@ const (
// ResolvedResource represents a resolved Kubernetes resource.
type ResolvedResource struct {
Name string // The resolved pod or service name
Namespace string // The namespace
Timestamp time.Time // When this was resolved
Timestamp time.Time
Name string
Namespace string
}
// cacheEntry stores a cached resolution result with expiry.
type cacheEntry struct {
resource ResolvedResource
expiresAt time.Time
resource ResolvedResource
}
// ResourceResolver resolves Kubernetes resources with caching.
@@ -188,7 +188,7 @@ func (r *ResourceResolver) getFromCache(key string) string {
// 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) {
if expiredEntry, ok := r.cache[key]; ok && time.Now().After(expiredEntry.expiresAt) {
delete(r.cache, key)
}
r.cacheMu.Unlock()
+3 -3
View File
@@ -17,10 +17,10 @@ func TestKlogWriter(t *testing.T) {
input string
expectedLevel string
expectedMsg string
description string
loggerLevel Level
loggerFormat Format
shouldLog bool
description string
}{
{
name: "info level log",
@@ -162,9 +162,9 @@ func TestKlogWriter(t *testing.T) {
func TestKlogWriterBuffering(t *testing.T) {
tests := []struct {
name string
description string
writes []string
expectCount int
description string
}{
{
name: "single complete line",
@@ -264,7 +264,7 @@ func TestKlogWriterConcurrency(t *testing.T) {
go func(id int) {
for j := 0; j < numWrites; j++ {
msg := fmt.Sprintf("I1124 12:34:56.789012 12345 test.go:123] Message from goroutine %d iteration %d\n", id, j)
klogWriter.Write([]byte(msg))
_, _ = klogWriter.Write([]byte(msg))
}
done <- true
}(i)
+7 -7
View File
@@ -14,12 +14,12 @@ import (
func TestLogrAdapter_Info(t *testing.T) {
tests := []struct {
name string
loggerLevel Level
logrLevel int
message string
keysAndValues []interface{}
expectOutput bool
expectContains []string
loggerLevel Level
logrLevel int
expectOutput bool
}{
{
name: "info log v0 with debug logger",
@@ -109,13 +109,13 @@ func TestLogrAdapter_Info(t *testing.T) {
func TestLogrAdapter_Error(t *testing.T) {
tests := []struct {
name string
loggerLevel Level
err error
name string
message string
keysAndValues []interface{}
expectOutput bool
expectContains []string
loggerLevel Level
expectOutput bool
}{
{
name: "error with error object",
@@ -179,9 +179,9 @@ func TestLogrAdapter_Error(t *testing.T) {
func TestLogrAdapter_WithName(t *testing.T) {
tests := []struct {
name string
loggerNames []string
message string
expectContains string
loggerNames []string
}{
{
name: "single logger name",
+36 -6
View File
@@ -1,3 +1,19 @@
// Package logger provides structured logging with support for text and JSON
// output formats. It intercepts Kubernetes client-go logs and routes them
// through the structured logger.
//
// The package provides both instance-based and global logging:
//
// // Instance-based logging
// log := logger.New(logger.LevelInfo, logger.FormatJSON, os.Stderr)
// log.Info("message", "key", "value")
//
// // Global logging (after Init)
// logger.Init(logger.LevelInfo, logger.FormatText, os.Stderr)
// logger.Info("message", "key", "value")
//
// Log levels: DEBUG < INFO < WARN < ERROR
// Output formats: FormatText (human-readable), FormatJSON (structured)
package logger
import (
@@ -9,36 +25,50 @@ import (
"time"
)
// Level represents the logging level.
// Higher levels include all lower levels (e.g., LevelInfo includes WARN and ERROR).
type Level int
const (
// LevelDebug is for detailed troubleshooting information.
LevelDebug Level = iota
// LevelInfo is for general operational information.
LevelInfo
// LevelWarn is for unexpected but handled situations.
LevelWarn
// LevelError is for failures that require attention.
LevelError
)
// Format represents the output format for log entries.
type Format int
const (
// FormatText outputs human-readable log lines.
FormatText Format = iota
// FormatJSON outputs structured JSON log entries.
FormatJSON
)
// Logger is a structured logger with configurable level and format.
// It is safe for concurrent use.
type Logger struct {
output io.Writer
level Level
format Format
output io.Writer
mu sync.Mutex // Protects concurrent writes to output
mu sync.Mutex
}
// logEntry represents a single log entry for JSON output.
type logEntry struct {
Time string `json:"time"`
Level string `json:"level"`
Message string `json:"message"`
Fields map[string]interface{} `json:"fields,omitempty"`
Fields map[string]any `json:"fields,omitempty"`
Time string `json:"time"`
Level string `json:"level"`
Message string `json:"message"`
}
// New creates a new Logger with the specified level, format, and output writer.
// If output is nil, os.Stderr is used.
func New(level Level, format Format, output io.Writer) *Logger {
if output == nil {
output = os.Stderr
+19 -19
View File
@@ -13,13 +13,13 @@ import (
func TestLoggerTextFormat(t *testing.T) {
tests := []struct {
fields map[string]interface{}
name string
message string
expectContains []string
level Level
logLevel Level
message string
fields map[string]interface{}
expectOutput bool
expectContains []string
}{
{
name: "info logged at info level",
@@ -138,13 +138,13 @@ func TestLoggerTextFormat(t *testing.T) {
func TestLoggerJSONFormat(t *testing.T) {
tests := []struct {
fields map[string]interface{}
name string
message string
expectLevel string
level Level
logLevel Level
message string
fields map[string]interface{}
expectOutput bool
expectLevel string
}{
{
name: "info logged at info level",
@@ -268,12 +268,12 @@ func TestLoggerJSONFormat(t *testing.T) {
func TestGlobalLogger(t *testing.T) {
tests := []struct {
name string
initLevel Level
initFormat Format
logFunc func(string, ...map[string]interface{})
name string
message string
expectContains string
initLevel Level
initFormat Format
}{
{
name: "global info logger text",
@@ -321,9 +321,9 @@ func TestGlobalLogger(t *testing.T) {
func TestLogLevelsFiltering(t *testing.T) {
tests := []struct {
name string
loggerLevel Level
logAtLevels []Level
expectOutputs []bool
loggerLevel Level
}{
{
name: "debug level logs everything",
@@ -387,14 +387,14 @@ func TestLoggerNilOutput(t *testing.T) {
func TestLevelToString(t *testing.T) {
tests := []struct {
level Level
expected string
level Level
}{
{LevelDebug, "DEBUG"},
{LevelInfo, "INFO"},
{LevelWarn, "WARN"},
{LevelError, "ERROR"},
{Level(999), "UNKNOWN"},
{level: LevelDebug, expected: "DEBUG"},
{level: LevelInfo, expected: "INFO"},
{level: LevelWarn, expected: "WARN"},
{level: LevelError, expected: "ERROR"},
{level: Level(999), expected: "UNKNOWN"},
}
for _, tt := range tests {
@@ -407,8 +407,8 @@ func TestLevelToString(t *testing.T) {
func TestJSONFieldTypes(t *testing.T) {
tests := []struct {
name string
fields map[string]interface{}
name string
}{
{
name: "string fields",
@@ -467,10 +467,10 @@ func TestJSONFieldTypes(t *testing.T) {
func TestInitWithCustomOutput(t *testing.T) {
tests := []struct {
name string
output io.Writer
expectDiscard bool
name string
description string
expectDiscard bool
}{
{
name: "init with custom buffer",
+29 -21
View File
@@ -1,3 +1,16 @@
// Package mdns provides multicast DNS (mDNS/Bonjour) hostname publishing
// for port forwards. When enabled, forwards with aliases can be accessed
// via <alias>.local hostnames on the local network.
//
// The Publisher manages mDNS service registrations using zeroconf:
// - Registers hostnames when forwards become active
// - Unregisters hostnames when forwards are stopped
// - Provides service discovery via the _kportal._tcp service type
//
// mDNS discovery commands:
//
// dns-sd -B _kportal._tcp local # macOS
// avahi-browse -t _kportal._tcp # Linux
package mdns
import (
@@ -23,11 +36,11 @@ const (
// 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
servers map[string]*zeroconf.Server
aliases map[string]string
localIPs []string
mu sync.RWMutex
enabled bool
}
// NewPublisher creates a new mDNS Publisher.
@@ -89,6 +102,11 @@ func (p *Publisher) Register(forwardID, alias string, localPort int) error {
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),
@@ -151,6 +169,13 @@ func (p *Publisher) Stop() {
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()
@@ -183,29 +208,12 @@ func shutdownWithTimeout(server *zeroconf.Server, forwardID 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
+25 -16
View File
@@ -13,15 +13,17 @@ import (
func TestNewPublisher_Disabled(t *testing.T) {
p := NewPublisher(false)
assert.False(t, p.IsEnabled())
assert.Equal(t, 0, p.GetRegisteredCount())
// When disabled, Register should succeed but be a no-op
err := p.Register("forward-1", "test-alias", 8080)
assert.NoError(t, err)
}
func TestNewPublisher_Enabled(t *testing.T) {
p := NewPublisher(true)
defer p.Stop()
assert.True(t, p.IsEnabled())
assert.Equal(t, 0, p.GetRegisteredCount())
// Enabled publisher should be created successfully
assert.NotNil(t, p)
}
func TestRegister_WhenDisabled_NoOp(t *testing.T) {
@@ -30,16 +32,17 @@ func TestRegister_WhenDisabled_NoOp(t *testing.T) {
err := p.Register("forward-1", "test-alias", 8080)
assert.NoError(t, err)
assert.Equal(t, 0, p.GetRegisteredCount())
// Unregister should also be safe when disabled
p.Unregister("forward-1")
}
func TestRegister_EmptyAlias_NoOp(t *testing.T) {
p := NewPublisher(true)
defer p.Stop()
err := p.Register("forward-1", "", 8080)
assert.NoError(t, err)
assert.Equal(t, 0, p.GetRegisteredCount())
}
func TestUnregister_WhenDisabled_NoOp(t *testing.T) {
@@ -51,10 +54,10 @@ func TestUnregister_WhenDisabled_NoOp(t *testing.T) {
func TestUnregister_NotRegistered_NoOp(t *testing.T) {
p := NewPublisher(true)
defer p.Stop()
// Should not panic
p.Unregister("non-existent")
assert.Equal(t, 0, p.GetRegisteredCount())
}
func TestStop_WhenDisabled_NoOp(t *testing.T) {
@@ -69,7 +72,6 @@ func TestStop_WhenNoRegistrations(t *testing.T) {
// Should not panic
p.Stop()
assert.Equal(t, 0, p.GetRegisteredCount())
}
func TestGetLocalIPs(t *testing.T) {
@@ -84,6 +86,11 @@ func TestGetLocalIPs(t *testing.T) {
}
}
func TestGetHostname(t *testing.T) {
hostname := GetHostname("myapp")
assert.Equal(t, "myapp.local", hostname)
}
// Integration tests - only run when explicitly requested
// These tests actually register mDNS services and require network access
@@ -96,9 +103,10 @@ func TestRegister_Integration(t *testing.T) {
defer p.Stop()
err := p.Register("forward-1", "test-service", 8080)
assert.NoError(t, err)
assert.Equal(t, 1, p.GetRegisteredCount())
// Verify by checking that unregister doesn't panic
p.Unregister("forward-1")
}
func TestRegister_Duplicate_Idempotent_Integration(t *testing.T) {
@@ -112,12 +120,10 @@ func TestRegister_Duplicate_Idempotent_Integration(t *testing.T) {
// 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) {
@@ -135,7 +141,6 @@ func TestRegister_MultipleForwards_Integration(t *testing.T) {
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) {
@@ -146,9 +151,13 @@ func TestUnregister_Success_Integration(t *testing.T) {
p := NewPublisher(true)
defer p.Stop()
p.Register("forward-1", "test-service", 8080)
assert.Equal(t, 1, p.GetRegisteredCount())
err := p.Register("forward-1", "test-service", 8080)
assert.NoError(t, err)
// Unregister should not panic and should handle it gracefully
p.Unregister("forward-1")
assert.Equal(t, 0, p.GetRegisteredCount())
// Re-registering should work after unregister
err = p.Register("forward-1", "test-service-2", 8080)
assert.NoError(t, err)
}
+30 -4
View File
@@ -1,3 +1,19 @@
// Package retry provides exponential backoff with jitter for retry logic.
// It implements a backoff sequence of 1s → 2s → 4s → 8s → 10s (max),
// with 10% random jitter to prevent thundering herd problems.
//
// Basic usage:
//
// backoff := retry.NewBackoff()
// for {
// err := doSomething()
// if err == nil {
// backoff.Reset()
// break
// }
// delay := backoff.Next()
// time.Sleep(delay)
// }
package retry
import (
@@ -11,20 +27,24 @@ const (
initialDelay = 1 * time.Second
maxDelay = 10 * time.Second
jitterPct = 0.1 // 10% jitter
// maxAttempt caps the exponent to prevent math.Pow overflow
// 2^30 seconds is ~34 years, well above maxDelay, so this is safe
maxAttempt = 30
)
// Backoff implements exponential backoff with jitter for retry logic.
// The backoff sequence is: 1s → 2s → 4s → 8s → 10s (max, then stays at 10s).
type Backoff struct {
attempt int
rng *rand.Rand
attempt int
}
// NewBackoff creates a new Backoff instance with a seeded random number generator.
func NewBackoff() *Backoff {
return &Backoff{
attempt: 0,
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
// #nosec G404 -- math/rand is appropriate for backoff jitter; cryptographic randomness not needed
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
@@ -32,8 +52,14 @@ func NewBackoff() *Backoff {
// The duration follows exponential backoff: 1s → 2s → 4s → 8s → 10s (max).
// A 10% jitter is added to prevent thundering herd effects.
func (b *Backoff) Next() time.Duration {
// Cap attempt to prevent overflow in math.Pow
attempt := b.attempt
if attempt > maxAttempt {
attempt = maxAttempt
}
// Calculate base delay: 2^attempt seconds
exp := math.Pow(2, float64(b.attempt))
exp := math.Pow(2, float64(attempt))
delay := time.Duration(exp) * time.Second
// Cap at max delay
@@ -43,7 +69,7 @@ func (b *Backoff) Next() time.Duration {
// Add jitter (±10%)
jitter := b.calculateJitter(delay)
delay = delay + jitter
delay += jitter
b.attempt++
return delay
+349 -236
View File
@@ -1,3 +1,22 @@
// Package ui provides the terminal user interface for kportal using bubbletea.
// It displays port-forward status in an interactive table and provides wizards
// for adding, editing, and removing forwards.
//
// The main components are:
// - BubbleTeaUI: The interactive TUI with table display and modal dialogs
// - TableUI: A simpler non-interactive status display for verbose mode
// - Wizards: Step-by-step interfaces for configuration changes
// - Controller: Coordinates UI with the forward manager
//
// Key bindings in the main view:
// - ↑↓/jk: Navigate forwards
// - Space: Toggle forward enabled/disabled
// - n: New forward wizard
// - e: Edit forward wizard
// - d: Delete forward
// - b: Benchmark forward
// - l: View HTTP logs
// - q: Quit
package ui
import (
@@ -35,8 +54,8 @@ type ForwardErrorMsg struct {
// ForwardAddMsg is sent when a new forward is added
type ForwardAddMsg struct {
ID string
Forward *ForwardStatus
ID string
}
// ForwardRemoveMsg is sent when a forward is removed
@@ -50,48 +69,32 @@ type HTTPLogSubscriber func(forwardID string, callback func(entry HTTPLogEntry))
// BubbleTeaUI is a bubbletea-based terminal UI
type BubbleTeaUI struct {
mu sync.RWMutex
program *tea.Program
forwards map[string]*ForwardStatus
forwardOrder []string
selectedIndex int
disabledMap map[string]bool
toggleCallback func(id string, enable bool)
version string
errors map[string]string // Track error messages by forward ID
// Update notification
updateAvailable bool
updateVersion string
updateURL string
// Modal wizard state
viewMode ViewMode
addWizard *AddWizardState
removeWizard *RemoveWizardState
// Delete confirmation state
deleteConfirming bool
discovery *k8s.Discovery
program *tea.Program
forwards map[string]*ForwardStatus
benchmarkState *BenchmarkState
httpLogSubscriber HTTPLogSubscriber
disabledMap map[string]bool
toggleCallback func(id string, enable bool)
httpLogCleanup func()
httpLogState *HTTPLogState
errors map[string]string
mutator *config.Mutator
removeWizard *RemoveWizardState
addWizard *AddWizardState
updateVersion string
updateURL string
configPath string
deleteConfirmID string
deleteConfirmAlias string
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
discovery *k8s.Discovery
mutator *config.Mutator
configPath string
// Manager for accessing workers
httpLogSubscriber HTTPLogSubscriber
version string
forwardOrder []string
viewMode ViewMode
deleteConfirmCursor int
selectedIndex int
mu sync.RWMutex
deleteConfirming bool
updateAvailable bool
}
// bubbletea model
@@ -168,6 +171,8 @@ func (ui *BubbleTeaUI) AddForward(id string, fwd *config.Forward) {
if existing, ok := ui.forwards[id]; ok {
existing.Status = "Starting"
ui.disabledMap[id] = false
// Clear any previous error when re-enabling
delete(ui.errors, id)
ui.mu.Unlock()
if ui.program != nil {
@@ -176,15 +181,12 @@ func (ui *BubbleTeaUI) AddForward(id string, fwd *config.Forward) {
return
}
// Parse resource
// Parse resource (e.g., "pod/my-app" -> type="pod", name="my-app")
resourceType := "pod"
resourceName := fwd.Resource
for idx := 0; idx < len(fwd.Resource); idx++ {
if fwd.Resource[idx] == '/' {
resourceType = fwd.Resource[:idx]
resourceName = fwd.Resource[idx+1:]
break
}
if parts := strings.SplitN(fwd.Resource, "/", 2); len(parts) == 2 {
resourceType = parts[0]
resourceName = parts[1]
}
alias := fwd.Alias
@@ -380,10 +382,10 @@ func (m model) View() string {
// Fallback to reasonable defaults if dimensions not yet received
if termWidth == 0 {
termWidth = 120
termWidth = DefaultTermWidth
}
if termHeight == 0 {
termHeight = 40
termHeight = DefaultTermHeight
}
// Overlay delete confirmation if active
@@ -411,28 +413,98 @@ func (m model) View() string {
}
}
// mainViewColors holds the color palette for the main view
type mainViewColors struct {
header lipgloss.Color
active lipgloss.Color
warning lipgloss.Color
errorColor lipgloss.Color
muted lipgloss.Color
selectedBg lipgloss.Color
selectedFg lipgloss.Color
}
// defaultMainViewColors returns the default color palette
func defaultMainViewColors() mainViewColors {
return mainViewColors{
header: lipgloss.Color("220"), // Yellow
active: lipgloss.Color("46"), // Green
warning: lipgloss.Color("220"), // Yellow
errorColor: lipgloss.Color("196"), // Red
muted: lipgloss.Color("240"), // Gray
selectedBg: lipgloss.Color("240"), // Gray background
selectedFg: lipgloss.Color("230"), // Light foreground
}
}
// keyBinding represents a keyboard shortcut and its description
type keyBinding struct {
key string
desc string
}
// mainViewKeyBindings returns the key bindings for the main view
func mainViewKeyBindings() []keyBinding {
return []keyBinding{
{"↑↓/jk", "Navigate"},
{"Space", "Toggle"},
{"n", "New"},
{"e", "Edit"},
{"d", "Delete"},
{"b", "Bench"},
{"l", "Logs"},
{"q", "Quit"},
}
}
func (m model) renderMainView() string {
m.ui.mu.RLock()
defer m.ui.mu.RUnlock()
var b strings.Builder
colors := defaultMainViewColors()
// Get terminal dimensions for proper sizing
termHeight := m.termHeight
if termHeight == 0 {
termHeight = 40 // Fallback
termWidth, termHeight := m.getTermDimensions()
// Render title header
b.WriteString(m.renderTitle(colors.header))
// Render forwards table or empty message
if len(m.ui.forwardOrder) == 0 {
b.WriteString(m.renderEmptyMessage(colors.muted))
} else {
b.WriteString(m.renderForwardsTable(colors))
}
// Color palette
headerColor := lipgloss.Color("220") // Yellow
activeColor := lipgloss.Color("46") // Green
warningColor := lipgloss.Color("220") // Yellow
errorColor := lipgloss.Color("196") // Red
mutedColor := lipgloss.Color("240") // Gray
selectedBg := lipgloss.Color("240") // Gray background
selectedFg := lipgloss.Color("230") // Light foreground
// Render error section if any errors exist
if len(m.ui.errors) > 0 {
b.WriteString(m.renderErrorSection())
}
// Render footer with proper spacing
b.WriteString(m.renderFooterWithSpacing(termWidth, termHeight, &b))
return b.String()
}
// getTermDimensions returns terminal dimensions with fallback defaults
func (m model) getTermDimensions() (width, height int) {
width = m.termWidth
height = m.termHeight
if width == 0 {
width = DefaultTermWidth
}
if height == 0 {
height = DefaultTermHeight
}
return
}
// renderTitle renders the title bar with version and optional update notification
func (m model) renderTitle(headerColor lipgloss.Color) string {
var b strings.Builder
// Title with version
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(headerColor).
@@ -451,180 +523,222 @@ func (m model) renderMainView() string {
}
b.WriteString("\n\n")
// No forwards
if len(m.ui.forwardOrder) == 0 {
disabledStyle := lipgloss.NewStyle().Foreground(mutedColor)
b.WriteString(disabledStyle.Render("No forwards configured\n"))
} else {
// Build table rows
var rows [][]string
for _, id := range m.ui.forwardOrder {
return b.String()
}
// renderEmptyMessage renders the message shown when no forwards are configured
func (m model) renderEmptyMessage(mutedColor lipgloss.Color) string {
disabledStyle := lipgloss.NewStyle().Foreground(mutedColor)
return disabledStyle.Render("No forwards configured\n")
}
// renderForwardsTable renders the forwards table with all styling
func (m model) renderForwardsTable(colors mainViewColors) string {
var b strings.Builder
// Build table rows
rows := m.buildTableRows()
// 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(m.createTableStyleFunc(colors))
b.WriteString(t.Render())
b.WriteString("\n")
return b.String()
}
// buildTableRows builds the data rows for the forwards table
func (m model) buildTableRows() [][]string {
var rows [][]string
for _, id := range m.ui.forwardOrder {
fwd, ok := m.ui.forwards[id]
if !ok {
continue
}
statusIcon, statusText := m.getStatusIconAndText(id, fwd)
rows = append(rows, []string{
truncate(fwd.Context, ColumnWidthContext),
truncate(fwd.Namespace, ColumnWidthNamespace),
truncate(fwd.Alias, ColumnWidthAlias),
truncate(fwd.Type, ColumnWidthType),
truncate(fwd.Resource, ColumnWidthResource),
fmt.Sprintf("%d", fwd.RemotePort),
fmt.Sprintf("%d", fwd.LocalPort),
statusIcon + " " + statusText,
})
}
return rows
}
// getStatusIconAndText returns the appropriate status icon and text for a forward
func (m model) getStatusIconAndText(id string, fwd *ForwardStatus) (icon, text string) {
icon = "●"
text = fwd.Status
if m.ui.isForwardDisabled(id) {
return "○", "Disabled"
}
switch fwd.Status {
case "Starting":
icon = "○"
case "Reconnecting":
icon = "◐"
case "Error":
icon = "✗"
}
return icon, text
}
// createTableStyleFunc creates the style function for the forwards table
func (m model) createTableStyleFunc(colors mainViewColors) func(row, col int) lipgloss.Style {
return func(row, col int) lipgloss.Style {
// Header row
if row == table.HeaderRow {
return lipgloss.NewStyle().
Bold(true).
Foreground(colors.header).
Padding(0, 1)
}
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]
if !ok {
continue
isSelected := row == m.ui.selectedIndex
isDisabled := m.ui.isForwardDisabled(id)
// Selected row gets background highlight
if isSelected {
return baseStyle.
Background(colors.selectedBg).
Foreground(colors.selectedFg)
}
isDisabled := m.ui.disabledMap[id] || fwd.Status == "Disabled"
// Status icon and text
statusIcon := "●"
statusText := fwd.Status
// Disabled rows are muted
if isDisabled {
statusIcon = "○"
statusText = "Disabled"
} else {
return baseStyle.Foreground(colors.muted)
}
// Status column gets colored based on status
if col == ColumnStatus && ok {
switch fwd.Status {
case "Starting":
statusIcon = "○"
case "Reconnecting":
statusIcon = "◐"
case "Active":
return baseStyle.Foreground(colors.active)
case "Starting", "Reconnecting":
return baseStyle.Foreground(colors.warning)
case "Error":
statusIcon = "✗"
}
}
rows = append(rows, []string{
truncate(fwd.Context, 14),
truncate(fwd.Namespace, 16),
truncate(fwd.Alias, 18),
truncate(fwd.Type, 8),
truncate(fwd.Resource, 20),
fmt.Sprintf("%d", fwd.RemotePort),
fmt.Sprintf("%d", fwd.LocalPort),
statusIcon + " " + statusText,
})
}
// 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)
if len(m.ui.errors) > 0 {
b.WriteString("\n\n")
errorHeaderStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("196"))
b.WriteString(errorHeaderStyle.Render("Errors:"))
b.WriteString("\n")
errorLineStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Width(118). // Slightly less than table width (120) for padding
MaxWidth(118)
for id, errMsg := range m.ui.errors {
// Find the forward to display its alias
if fwd, ok := m.ui.forwards[id]; ok {
// Format: " • alias: error message"
prefix := fmt.Sprintf(" • %s: ", fwd.Alias)
// Wrap the error message if it's too long
// Max line length is 118, subtract prefix length
maxErrLen := 118 - len(prefix)
wrappedMsg := wrapText(errMsg, maxErrLen)
// Render first line with prefix
lines := strings.Split(wrappedMsg, "\n")
if len(lines) > 0 {
b.WriteString(errorLineStyle.Render(prefix + lines[0]))
b.WriteString("\n")
// Render subsequent lines with indentation
indent := strings.Repeat(" ", len(prefix))
for i := 1; i < len(lines); i++ {
b.WriteString(errorLineStyle.Render(indent + lines[i]))
b.WriteString("\n")
}
return baseStyle.Foreground(colors.errorColor)
}
}
}
return baseStyle
}
}
// renderErrorSection renders the error display section
func (m model) renderErrorSection() string {
var b strings.Builder
b.WriteString("\n\n")
errorHeaderStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("196"))
b.WriteString(errorHeaderStyle.Render("Errors:"))
b.WriteString("\n")
errorLineStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Width(ErrorDisplayWidth).
MaxWidth(ErrorDisplayWidth)
for id, errMsg := range m.ui.errors {
// Find the forward to display its alias
if fwd, ok := m.ui.forwards[id]; ok {
b.WriteString(m.renderErrorLine(fwd.Alias, errMsg, errorLineStyle))
}
}
return b.String()
}
// renderErrorLine renders a single error line with proper wrapping
func (m model) renderErrorLine(alias, errMsg string, style lipgloss.Style) string {
var b strings.Builder
// Format: " • alias: error message"
prefix := fmt.Sprintf(" • %s: ", alias)
// Wrap the error message if it's too long
maxErrLen := ErrorDisplayWidth - len(prefix)
wrappedMsg := wrapText(errMsg, maxErrLen)
// Render first line with prefix
lines := strings.Split(wrappedMsg, "\n")
if len(lines) > 0 {
b.WriteString(style.Render(prefix + lines[0]))
b.WriteString("\n")
// Render subsequent lines with indentation
indent := strings.Repeat(" ", len(prefix))
for i := 1; i < len(lines); i++ {
b.WriteString(style.Render(indent + lines[i]))
b.WriteString("\n")
}
}
return b.String()
}
// renderFooterWithSpacing renders the footer with proper vertical spacing
func (m model) renderFooterWithSpacing(termWidth, termHeight int, content *strings.Builder) string {
var b strings.Builder
// Calculate current content height
currentContent := b.String()
currentContent := content.String()
currentLines := strings.Count(currentContent, "\n") + 1
// Footer styles
// Build footer content
footerLines := m.buildFooterLines(termWidth)
// Calculate footer height and add spacing
footerHeight := len(footerLines) + 1 // +1 for the blank line before footer
remainingLines := termHeight - currentLines - footerHeight
if remainingLines > 0 {
b.WriteString(strings.Repeat("\n", remainingLines))
}
// Add footer at bottom
footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
b.WriteString("\n")
for i, line := range footerLines {
if i > 0 {
b.WriteString("\n")
}
b.WriteString(footerStyle.Render(line))
}
return b.String()
}
// buildFooterLines builds the footer lines that fit within terminal width
func (m model) buildFooterLines(termWidth int) []string {
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
bindings := mainViewKeyBindings()
// Get terminal width for footer wrapping
termWidth := m.termWidth
if termWidth == 0 {
termWidth = 120
}
// Define key bindings as structured data for flexible rendering
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
@@ -676,23 +790,7 @@ func (m model) renderMainView() string {
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
if remainingLines > 0 {
b.WriteString(strings.Repeat("\n", remainingLines))
}
// Add footer at bottom
b.WriteString("\n")
for i, line := range footerLines {
if i > 0 {
b.WriteString("\n")
}
b.WriteString(footerStyle.Render(line))
}
return b.String()
return footerLines
}
// wrapText wraps text to the specified width, breaking at word boundaries
@@ -835,3 +933,18 @@ func (ui *BubbleTeaUI) toggleSelected() {
go ui.toggleCallback(selectedID, !newState) // enable is inverse of disabled
}
}
// isForwardDisabled checks if a forward is disabled.
// A forward is considered disabled if either:
// 1. The user has disabled it via the UI (tracked in disabledMap)
// 2. The forward's status is "Disabled" (from the manager)
// Caller must hold ui.mu.RLock or ui.mu.Lock.
func (ui *BubbleTeaUI) isForwardDisabled(id string) bool {
if ui.disabledMap[id] {
return true
}
if fwd, ok := ui.forwards[id]; ok && fwd.Status == "Disabled" {
return true
}
return false
}
+254 -1
View File
@@ -243,9 +243,9 @@ func TestBubbleTeaUI_Remove_ClearsErrors(t *testing.T) {
func TestBubbleTeaUI_Remove_AdjustsSelectedIndex(t *testing.T) {
tests := []struct {
name string
removeID string
forwards []string
selectedIndex int
removeID string
expectedIndex int
expectedRemaining int
}{
@@ -527,3 +527,256 @@ func TestBubbleTeaUI_ResetDeleteConfirmation(t *testing.T) {
assert.Empty(t, ui.deleteConfirmAlias)
assert.Equal(t, 0, ui.deleteConfirmCursor)
}
// TestBubbleTeaUI_IsForwardDisabled tests the disabled state helper
func TestBubbleTeaUI_IsForwardDisabled(t *testing.T) {
tests := []struct {
name string
forwardStatus string
disabledMap bool
expectedResult bool
}{
{
name: "not disabled in map, Active status",
disabledMap: false,
forwardStatus: "Active",
expectedResult: false,
},
{
name: "disabled in map, Active status",
disabledMap: true,
forwardStatus: "Active",
expectedResult: true,
},
{
name: "not disabled in map, Disabled status",
disabledMap: false,
forwardStatus: "Disabled",
expectedResult: true,
},
{
name: "both disabled in map and Disabled status",
disabledMap: true,
forwardStatus: "Disabled",
expectedResult: true,
},
{
name: "not disabled in map, Error status",
disabledMap: false,
forwardStatus: "Error",
expectedResult: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(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.mu.Lock()
ui.disabledMap["test-id"] = tt.disabledMap
ui.forwards["test-id"].Status = tt.forwardStatus
ui.mu.Unlock()
ui.mu.RLock()
result := ui.isForwardDisabled("test-id")
ui.mu.RUnlock()
assert.Equal(t, tt.expectedResult, result)
})
}
}
// TestBubbleTeaUI_IsForwardDisabled_NonExistent tests disabled check for non-existent forward
func TestBubbleTeaUI_IsForwardDisabled_NonExistent(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
ui.mu.RLock()
result := ui.isForwardDisabled("non-existent")
ui.mu.RUnlock()
assert.False(t, result, "Non-existent forward should not be disabled")
}
// TestBubbleTeaUI_AddForward_ReEnableClearsError tests that re-enabling clears previous errors
func TestBubbleTeaUI_AddForward_ReEnableClearsError(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)
// Set error and disable
ui.SetError("test-id", "connection refused")
ui.mu.Lock()
ui.disabledMap["test-id"] = true
ui.forwards["test-id"].Status = "Disabled"
ui.mu.Unlock()
// Verify error exists
ui.mu.RLock()
_, hasError := ui.errors["test-id"]
ui.mu.RUnlock()
assert.True(t, hasError, "Error should exist before re-enable")
// Re-enable (re-add)
ui.AddForward("test-id", fwd)
// Verify error is cleared
ui.mu.RLock()
_, hasError = ui.errors["test-id"]
ui.mu.RUnlock()
assert.False(t, hasError, "Error should be cleared after re-enable")
}
// TestWrapText tests the text wrapping function
func TestWrapText(t *testing.T) {
tests := []struct {
name string
text string
expected string
width int
}{
{
name: "short text fits",
text: "hello world",
width: 20,
expected: "hello world",
},
{
name: "single long word",
text: "superlongwordthatexceedswidth",
width: 10,
expected: "superlongwordthatexceedswidth",
},
{
name: "wraps at word boundary",
text: "hello world this is a test",
width: 15,
expected: "hello world\nthis is a test",
},
{
name: "multiple wraps",
text: "one two three four five six",
width: 10,
expected: "one two\nthree four\nfive six",
},
{
name: "empty string",
text: "",
width: 10,
expected: "",
},
{
name: "single word",
text: "hello",
width: 10,
expected: "hello",
},
{
name: "exact width",
text: "hello wor",
width: 9,
expected: "hello wor",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := wrapText(tt.text, tt.width)
assert.Equal(t, tt.expected, result)
})
}
}
// TestBubbleTeaUI_AddForward_ResourceParsing tests various resource format parsing
func TestBubbleTeaUI_AddForward_ResourceParsing(t *testing.T) {
tests := []struct {
name string
resource string
expectedType string
expectedName string
}{
{
name: "pod with prefix",
resource: "pod/my-app",
expectedType: "pod",
expectedName: "my-app",
},
{
name: "service resource",
resource: "service/postgres",
expectedType: "service",
expectedName: "postgres",
},
{
name: "deployment resource",
resource: "deployment/api-server",
expectedType: "deployment",
expectedName: "api-server",
},
{
name: "no type prefix (pod default)",
resource: "my-pod",
expectedType: "pod",
expectedName: "my-pod",
},
{
name: "resource with multiple slashes",
resource: "custom/type/resource",
expectedType: "custom",
expectedName: "type/resource",
},
{
name: "empty resource",
resource: "",
expectedType: "pod",
expectedName: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ui := NewBubbleTeaUI(nil, "1.0.0")
fwd := &config.Forward{
Resource: tt.resource,
Port: 8080,
LocalPort: 8080,
}
ui.AddForward("test-id", fwd)
ui.mu.RLock()
status := ui.forwards["test-id"]
ui.mu.RUnlock()
assert.Equal(t, tt.expectedType, status.Type)
assert.Equal(t, tt.expectedName, status.Resource)
})
}
}
// TestConstants tests that UI constants are properly defined
func TestConstants(t *testing.T) {
assert.Equal(t, 120, DefaultTermWidth)
assert.Equal(t, 40, DefaultTermHeight)
assert.Equal(t, 7, ColumnStatus)
assert.Equal(t, 14, ColumnWidthContext)
assert.Equal(t, 16, ColumnWidthNamespace)
assert.Equal(t, 18, ColumnWidthAlias)
assert.Equal(t, 8, ColumnWidthType)
assert.Equal(t, 20, ColumnWidthResource)
assert.Equal(t, 118, ErrorDisplayWidth)
assert.Equal(t, 20, ViewportHeight)
}
+6 -3
View File
@@ -82,13 +82,16 @@ func TestMessageTypes(t *testing.T) {
}
assert.Equal(t, 8080, availableMsg.port)
assert.True(t, availableMsg.available)
assert.Equal(t, "Port 8080 available", availableMsg.message)
unavailableMsg := PortCheckedMsg{
port: 8080,
available: false,
message: "Port 8080 in use by process",
}
assert.Equal(t, 8080, unavailableMsg.port)
assert.False(t, unavailableMsg.available)
assert.Equal(t, "Port 8080 in use by process", unavailableMsg.message)
})
t.Run("ForwardSavedMsg", func(t *testing.T) {
@@ -117,10 +120,10 @@ func TestMessageTypes(t *testing.T) {
t.Run("BenchmarkCompleteMsg", func(t *testing.T) {
msg := BenchmarkCompleteMsg{
ForwardID: "fwd-123",
Results: nil,
Error: nil,
}
assert.Equal(t, "fwd-123", msg.ForwardID)
assert.Nil(t, msg.Results)
assert.Nil(t, msg.Error)
})
t.Run("BenchmarkProgressMsg", func(t *testing.T) {
@@ -256,7 +259,7 @@ func TestRunBenchmarkCmd_Cancellation(t *testing.T) {
// Run with timeout to prevent hanging
done := make(chan bool, 1)
var msg interface{}
var msg any
go func() {
msg = cmd()
done <- true
+45
View File
@@ -0,0 +1,45 @@
package ui
// Terminal dimension constants
const (
// DefaultTermWidth is the fallback terminal width when not detected
DefaultTermWidth = 120
// DefaultTermHeight is the fallback terminal height when not detected
DefaultTermHeight = 40
)
// Table column constants
const (
// Column indices in the forwards table
ColumnContext = 0
ColumnNamespace = 1
ColumnAlias = 2
ColumnType = 3
ColumnResource = 4
ColumnRemote = 5
ColumnLocal = 6
ColumnStatus = 7
// Column widths for truncation
ColumnWidthContext = 14
ColumnWidthNamespace = 16
ColumnWidthAlias = 18
ColumnWidthType = 8
ColumnWidthResource = 20
// Error display widths
ErrorDisplayWidth = 118 // Slightly less than table width (120) for padding
)
// Viewport constants
const (
// ViewportHeight is the number of items visible in list views
ViewportHeight = 20
)
// Path display constants
const (
// MaxPathWidth is the maximum width for displaying file paths
MaxPathWidth = 48
)
+3 -3
View File
@@ -695,12 +695,12 @@ func TestHandleSelectorValidated(t *testing.T) {
func TestHandlePortChecked(t *testing.T) {
tests := []struct {
name string
available bool
expectStep AddWizardStep
available bool
expectError bool
}{
{"port available", true, StepConfirmation, false},
{"port in use", false, StepEnterLocalPort, true},
{name: "port available", available: true, expectStep: StepConfirmation, expectError: false},
{name: "port in use", available: false, expectStep: StepEnterLocalPort, expectError: true},
}
for _, tt := range tests {
+5 -5
View File
@@ -180,13 +180,13 @@ func TestHTTPLogState_GetFilterModeLabel(t *testing.T) {
state := newHTTPLogState("fwd", "alias")
tests := []struct {
mode HTTPLogFilterMode
expected string
mode HTTPLogFilterMode
}{
{HTTPLogFilterNone, "All"},
{HTTPLogFilterText, "Text"},
{HTTPLogFilterNon200, "Non-2xx"},
{HTTPLogFilterErrors, "Errors (4xx/5xx)"},
{mode: HTTPLogFilterNone, expected: "All"},
{mode: HTTPLogFilterText, expected: "Text"},
{mode: HTTPLogFilterNon200, expected: "Non-2xx"},
{mode: HTTPLogFilterErrors, expected: "Errors (4xx/5xx)"},
}
for _, tt := range tests {
+33 -53
View File
@@ -10,36 +10,28 @@ import (
// 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
ListPodsErr error
ListServicesErr error
ListPodsWithSelectorErr error
ListContextsErr error
GetCurrentContextErr error
ListNamespacesErr error
LastSelector string
CurrentContext string
LastNamespace string
LastContextName string
PodsWithSelector []k8s.PodInfo
Services []k8s.ServiceInfo
Pods []k8s.PodInfo
Namespaces []string
Contexts []string
ListContextsCalls int
GetCurrentContextCalls int
ListNamespacesCalls int
ListPodsCalls int
ListPodsWithSelectorCalls int
ListServicesCalls int
// Captured arguments
LastContextName string
LastNamespace string
LastSelector string
mu sync.Mutex
}
func NewMockDiscovery() *MockDiscovery {
@@ -104,34 +96,26 @@ func (m *MockDiscovery) ListServices(ctx context.Context, contextName, namespace
// 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 {
AddForwardErr error
RemoveForwardsErr error
LastPredicate func(ctx, ns string, fwd config.Forward) bool
LastContextName string
LastOldID string
LastNamespaceName string
LastRemovedID string
Forwards []struct {
Context string
Namespace string
Forward config.Forward
}
LastForward config.Forward
RemoveForwardByIDCalls int
UpdateForwardCalls int
RemoveForwardsCalls int
AddForwardCalls int
mu sync.Mutex
}
func NewMockMutator() *MockMutator {
@@ -186,14 +170,10 @@ func (m *MockMutator) UpdateForward(oldID, newContextName, newNamespaceName stri
// 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
mu sync.Mutex
ShouldFail bool
}
func NewMockHTTPLogSubscriber() *MockHTTPLogSubscriber {
@@ -237,11 +217,11 @@ func (m *MockHTTPLogSubscriber) GetSubscriberFunc() HTTPLogSubscriber {
// MockToggleCallback tracks toggle callback invocations
type MockToggleCallback struct {
mu sync.Mutex
Calls []struct {
ID string
Enable bool
}
mu sync.Mutex
}
func NewMockToggleCallback() *MockToggleCallback {
+6 -6
View File
@@ -14,17 +14,17 @@ type ForwardStatus struct {
Context string
Namespace string
Alias string
Type string // "service", "pod", etc.
Resource string // name without type prefix
Type string
Resource string
Status string
RemotePort int
LocalPort int
Status string // "Starting", "Active", "Reconnecting", "Error"
}
// TableUI manages the terminal table display
type TableUI struct {
forwards map[string]*ForwardStatus
mu sync.RWMutex
forwards map[string]*ForwardStatus // key is forward ID
verbose bool
}
@@ -101,12 +101,12 @@ func (t *TableUI) Render() {
// Sort forwards by local port for consistent display
type sortEntry struct {
id string
fwd *ForwardStatus
id string
}
var entries []sortEntry
for id, fwd := range t.forwards {
entries = append(entries, sortEntry{id, fwd})
entries = append(entries, sortEntry{fwd: fwd, id: id})
}
// Simple sort by local port
+14 -13
View File
@@ -9,6 +9,7 @@ import (
"github.com/nvm/kportal/internal/benchmark"
"github.com/nvm/kportal/internal/config"
"github.com/nvm/kportal/internal/k8s"
"github.com/nvm/kportal/internal/logger"
)
const (
@@ -19,53 +20,53 @@ const (
// ContextsLoadedMsg is sent when contexts have been loaded
type ContextsLoadedMsg struct {
contexts []string
err error
contexts []string
}
// NamespacesLoadedMsg is sent when namespaces have been loaded
type NamespacesLoadedMsg struct {
namespaces []string
err error
namespaces []string
}
// PodsLoadedMsg is sent when pods have been loaded
type PodsLoadedMsg struct {
pods []k8s.PodInfo
err error
pods []k8s.PodInfo
}
// ServicesLoadedMsg is sent when services have been loaded
type ServicesLoadedMsg struct {
services []k8s.ServiceInfo
err error
services []k8s.ServiceInfo
}
// SelectorValidatedMsg is sent when a selector has been validated
type SelectorValidatedMsg struct {
valid bool
pods []k8s.PodInfo
err error
pods []k8s.PodInfo
valid bool
}
// PortCheckedMsg is sent when a port's availability has been checked
type PortCheckedMsg struct {
message string
port int
available bool
message string
}
// ForwardSavedMsg is sent when a forward has been saved to config
type ForwardSavedMsg struct {
success bool
err error
success bool
}
// ForwardsRemovedMsg is sent when forwards have been removed from config
type ForwardsRemovedMsg struct {
success bool
count int
err error
count int
success bool
}
// WizardCompleteMsg signals that the wizard has completed
@@ -241,9 +242,9 @@ 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
Results *benchmark.Results
ForwardID string
}
// BenchmarkProgressMsg is sent periodically during benchmark execution
@@ -291,7 +292,7 @@ func runBenchmarkCmd(ctx context.Context, forwardID string, localPort int, urlPa
// Recover from panics in the callback
defer func() {
if r := recover(); r != nil {
// Silently recover - progress callback failure shouldn't crash the benchmark
logger.Debug("recovered from panic in progress callback", map[string]any{"panic": r})
}
}()
// Non-blocking send to progress channel
+2 -2
View File
@@ -86,10 +86,10 @@ func TestWizardMutualExclusion_HTTPLogBlocksOthers(t *testing.T) {
// TestWizardMutualExclusion_CheckActiveModal tests the modal activity check logic
func TestWizardMutualExclusion_CheckActiveModal(t *testing.T) {
tests := []struct {
name string
setupFunc func(*BubbleTeaUI)
expectActive bool
name string
activeModalStr string
expectActive bool
}{
{
name: "no modal active",
+62 -86
View File
@@ -109,45 +109,33 @@ func (r ResourceType) Description() string {
// AddWizardState maintains the state for the add port forward wizard
type AddWizardState struct {
step AddWizardStep
inputMode InputMode
cursor int
scrollOffset int // For scrolling long lists
textInput string
searchFilter string // For filtering lists (contexts, namespaces, services)
loading bool
error error
// Selections made by user
error error
resourceValue string
originalID string
portCheckMsg string
alias string
textInput string
searchFilter string
selector string
selectedContext string
selectedNamespace string
pods []k8s.PodInfo
contexts []string
detectedPorts []k8s.PortInfo
matchingPods []k8s.PodInfo
services []k8s.ServiceInfo
namespaces []string
scrollOffset int
selectedResourceType ResourceType
resourceValue string // pod prefix or service name
selector string // for pod selector type
remotePort int
step AddWizardStep
localPort int
alias string
// Available options (loaded asynchronously from k8s)
contexts []string
namespaces []string
pods []k8s.PodInfo
services []k8s.ServiceInfo
// Validation state
portAvailable bool
portCheckMsg string
matchingPods []k8s.PodInfo
// Edit mode
isEditing bool
originalID string // ID of the forward being edited
// Detected ports from resources
detectedPorts []k8s.PortInfo
// Confirmation focus (alias field vs buttons)
confirmationFocus ConfirmationFocus
cursor int
remotePort int
inputMode InputMode
confirmationFocus ConfirmationFocus
portAvailable bool
isEditing bool
loading bool
}
// newAddWizardState creates a new add wizard state initialized to the first step
@@ -239,11 +227,11 @@ func (w *AddWizardState) clearTextInput() {
// RemoveWizardState maintains the state for the remove port forward wizard
type RemoveWizardState struct {
selected map[int]bool
forwards []RemovableForward
cursor int
selected map[int]bool
confirmCursor int
confirming bool
confirmCursor int // 0 = Yes, 1 = No
}
// RemovableForward represents a forward that can be removed
@@ -387,45 +375,39 @@ const (
// BenchmarkState maintains the state for the benchmark wizard
type BenchmarkState struct {
step BenchmarkStep
error error
results *BenchmarkResults
cancelFunc func()
progressCh chan BenchmarkProgressMsg
textInput string
forwardID string
forwardAlias string
urlPath string
method string
cursor int
progress int
total int
step BenchmarkStep
requests int
concurrency int
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
running bool
}
// BenchmarkResults holds benchmark results for display
type BenchmarkResults struct {
StatusCodes map[int]int
TotalRequests int
Successful int
Failed int
MinLatency float64 // milliseconds
MinLatency float64
MaxLatency float64
AvgLatency float64
P50Latency float64
P95Latency float64
P99Latency float64
Throughput float64 // requests per second
Throughput float64
BytesRead int64
StatusCodes map[int]int
}
// newBenchmarkState creates a new benchmark state for a forward
@@ -455,41 +437,35 @@ const (
// 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!")
forwardID string
forwardAlias string
filterText string
copyMessage string
entries []HTTPLogEntry
cursor int
scrollOffset int
filterMode HTTPLogFilterMode
detailScroll int
autoScroll bool
filterActive bool
showingDetail bool
}
// 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
Method string
RequestID string
Path string
Direction string
Timestamp string
RequestBody string
ResponseBody string
Error string
StatusCode int
LatencyMs int64
BodySize int
}
// newHTTPLogState creates a new HTTP log viewing state
+2 -2
View File
@@ -285,10 +285,10 @@ func TestClearSearchFilter(t *testing.T) {
func TestMoveCursorWithFilteredLists(t *testing.T) {
tests := []struct {
name string
step AddWizardStep
searchFilter string
contexts []string
namespaces []string
searchFilter string
step AddWizardStep
initialCursor int
delta int
expectedCursor int
+1 -2
View File
@@ -143,7 +143,6 @@ func renderBreadcrumb(parts ...string) string {
func renderList(items []string, cursor int, prefix string, scrollOffset int) string {
var b strings.Builder
const viewportHeight = 20
totalItems := len(items)
// Show scroll up indicator if there are items above the viewport
@@ -153,7 +152,7 @@ func renderList(items []string, cursor int, prefix string, scrollOffset int) str
// Calculate visible range
start := scrollOffset
end := scrollOffset + viewportHeight
end := scrollOffset + ViewportHeight
if end > totalItems {
end = totalItems
}
+14 -8
View File
@@ -1,3 +1,15 @@
// Package version provides version checking against GitHub releases.
// It queries the GitHub API to check for newer versions of kportal
// and provides update notifications.
//
// Basic usage:
//
// info, err := version.CheckForUpdate(ctx, "owner", "repo", "v1.0.0")
// if err != nil {
// log.Printf("Version check failed: %v", err)
// } else if info.UpdateAvailable {
// fmt.Printf("Update available: %s -> %s\n", info.CurrentVersion, info.LatestVersion)
// }
package version
import (
@@ -33,10 +45,10 @@ type UpdateInfo struct {
// Checker checks for new versions on GitHub
type Checker struct {
client *http.Client
owner string
repo string
current string
client *http.Client
}
// NewChecker creates a new version checker
@@ -144,15 +156,9 @@ func parseVersion(v string) []int {
for _, p := range parts {
var num int
fmt.Sscanf(p, "%d", &num)
_, _ = 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)
}
-13
View File
@@ -75,16 +75,3 @@ func TestIsNewerVersion(t *testing.T) {
})
}
}
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")
}