Compare commits

..

40 Commits

Author SHA1 Message Date
lukaszraczylo 9497b6d705 Update go.mod and go.sum (#41) 2026-02-09 04:00:55 +00:00
lukaszraczylo e6bd540306 Update go.mod and go.sum (#40) 2026-02-07 03:52:04 +00:00
lukaszraczylo 86d91e0071 Update go.mod and go.sum (#39) 2026-02-05 03:53:12 +00:00
lukaszraczylo 4eff5ff5eb Update go.mod and go.sum (#38) 2026-02-04 03:53:00 +00:00
lukaszraczylo b9b7d5ec87 Update go.mod and go.sum (#37) 2026-02-02 03:57:42 +00:00
lukaszraczylo bc3b61e778 Update go.mod and go.sum (#36) 2026-01-28 03:40:33 +00:00
lukaszraczylo 676fd3df39 Update go.mod and go.sum (#35) 2026-01-26 03:45:09 +00:00
lukaszraczylo 00380ca307 Update go.mod and go.sum (#34) 2026-01-25 03:43:03 +00:00
lukaszraczylo e4930071fc Update go.mod and go.sum (#33) 2026-01-23 03:40:40 +00:00
lukaszraczylo c43aca3805 Update go.mod and go.sum (#32) 2026-01-19 03:42:14 +00:00
lukaszraczylo 4add04e3be Update go.mod and go.sum (#31) 2026-01-16 03:39:04 +00:00
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
66 changed files with 1553 additions and 1035 deletions
+4 -1
View File
@@ -8,10 +8,13 @@ on:
permissions:
contents: write
actions: write
pull-requests: write
security-events: write
jobs:
autoupdate:
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
with:
go-version: ">=1.21"
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"
+4 -3
View File
@@ -12,11 +12,12 @@ on:
permissions:
contents: write
packages: write
id-token: write
jobs:
release:
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
with:
go-version: "1.23"
secrets:
homebrew-tap-token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
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
+20 -4
View File
@@ -62,7 +62,23 @@ homebrew_casks:
homepage: https://lukaszraczylo.github.io/kportal
description: "Modern Kubernetes port-forward manager with interactive TUI"
license: MIT
postflight: |
system_command "/usr/bin/xattr",
args: ["-dr", "com.apple.quarantine", "#{staged_path}/kportal"],
sudo: false
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
+13
View File
@@ -83,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`:
+44 -24
View File
@@ -199,8 +199,8 @@ func main() {
os.Exit(0)
}
// Create empty config file
if err := config.CreateEmptyConfigFile(*configFile); err != nil {
fmt.Fprintf(os.Stderr, "Error creating config file: %v\n", err)
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)
@@ -295,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)
@@ -309,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() {}
}
@@ -369,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)
}
@@ -381,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 {
@@ -406,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
}
@@ -422,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)
}
}
@@ -454,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)
}
}
@@ -475,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")
@@ -494,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
}
@@ -506,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:
@@ -529,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)
}
}
+24 -26
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,27 @@ 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.2 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.6.1 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/clipperhouse/displaywidth v0.10.0 // indirect
github.com/clipperhouse/uax29/v2 v2.6.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 +43,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 +52,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.72 // 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,22 +68,22 @@ 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.35.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.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/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // 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
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
+56 -78
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,24 +10,22 @@ 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.2 h1:XAG3FSjiVtFvgEgGrNBkCNNYrsucAt8c6bfxHyROLLs=
github.com/charmbracelet/x/ansi v0.11.2/go.mod h1:9tY2bzX5SiJCU0iWyskjBeI2BRQfvPqI+J760Mjf+Rg=
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/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
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.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY=
github.com/clipperhouse/displaywidth v0.6.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos=
github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -40,10 +40,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 +76,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 +91,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 +104,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.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/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 +124,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 +148,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.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.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,23 +199,23 @@ 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/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY=
k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
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=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.3.1 h1:JrhdFMqOd/+3ByqlP2I45kTOZmTRLBUm5pvRjeheg7E=
sigs.k8s.io/structured-merge-diff/v6 v6.3.1/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8=
sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
+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 -19
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
+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()
+39 -19
View File
@@ -1,3 +1,24 @@
// 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 (
@@ -36,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
@@ -59,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.
@@ -167,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
@@ -196,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.
@@ -296,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)
+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)
+8 -10
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
@@ -103,8 +103,8 @@ func TestLoadConfig_FileNotFound(t *testing.T) {
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,10 +389,8 @@ 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")
}
})
}
@@ -407,8 +405,8 @@ func TestNewEmptyConfig(t *testing.T) {
func TestConfig_IsEmpty(t *testing.T) {
tests := []struct {
name string
config *Config
name string
expected bool
}{
{
@@ -505,7 +503,7 @@ func TestCreateEmptyConfigFile_AlreadyExists(t *testing.T) {
configPath := filepath.Join(tmpDir, ".kportal.yaml")
// Create existing file
err := os.WriteFile(configPath, []byte("existing content"), 0644)
err := os.WriteFile(configPath, []byte("existing content"), 0600)
assert.NoError(t, err)
// Try to create config file - should fail
+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)
}
+8 -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.
@@ -204,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()),
})
}
}
+11 -20
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",
@@ -977,8 +968,8 @@ func TestValidator_ValidateConfigWithOptions(t *testing.T) {
validator := NewValidator()
tests := []struct {
name string
config *Config
name string
allowEmpty bool
expectErrors bool
}{
+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", "")
+22 -34
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 {
@@ -419,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")
}
}
})
@@ -493,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()
+2 -42
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)
@@ -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",
+17 -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.
@@ -195,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")
}