Compare commits

...

16 Commits

Author SHA1 Message Date
lukaszraczylo 0d03f228f9 fixup! Update gorelease config and docs - moving to cask due to depreciation 2025-12-07 14:04:38 +00:00
lukaszraczylo 2a44c6ff9c Update gorelease config and docs - moving to cask due to depreciation 2025-12-07 13:59:51 +00:00
lukaszraczylo 8672d932bb Move build and release to shared workflow 2025-12-07 13:34:14 +00:00
lukaszraczylo 87317adb91 Move to shared workflow. 2025-12-07 13:16:41 +00:00
lukaszraczylo ced7e80a06 Update the autoupdate workflow 2025-12-07 12:35:56 +00:00
lukaszraczylo 13723733df Update autoupdate workflow. LOL. 2025-12-03 00:47:06 +00:00
lukaszraczylo 9538623bcb Remove unused struct 2025-12-02 18:13:05 +00:00
lukaszraczylo 8bb377909c Add startupSettleTime to zeroconf to avoid race conditions. 2025-12-02 18:06:11 +00:00
lukaszraczylo 263a0370d3 fixup! fixup! Fix autoupdate workflow. 2025-12-01 14:39:54 +00:00
lukaszraczylo 62eca4a9a1 Update go.mod and go.sum
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-01 14:34:23 +00:00
lukaszraczylo ea20a037b9 fixup! Fix autoupdate workflow. 2025-12-01 14:31:06 +00:00
lukaszraczylo 46db732f87 Fix autoupdate workflow. 2025-12-01 14:24:34 +00:00
lukaszraczylo a297ba7073 Enhancement: Empty config
When user starts kportal for the first time, and there is no config file,
kportal should create an empty config file with default values and empty
forwarding rules, so that user can easily edit the config file and add their
own rules.
2025-11-29 12:44:33 +00:00
lukaszraczylo 518879dc56 Update menus to wrap for smaller screens. 2025-11-29 02:09:54 +00:00
lukaszraczylo 649227b201 Update taps confusion. 2025-11-29 01:39:21 +00:00
lukaszraczylo 28e2fc315a Create CNAME 2025-11-28 00:29:37 +00:00
20 changed files with 643 additions and 219 deletions
+8 -64
View File
@@ -5,69 +5,13 @@ on:
schedule:
- cron: "0 3 * * *"
env:
GO_VERSION: ">=1.21"
permissions:
contents: write
actions: write
jobs:
# This job is responsible for preparation of the build
# environment variables.
prepare:
name: Preparing build context
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@v5
id: cache
with:
go-version: ${{env.GO_VERSION}}
cache-dependency-path: "**/*.sum"
- name: Go get dependencies
if: steps.cache.outputs.cache-hit != 'true'
run: |
go get ./...
# This job is responsible for running tests and linting the codebase
test:
name: "Unit testing"
runs-on: ubuntu-latest
container: golang:1
needs: [prepare]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Ensure full history is checked out
token: ${{ secrets.GHCR_TOKEN }}
- name: Install Go
uses: actions/setup-go@v5
with:
go-version: ${{env.GO_VERSION}}
cache-dependency-path: "**/*.sum"
- name: Install dependencies
run: |
apt-get update
apt-get install ca-certificates make -y
update-ca-certificates
go mod tidy
go get -u -v ./...
go mod tidy -v
- name: Run unit tests
run: |
CI_RUN=${CI} make test
git config --global --add safe.directory /__w/kportal/kportal
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "Update go.mod and go.sum"
commit_options: "--no-verify --signoff"
file_pattern: "go.mod go.sum"
autoupdate:
uses: lukaszraczylo/shared-actions/.github/workflows/go-autoupdate.yaml@main
with:
go-version: ">=1.21"
release-workflow: "release.yml"
+8 -80
View File
@@ -5,90 +5,18 @@ on:
branches:
- main
paths:
- '**.go'
- 'go.mod'
- 'go.sum'
- "**.go"
- "go.mod"
- "go.sum"
workflow_dispatch:
permissions:
contents: write
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Run tests with race detector
run: go test -race -v ./...
version:
name: Calculate Version
needs: test
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version_formatted.outputs.version }}
version_tag: ${{ steps.version_formatted.outputs.version_tag }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Calculate semantic version
id: semver
uses: lukaszraczylo/semver-generator@v1
with:
config_file: semver.yaml
repository_local: true
- name: Format version
id: version_formatted
run: |
VERSION="${{ steps.semver.outputs.semantic_version }}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "version_tag=v${VERSION}" >> $GITHUB_OUTPUT
- name: Display version
run: |
echo "Calculated version: ${{ steps.version_formatted.outputs.version }}"
echo "Version tag: ${{ steps.version_formatted.outputs.version_tag }}"
release:
name: Release with GoReleaser
needs: version
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Create and push tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a ${{ needs.version.outputs.version_tag }} -m "Release ${{ needs.version.outputs.version_tag }}"
git push origin ${{ needs.version.outputs.version_tag }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main
with:
go-version: "1.23"
secrets:
homebrew-tap-token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
+12 -10
View File
@@ -23,11 +23,11 @@ builds:
archives:
- id: kportal
format: tar.gz
formats: [tar.gz]
name_template: "kportal-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
format_overrides:
- goos: windows
format: zip
formats: [zip]
files:
- LICENSE
- README.md
@@ -53,17 +53,19 @@ release:
draft: false
prerelease: auto
brews:
homebrew_casks:
- repository:
owner: lukaszraczylo
name: brew-taps
name: homebrew-taps
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
directory: Formula
directory: Casks
homepage: https://lukaszraczylo.github.io/kportal
description: "Modern Kubernetes port-forward manager with interactive TUI"
license: MIT
test: |
system "#{bin}/kportal", "--version"
dependencies:
- name: kubernetes-cli
type: optional
hooks:
post.install: |
if OS.mac?
system_command "/usr/bin/xattr",
args: ["-dr", "com.apple.quarantine", "#{staged_path}/kportal"],
sudo: false
end
+7 -2
View File
@@ -54,12 +54,17 @@ kportal manages multiple Kubernetes port-forwards with an interactive terminal i
## 📦 Installation
### Homebrew (macOS/Linux)
### Homebrew (macOS)
```bash
brew install lukaszraczylo/brew-taps/kportal
brew install --cask lukaszraczylo/taps/kportal
```
> **Note**: If you previously installed via `brew install lukaszraczylo/taps/kportal` (formula), uninstall first:
> ```bash
> brew uninstall kportal
> ```
### Quick Install
```bash
+46 -4
View File
@@ -1,6 +1,7 @@
package main
import (
"bufio"
"context"
"flag"
"fmt"
@@ -49,6 +50,23 @@ var (
appVersion = "0.1.0" // Set via ldflags during build
)
// promptCreateConfig asks the user if they want to create a new config file.
// Returns true if the user answers yes, false otherwise.
func promptCreateConfig(path string) bool {
fmt.Printf("Configuration file not found: %s\n", path)
fmt.Print("Would you like to create an empty configuration? [Y/n] ")
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return false
}
response = strings.TrimSpace(strings.ToLower(response))
// Empty response (just Enter) defaults to yes
return response == "" || response == "y" || response == "yes"
}
func main() {
flag.Parse()
@@ -173,14 +191,38 @@ func main() {
// Load configuration
cfg, err := config.LoadConfig(*configFile)
configIsNew := false
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
if err == config.ErrConfigNotFound {
// Config file doesn't exist - offer to create it
if !promptCreateConfig(*configFile) {
os.Exit(0)
}
// Create empty config file
if err := config.CreateEmptyConfigFile(*configFile); err != nil {
fmt.Fprintf(os.Stderr, "Error creating config file: %v\n", err)
os.Exit(1)
}
fmt.Printf("Created %s\n", *configFile)
fmt.Println("Use 'n' in the UI to add port forwards, or edit the file manually.")
fmt.Println()
// Load the newly created config
cfg, err = config.LoadConfig(*configFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
configIsNew = true
} else {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
}
// Validate configuration
// Validate configuration (allow empty configs for newly created files)
validator := config.NewValidator()
if errs := validator.ValidateConfig(cfg); len(errs) > 0 {
if errs := validator.ValidateConfigWithOptions(cfg, configIsNew || cfg.IsEmpty()); len(errs) > 0 {
fmt.Fprint(os.Stderr, config.FormatValidationErrors(errs))
os.Exit(1)
}
+1
View File
@@ -0,0 +1 @@
kportal.raczylo.com
+3 -3
View File
@@ -556,11 +556,11 @@
<i class="fas fa-beer text-orange-500 dark:text-orange-400 text-2xl mr-3"></i>
<div>
<h3 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Homebrew</h3>
<p class="text-gray-600 dark:text-gray-400 text-sm">macOS & Linux</p>
<p class="text-gray-600 dark:text-gray-400 text-sm">macOS</p>
</div>
</div>
<div onclick="copyToClipboard('brew install lukaszraczylo/brew-taps/kportal', this)" class="relative bg-gradient-to-br from-gray-900 to-gray-800 dark:from-gray-950 dark:to-black text-gray-100 p-4 rounded-lg text-sm cursor-pointer group overflow-x-auto border border-gray-700 hover:border-orange-500 transition-all duration-300">
<code class="block whitespace-nowrap font-mono">brew install lukaszraczylo/brew-taps/kportal</code>
<div onclick="copyToClipboard('brew install --cask lukaszraczylo/taps/kportal', this)" class="relative bg-gradient-to-br from-gray-900 to-gray-800 dark:from-gray-950 dark:to-black text-gray-100 p-4 rounded-lg text-sm cursor-pointer group overflow-x-auto border border-gray-700 hover:border-orange-500 transition-all duration-300">
<code class="block whitespace-nowrap font-mono">brew install --cask lukaszraczylo/taps/kportal</code>
<div class="absolute top-3 right-3"><i class="fas fa-copy text-gray-500 group-hover:text-orange-400 transition-colors duration-300"></i></div>
</div>
</div>
+2 -2
View File
@@ -20,10 +20,10 @@ require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
github.com/charmbracelet/colorprofile v0.3.3 // indirect
github.com/charmbracelet/x/ansi v0.11.1 // indirect
github.com/charmbracelet/x/ansi v0.11.2 // indirect
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.6.0 // indirect
github.com/clipperhouse/displaywidth v0.6.1 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
+4 -4
View File
@@ -12,16 +12,16 @@ github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGx
github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.1 h1:iXAC8SyMQDJgtcz9Jnw+HU8WMEctHzoTAETIeA3JXMk=
github.com/charmbracelet/x/ansi v0.11.1/go.mod h1:M49wjzpIujwPceJ+t5w3qh2i87+HRtHohgb5iTyepL0=
github.com/charmbracelet/x/ansi v0.11.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/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/displaywidth v0.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=
-7
View File
@@ -221,10 +221,3 @@ func (r *Runner) makeRequest(ctx context.Context, cfg Config) (statusCode int, b
return resp.StatusCode, int64(len(respBody)), bytesWritten, nil
}
// Progress represents the current progress of a benchmark run
type Progress struct {
Completed int
Total int
Elapsed time.Duration
}
+62
View File
@@ -2,6 +2,7 @@ package config
import (
"bytes"
"errors"
"fmt"
"os"
"strings"
@@ -10,6 +11,9 @@ import (
"gopkg.in/yaml.v3"
)
// ErrConfigNotFound is returned when the configuration file does not exist
var ErrConfigNotFound = fmt.Errorf("config file not found")
const (
// maxConfigSize is the maximum allowed configuration file size (10MB)
maxConfigSize = 10 * 1024 * 1024
@@ -282,6 +286,9 @@ func LoadConfig(path string) (*Config, error) {
// Validate file size before reading
fileInfo, err := os.Stat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrConfigNotFound
}
return nil, fmt.Errorf("failed to stat config file: %w", err)
}
@@ -337,3 +344,58 @@ func (c *Config) GetAllForwards() []Forward {
return forwards
}
// NewEmptyConfig returns a minimal empty configuration with no forwards.
// This is used when creating a new config file for the first time.
func NewEmptyConfig() *Config {
return &Config{
Contexts: []Context{},
}
}
// IsEmpty returns true if the configuration has no forwards defined.
func (c *Config) IsEmpty() bool {
return len(c.Contexts) == 0 || len(c.GetAllForwards()) == 0
}
// CreateEmptyConfigFile creates a new empty configuration file at the given path.
// Returns an error if the file already exists or cannot be created.
func CreateEmptyConfigFile(path string) error {
// Check if file already exists
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("config file already exists: %s", path)
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to check config file: %w", err)
}
cfg := NewEmptyConfig()
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("failed to marshal empty config: %w", err)
}
// Add a helpful comment header
header := `# kportal configuration file
# Add port forwards using the 'n' key in the TUI, or manually add them below.
#
# Example forward:
# contexts:
# - name: my-cluster
# namespaces:
# - name: default
# forwards:
# - resource: service/my-service
# protocol: tcp
# port: 8080
# localPort: 8080
#
`
content := header + string(data)
// Write with restrictive permissions (0600)
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
return nil
}
+121 -1
View File
@@ -97,7 +97,7 @@ func TestLoadConfig_FileNotFound(t *testing.T) {
cfg, err := LoadConfig("/non/existent/path/.kportal.yaml")
assert.Error(t, err, "LoadConfig should fail with non-existent file")
assert.Nil(t, cfg, "config should be nil on error")
assert.Contains(t, err.Error(), "failed to stat config file", "error should mention stat failure")
assert.Equal(t, ErrConfigNotFound, err, "should return ErrConfigNotFound")
}
func TestForward_ID(t *testing.T) {
@@ -397,3 +397,123 @@ func TestHTTPLogSpec_UnmarshalYAML(t *testing.T) {
})
}
}
func TestNewEmptyConfig(t *testing.T) {
cfg := NewEmptyConfig()
assert.NotNil(t, cfg, "NewEmptyConfig should return non-nil config")
assert.Empty(t, cfg.Contexts, "NewEmptyConfig should have empty contexts")
assert.True(t, cfg.IsEmpty(), "NewEmptyConfig should be considered empty")
}
func TestConfig_IsEmpty(t *testing.T) {
tests := []struct {
name string
config *Config
expected bool
}{
{
name: "nil contexts",
config: &Config{},
expected: true,
},
{
name: "empty contexts slice",
config: &Config{Contexts: []Context{}},
expected: true,
},
{
name: "context with empty namespaces",
config: &Config{
Contexts: []Context{
{Name: "test", Namespaces: []Namespace{}},
},
},
expected: true,
},
{
name: "context with namespace but no forwards",
config: &Config{
Contexts: []Context{
{
Name: "test",
Namespaces: []Namespace{
{Name: "default", Forwards: []Forward{}},
},
},
},
},
expected: true,
},
{
name: "config with forward",
config: &Config{
Contexts: []Context{
{
Name: "test",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{Resource: "pod/app", Port: 8080, LocalPort: 8080},
},
},
},
},
},
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, tt.config.IsEmpty())
})
}
}
func TestCreateEmptyConfigFile(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
// Create empty config file
err := CreateEmptyConfigFile(configPath)
assert.NoError(t, err, "CreateEmptyConfigFile should succeed")
// Verify file exists
_, err = os.Stat(configPath)
assert.NoError(t, err, "config file should exist")
// Verify file is readable and parseable
cfg, err := LoadConfig(configPath)
assert.NoError(t, err, "should be able to load created config")
assert.NotNil(t, cfg, "config should not be nil")
assert.True(t, cfg.IsEmpty(), "created config should be empty")
// Verify file permissions (0600)
info, _ := os.Stat(configPath)
assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "file should have 0600 permissions")
// Verify file contains helpful header
content, _ := os.ReadFile(configPath)
assert.Contains(t, string(content), "# kportal configuration file", "should contain header comment")
assert.Contains(t, string(content), "Example forward", "should contain example")
}
func TestCreateEmptyConfigFile_AlreadyExists(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
// Create existing file
err := os.WriteFile(configPath, []byte("existing content"), 0644)
assert.NoError(t, err)
// Try to create config file - should fail
err = CreateEmptyConfigFile(configPath)
assert.Error(t, err, "CreateEmptyConfigFile should fail when file exists")
assert.Contains(t, err.Error(), "already exists")
// Verify original content is preserved
content, _ := os.ReadFile(configPath)
assert.Equal(t, "existing content", string(content))
}
+13
View File
@@ -37,6 +37,13 @@ func NewValidator() *Validator {
// ValidateConfig validates the entire configuration and returns all errors found.
func (v *Validator) ValidateConfig(cfg *Config) []ValidationError {
return v.ValidateConfigWithOptions(cfg, false)
}
// ValidateConfigWithOptions validates configuration with configurable strictness.
// When allowEmpty is true, empty configurations (no contexts/forwards) are allowed.
// This is useful for newly created config files where the user will add forwards via the TUI.
func (v *Validator) ValidateConfigWithOptions(cfg *Config, allowEmpty bool) []ValidationError {
var errs []ValidationError
if cfg == nil {
@@ -46,6 +53,12 @@ func (v *Validator) ValidateConfig(cfg *Config) []ValidationError {
}}
}
// If empty configs are allowed and this config is empty, skip structure validation
if allowEmpty && cfg.IsEmpty() {
// Still validate health check and reliability if present (they don't require forwards)
return errs
}
// Validate structure
errs = append(errs, v.validateStructure(cfg)...)
+138
View File
@@ -972,3 +972,141 @@ func TestIsAlphanumeric(t *testing.T) {
})
}
}
func TestValidator_ValidateConfigWithOptions(t *testing.T) {
validator := NewValidator()
tests := []struct {
name string
config *Config
allowEmpty bool
expectErrors bool
}{
{
name: "empty config - strict mode",
config: &Config{Contexts: []Context{}},
allowEmpty: false,
expectErrors: true,
},
{
name: "empty config - allow empty",
config: &Config{Contexts: []Context{}},
allowEmpty: true,
expectErrors: false,
},
{
name: "nil contexts - allow empty",
config: &Config{},
allowEmpty: true,
expectErrors: false,
},
{
name: "context with no forwards - allow empty",
config: &Config{
Contexts: []Context{
{
Name: "dev",
Namespaces: []Namespace{
{Name: "default", Forwards: []Forward{}},
},
},
},
},
allowEmpty: true,
expectErrors: false,
},
{
name: "valid config - strict mode",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/my-app",
Protocol: "tcp",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
allowEmpty: false,
expectErrors: false,
},
{
name: "valid config - allow empty (should still validate)",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/my-app",
Protocol: "tcp",
Port: 8080,
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
allowEmpty: true,
expectErrors: false,
},
{
name: "invalid forward in non-empty config - allow empty still validates",
config: &Config{
Contexts: []Context{
{
Name: "dev-cluster",
Namespaces: []Namespace{
{
Name: "default",
Forwards: []Forward{
{
Resource: "pod/my-app",
Protocol: "tcp",
Port: 0, // Invalid port
LocalPort: 8080,
contextName: "dev-cluster",
namespaceName: "default",
},
},
},
},
},
},
},
allowEmpty: true,
expectErrors: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := validator.ValidateConfigWithOptions(tt.config, tt.allowEmpty)
if tt.expectErrors {
assert.NotEmpty(t, errs, "expected validation errors")
} else {
assert.Empty(t, errs, "expected no validation errors, got: %v", errs)
}
})
}
}
+3 -1
View File
@@ -169,8 +169,10 @@ func (m *Manager) Start(cfg *config.Config) error {
// Get all forwards from config
forwards := cfg.GetAllForwards()
// Empty config is valid - user can add forwards later via TUI
if len(forwards) == 0 {
return fmt.Errorf("no forwards configured")
log.Printf("No forwards configured - use 'n' to add forwards")
return nil
}
// Check port availability before starting
+2 -2
View File
@@ -124,8 +124,8 @@ func TestManager_Start_EmptyForwards(t *testing.T) {
cfg := &config.Config{}
err = manager.Start(cfg)
assert.Error(t, err)
assert.Contains(t, err.Error(), "no forwards configured")
// Empty config is now valid - allows users to add forwards via TUI
assert.NoError(t, err)
}
// TestManager_Reload_NilConfig tests reloading with nil config
+12
View File
@@ -89,6 +89,11 @@ func (p *Publisher) Register(forwardID, alias string, localPort int) error {
p.servers[forwardID] = server
p.aliases[forwardID] = alias
// Allow zeroconf's internal goroutines (recv4, recv6) to fully initialize.
// This prevents a race condition where shutdown() could set connections to nil
// while recv goroutines are still starting up.
time.Sleep(startupSettleTime)
logger.Info("mDNS hostname registered", map[string]interface{}{
"forward_id": forwardID,
"hostname": GetHostname(alias),
@@ -151,6 +156,13 @@ func (p *Publisher) Stop() {
logger.Info("mDNS publisher stopped", nil)
}
// startupSettleTime is a small delay after zeroconf registration to allow internal
// goroutines (recv4, recv6) to fully initialize before any shutdown can occur.
// This works around a race condition in grandcat/zeroconf where shutdown() sets
// connections to nil while recv goroutines may still be initializing.
// See: https://github.com/grandcat/zeroconf/issues/95
const startupSettleTime = 50 * time.Millisecond
// shutdownSettleTime is a small delay after zeroconf shutdown to allow internal
// goroutines to exit cleanly. This works around a race condition in the
// grandcat/zeroconf library where recv4() can access ipv4conn after shutdown()
+82 -15
View File
@@ -602,20 +602,82 @@ func (m model) renderMainView() string {
footerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("220"))
footer := fmt.Sprintf("%s/%s: Navigate %s: Toggle %s: New %s: Edit %s: Delete %s: Bench %s: Logs %s: Quit │ Total: %d",
keyStyle.Render("↑↓"),
keyStyle.Render("jk"),
keyStyle.Render("Space"),
keyStyle.Render("n"),
keyStyle.Render("e"),
keyStyle.Render("d"),
keyStyle.Render("b"),
keyStyle.Render("l"),
keyStyle.Render("q"),
len(m.ui.forwardOrder))
// Get terminal width for footer wrapping
termWidth := m.termWidth
if termWidth == 0 {
termWidth = 120
}
// Fill space to push footer to bottom (reserve 2 lines: 1 for spacing, 1 for footer)
footerHeight := 2
// 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
// Calculate how much space we need for the total count suffix
totalSuffix := fmt.Sprintf(" │ Total: %d", len(m.ui.forwardOrder))
totalSuffixLen := len(totalSuffix)
// Available width (account for some margin)
availableWidth := termWidth - 4
for i, binding := range bindings {
// Build this binding's text
keyRendered := keyStyle.Render(binding.key)
bindingText := keyRendered + ": " + binding.desc
// Visual length without ANSI codes
bindingVisualLen := len(binding.key) + 2 + len(binding.desc)
// Add separator if not first item on line
separator := ""
separatorLen := 0
if currentLine.Len() > 0 {
separator = " "
separatorLen = 2
}
// Check if this binding fits on current line
// For the last binding, also need to fit the total suffix
neededWidth := currentLineVisualLen + separatorLen + bindingVisualLen
if i == len(bindings)-1 {
neededWidth += totalSuffixLen
}
if neededWidth > availableWidth && currentLine.Len() > 0 {
// Start a new line
footerLines = append(footerLines, currentLine.String())
currentLine.Reset()
currentLineVisualLen = 0
separator = ""
separatorLen = 0
}
currentLine.WriteString(separator)
currentLine.WriteString(bindingText)
currentLineVisualLen += separatorLen + bindingVisualLen
}
// Add total count to the last line
currentLine.WriteString(totalSuffix)
footerLines = append(footerLines, currentLine.String())
// Calculate footer height
footerHeight := len(footerLines) + 1 // +1 for the blank line before footer
remainingLines := termHeight - currentLines - footerHeight
if remainingLines > 0 {
b.WriteString(strings.Repeat("\n", remainingLines))
@@ -623,7 +685,12 @@ func (m model) renderMainView() string {
// Add footer at bottom
b.WriteString("\n")
b.WriteString(footerStyle.Render(footer))
for i, line := range footerLines {
if i > 0 {
b.WriteString("\n")
}
b.WriteString(footerStyle.Render(line))
}
return b.String()
}
@@ -736,7 +803,7 @@ func (m model) renderDeleteConfirmation() string {
}
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("←/→: Navigate Enter: Confirm Esc: Cancel"))
b.WriteString(wrapHelpText("←/→: Navigate Enter: Confirm Esc: Cancel", wizardHelpWidth(m.termWidth)))
// Wrap in a box using wizard style
boxStyle := lipgloss.NewStyle().
+90
View File
@@ -194,6 +194,96 @@ func renderTextInput(label, value string, valid bool) string {
return b.String()
}
// wizardHelpWidth returns an appropriate width for wizard help text
// based on terminal width. For modals, we use a sensible maximum.
func wizardHelpWidth(termWidth int) int {
if termWidth == 0 {
termWidth = 80
}
// Wizard modals shouldn't be wider than 70 chars typically
// but on narrow terminals, use available space minus padding
maxWidth := 70
available := termWidth - 10 // account for modal borders and padding
if available < maxWidth {
return available
}
return maxWidth
}
// wrapHelpText wraps help text to fit within the given width.
// Help text is expected to be in the format "key: action key: action ..."
// separated by double spaces. On smaller screens, it wraps to multiple lines.
func wrapHelpText(text string, width int) string {
if width <= 0 {
width = 80 // Default width
}
// Account for some padding/margin
availableWidth := width - 4
if availableWidth < 20 {
availableWidth = 20
}
// If text fits, return as-is
if len(text) <= availableWidth {
return helpStyle.Render(text)
}
// Split by double-space separator (common in help text)
parts := strings.Split(text, " ")
if len(parts) <= 1 {
// No double-space separators, just truncate
if len(text) > availableWidth-3 {
return helpStyle.Render(text[:availableWidth-3] + "...")
}
return helpStyle.Render(text)
}
var lines []string
var currentLine strings.Builder
for i, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// Check if adding this part would exceed width
addition := part
if currentLine.Len() > 0 {
addition = " " + part
}
if currentLine.Len()+len(addition) > availableWidth && currentLine.Len() > 0 {
// Start new line
lines = append(lines, currentLine.String())
currentLine.Reset()
currentLine.WriteString(part)
} else {
if currentLine.Len() > 0 {
currentLine.WriteString(" ")
}
currentLine.WriteString(part)
}
// Handle last part
if i == len(parts)-1 && currentLine.Len() > 0 {
lines = append(lines, currentLine.String())
}
}
// Join with newlines and apply style to each line
var result strings.Builder
for i, line := range lines {
if i > 0 {
result.WriteString("\n")
}
result.WriteString(helpStyle.Render(line))
}
return result.String()
}
// overlayContent overlays modal content centered on the base view
// Note: base parameter is kept for API compatibility but not used since
// lipgloss.Place provides cleaner centering without background artifacts
+29 -24
View File
@@ -109,10 +109,11 @@ func (m model) renderSelectContext() string {
}
b.WriteString("\n")
helpWidth := wizardHelpWidth(m.termWidth)
if wizard.searchFilter != "" {
b.WriteString(helpStyle.Render(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Cancel", len(wizard.getFilteredContexts()), len(wizard.contexts))))
b.WriteString(wrapHelpText(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Cancel", len(wizard.getFilteredContexts()), len(wizard.contexts)), helpWidth))
} else {
b.WriteString(helpStyle.Render("Type to filter ↑/↓: Navigate Enter: Select Esc/Ctrl+C: Cancel"))
b.WriteString(wrapHelpText("Type to filter ↑/↓: Navigate Enter: Select Esc/Ctrl+C: Cancel", helpWidth))
}
return b.String()
@@ -150,10 +151,11 @@ func (m model) renderSelectNamespace() string {
}
b.WriteString("\n")
helpWidth := wizardHelpWidth(m.termWidth)
if wizard.searchFilter != "" {
b.WriteString(helpStyle.Render(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredNamespaces()), len(wizard.namespaces))))
b.WriteString(wrapHelpText(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredNamespaces()), len(wizard.namespaces)), helpWidth))
} else {
b.WriteString(helpStyle.Render("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
b.WriteString(wrapHelpText("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel", helpWidth))
}
return b.String()
@@ -192,7 +194,7 @@ func (m model) renderSelectResourceType() string {
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
b.WriteString(wrapHelpText("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth)))
return b.String()
}
@@ -305,14 +307,15 @@ func (m model) renderEnterResource() string {
b.WriteString("\n")
// Show appropriate help text based on resource type and filter state
helpWidth := wizardHelpWidth(m.termWidth)
if wizard.selectedResourceType == ResourceTypeService {
if wizard.searchFilter != "" {
b.WriteString(helpStyle.Render(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredServices()), len(wizard.services))))
b.WriteString(wrapHelpText(fmt.Sprintf("↑/↓: Navigate Enter: Select Backspace: Clear filter (%d/%d) Esc: Back", len(wizard.getFilteredServices()), len(wizard.services)), helpWidth))
} else {
b.WriteString(helpStyle.Render("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
b.WriteString(wrapHelpText("Type to filter ↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel", helpWidth))
}
} else {
b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel"))
b.WriteString(wrapHelpText("Enter: Continue Esc: Back Ctrl+C: Cancel", helpWidth))
}
return b.String()
@@ -403,7 +406,7 @@ func (m model) renderEnterRemotePort() string {
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel"))
b.WriteString(wrapHelpText("↑/↓: Navigate Enter: Select Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth)))
} else {
// Text input mode (no detected ports or user chose manual entry)
if len(wizard.detectedPorts) > 0 {
@@ -436,7 +439,7 @@ func (m model) renderEnterRemotePort() string {
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel"))
b.WriteString(wrapHelpText("Enter: Continue Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth)))
}
return b.String()
@@ -477,7 +480,7 @@ func (m model) renderEnterLocalPort() string {
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("Enter: Continue Esc: Back Ctrl+C: Cancel"))
b.WriteString(wrapHelpText("Enter: Continue Esc: Back Ctrl+C: Cancel", wizardHelpWidth(m.termWidth)))
return b.String()
}
@@ -535,7 +538,7 @@ func (m model) renderConfirmation() string {
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("↑/↓/Tab: Navigate Enter: Confirm Esc: Back"))
b.WriteString(wrapHelpText("↑/↓/Tab: Navigate Enter: Confirm Esc: Back", wizardHelpWidth(m.termWidth)))
return b.String()
}
@@ -578,7 +581,7 @@ func (m model) renderSuccess() string {
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Select"))
b.WriteString(wrapHelpText("↑/↓: Navigate Enter: Select", wizardHelpWidth(m.termWidth)))
return b.String()
}
@@ -641,7 +644,7 @@ func (m model) renderRemoveSelection() string {
selectedCount := wizard.getSelectedCount()
b.WriteString(fmt.Sprintf("%d of %d selected\n\n", selectedCount, len(wizard.forwards)))
b.WriteString(helpStyle.Render("Space: Toggle a: All n: None Enter: Remove Esc: Cancel"))
b.WriteString(wrapHelpText("Space: Toggle a: All n: None Enter: Remove Esc: Cancel", wizardHelpWidth(m.termWidth)))
return b.String()
}
@@ -676,7 +679,7 @@ func (m model) renderRemoveConfirmation() string {
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("↑/↓: Navigate Enter: Confirm Esc: Cancel"))
b.WriteString(wrapHelpText("↑/↓: Navigate Enter: Confirm Esc: Cancel", wizardHelpWidth(m.termWidth)))
return b.String()
}
@@ -740,7 +743,7 @@ func (m model) renderBenchmarkConfig() string {
b.WriteString("\n")
b.WriteString(mutedStyle.Render(fmt.Sprintf("Will send %d requests with %d concurrent workers", state.requests, state.concurrency)))
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("↑/↓/Tab: Navigate Type to edit Enter: Run Esc: Cancel"))
b.WriteString(wrapHelpText("↑/↓/Tab: Navigate Type to edit Enter: Run Esc: Cancel", wizardHelpWidth(m.termWidth)))
return b.String()
}
@@ -780,7 +783,7 @@ func (m model) renderBenchmarkRunning() string {
b.WriteString(mutedStyle.Render(fmt.Sprintf("Method: %s Concurrency: %d", state.method, state.concurrency)))
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("Please wait..."))
b.WriteString(wrapHelpText("Please wait...", wizardHelpWidth(m.termWidth)))
return b.String()
}
@@ -796,14 +799,14 @@ func (m model) renderBenchmarkResults() string {
if state.error != nil {
b.WriteString(errorStyle.Render(fmt.Sprintf("✗ Error: %v", state.error)))
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("Press Enter or Esc to return"))
b.WriteString(wrapHelpText("Press Enter or Esc to return", wizardHelpWidth(m.termWidth)))
return b.String()
}
if state.results == nil {
b.WriteString(mutedStyle.Render("No results available"))
b.WriteString("\n\n")
b.WriteString(helpStyle.Render("Press Enter or Esc to return"))
b.WriteString(wrapHelpText("Press Enter or Esc to return", wizardHelpWidth(m.termWidth)))
return b.String()
}
@@ -872,7 +875,7 @@ func (m model) renderBenchmarkResults() string {
}
b.WriteString("\n")
b.WriteString(helpStyle.Render("Press Enter or Esc to return"))
b.WriteString(wrapHelpText("Press Enter or Esc to return", wizardHelpWidth(m.termWidth)))
return b.String()
}
@@ -1076,8 +1079,10 @@ func (m model) renderHTTPLog() string {
}
b.WriteString("\n")
// Help line at bottom
b.WriteString(helpStyle.Render(" ↑/↓: Navigate Enter: Details a: Auto-scroll f: Filter /: Search c: Clear q: Close"))
// Help line at bottom (wrap for smaller screens)
helpText := "↑/↓: Navigate Enter: Details a: Auto-scroll f: Filter /: Search c: Clear q: Close"
b.WriteString(" ")
b.WriteString(wrapHelpText(helpText, termWidth-4))
return b.String()
}
@@ -1273,9 +1278,9 @@ func (m model) renderHTTPLogDetail(entry HTTPLogEntry, termWidth, termHeight int
if state.copyMessage != "" {
b.WriteString(successStyle.Render(state.copyMessage))
b.WriteString(" ")
b.WriteString(helpStyle.Render("↑/↓: Scroll c: Copy Esc: Back"))
b.WriteString(wrapHelpText("↑/↓: Scroll c: Copy Esc: Back", termWidth-10))
} else {
b.WriteString(helpStyle.Render("↑/↓/PgUp/PgDn: Scroll g: Top c: Copy response Esc: Back"))
b.WriteString(wrapHelpText("↑/↓/PgUp/PgDn: Scroll g: Top c: Copy response Esc: Back", termWidth-10))
}
return b.String()