mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-10-12 04:35:10 +00:00
* feat: add --context to forward only selected contexts
kportal forwards every context in the config file. When the file describes
several clusters that host the same services, that means holding connections
open to clusters nobody is using, and - because all contexts are active at once
- every localPort has to be globally unique. The same service therefore lands on
a different local port per cluster, so switching clusters means reconfiguring
the app being developed.
--context limits a run to the contexts you name. It is repeatable and accepts
comma-separated values, and composes with -headless:
kportal --context development-team-a
kportal --context team-a,team-b -headless
kportal --context team-a --context team-b
An unknown name is an error listing the contexts the file defines, so a typo
fails loudly instead of silently forwarding nothing.
Local-port and mDNS-hostname conflicts are now scoped to forwards that could run
at the same time: same context, or both contexts in the active selection. With
no selection every context is active, so existing behaviour is unchanged.
Contexts that are never selected together may reuse the same localPort, which is
what lets a service keep one fixed local port in every cluster.
Validation still covers the whole file, so a problem in a context outside the
selection is still reported and --check does not get weaker. Loading, validating
and filtering are shared by every path that re-reads the config (startup, file
watcher, SIGHUP in both headless and verbose modes), so no path can forget the
selection.
Also fixes a latent identity bug this makes reachable: Forward.ID() is only
"alias:localPort" when an alias is set, which was unique across contexts only
because duplicate local ports were globally rejected. With ports now shareable,
deleting or editing a forward from the TUI could have matched same-alias
forwards in other contexts and silently removed them. Delete and edit are now
scoped to the context the row came from.
* fix(config): keep the running config when no selected context remains
A reload dropped selected contexts that no longer exist in the file.
When all of them vanished, the reload returned an empty config and the
manager stopped every forward after a single rename.
LoadForRuntime now returns an error in that case. The watcher keeps the
previous config, as it does for any other invalid reload.
Refs #80
* fix(ui): apply new table columns after a successful SIGHUP reload
The context selection moved the SIGHUP reload into reloadFromSignal,
which had no way to update the verbose table columns. The function now
takes a callback that runs only after the manager reloads without error.
The verbose table passes SetColumns.
Refs #80
* style: gofmt run_test.go
---------
Co-authored-by: André Cruz <[email protected]>
329 lines
7.7 KiB
Go
329 lines
7.7 KiB
Go
package complete
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestGenerateBash(t *testing.T) {
|
|
script, err := Generate(ShellBash)
|
|
if err != nil {
|
|
t.Fatalf("Generate(ShellBash) failed: %v", err)
|
|
}
|
|
|
|
// Verify script contains key elements
|
|
if !strings.Contains(script, "_kportal()") {
|
|
t.Error("Bash script missing _kportal function")
|
|
}
|
|
if !strings.Contains(script, "complete -F _kportal kportal") {
|
|
t.Error("Bash script missing completion registration")
|
|
}
|
|
if !strings.Contains(script, "--version") {
|
|
t.Error("Bash script missing --version flag")
|
|
}
|
|
if !strings.Contains(script, "generate") {
|
|
t.Error("Bash script missing generate subcommand")
|
|
}
|
|
}
|
|
|
|
func TestGenerateZsh(t *testing.T) {
|
|
script, err := Generate(ShellZsh)
|
|
if err != nil {
|
|
t.Fatalf("Generate(ShellZsh) failed: %v", err)
|
|
}
|
|
|
|
// Verify script contains key elements
|
|
if !strings.Contains(script, "#compdef kportal") {
|
|
t.Error("Zsh script missing #compdef directive")
|
|
}
|
|
if !strings.Contains(script, "_kportal()") {
|
|
t.Error("Zsh script missing _kportal function")
|
|
}
|
|
if !strings.Contains(script, "'generate:") {
|
|
t.Error("Zsh script missing generate subcommand")
|
|
}
|
|
if !strings.Contains(script, "'--context[") {
|
|
t.Error("Zsh script missing --context flag")
|
|
}
|
|
}
|
|
|
|
func TestGenerateFish(t *testing.T) {
|
|
script, err := Generate(ShellFish)
|
|
if err != nil {
|
|
t.Fatalf("Generate(ShellFish) failed: %v", err)
|
|
}
|
|
|
|
// Verify script contains key elements
|
|
if !strings.Contains(script, "complete -c kportal") {
|
|
t.Error("Fish script missing complete directive")
|
|
}
|
|
if !strings.Contains(script, "-n '__fish_use_subcommand'") {
|
|
t.Error("Fish script missing subcommand detection")
|
|
}
|
|
if !strings.Contains(script, "-l context") {
|
|
t.Error("Fish script missing --context flag")
|
|
}
|
|
}
|
|
|
|
func TestGenerateUnsupported(t *testing.T) {
|
|
_, err := Generate(Shell("unsupported"))
|
|
if err == nil {
|
|
t.Error("Expected error for unsupported shell")
|
|
}
|
|
}
|
|
|
|
func TestAutoDetectShell(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
shellEnv string
|
|
expected Shell
|
|
}{
|
|
{"bash", "/bin/bash", ShellBash},
|
|
{"zsh", "/usr/bin/zsh", ShellZsh},
|
|
{"fish", "/usr/local/bin/fish", ShellFish},
|
|
{"tcsh", "/bin/tcsh", ShellBash}, // Falls back to bash
|
|
{"empty", "", ShellBash}, // Falls back to bash
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
detected := autoDetectShellFromEnv(tt.shellEnv)
|
|
if detected != tt.expected {
|
|
t.Errorf("AutoDetectShell() = %v, want %v", detected, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// autoDetectShellFromEnv is a test helper that simulates shell detection
|
|
func autoDetectShellFromEnv(shellEnv string) Shell {
|
|
if shellEnv == "" {
|
|
return ShellBash
|
|
}
|
|
if strings.HasSuffix(shellEnv, "/bash") {
|
|
return ShellBash
|
|
}
|
|
if strings.HasSuffix(shellEnv, "/zsh") {
|
|
return ShellZsh
|
|
}
|
|
if strings.HasSuffix(shellEnv, "/fish") {
|
|
return ShellFish
|
|
}
|
|
return ShellBash
|
|
}
|
|
|
|
func TestInstaller(t *testing.T) {
|
|
// Test with bash
|
|
installer := NewInstaller(ShellBash)
|
|
if installer.prefixDir == "" {
|
|
t.Error("Installer should have a prefix directory")
|
|
}
|
|
|
|
// Test filename generation
|
|
t.Run("filename", func(t *testing.T) {
|
|
tests := []struct {
|
|
shell Shell
|
|
expected string
|
|
}{
|
|
{ShellBash, "_kportal"},
|
|
{ShellZsh, "_kportal"},
|
|
{ShellFish, "kportal.fish"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
inst := NewInstaller(tt.shell)
|
|
got := inst.getCompletionFilename()
|
|
if got != tt.expected {
|
|
t.Errorf("getCompletionFilename() for %v = %v, want %v", tt.shell, got, tt.expected)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestInstallCompletion(t *testing.T) {
|
|
// Temp directory (auto-removed by the test framework)
|
|
tempDir := t.TempDir()
|
|
|
|
// Test installation to temp directory
|
|
installer := &Installer{
|
|
shell: ShellBash,
|
|
prefixDir: tempDir,
|
|
}
|
|
|
|
// Should fail because file doesn't exist (doesn't matter, just test the method exists)
|
|
_ = installer.Install()
|
|
}
|
|
|
|
func TestPrint(t *testing.T) {
|
|
// Test that Print doesn't crash and outputs something
|
|
orig := os.Stdout
|
|
r, w, _ := os.Pipe()
|
|
os.Stdout = w
|
|
|
|
err := Print(ShellBash)
|
|
|
|
_ = w.Close()
|
|
os.Stdout = orig
|
|
|
|
if err != nil {
|
|
t.Fatalf("Print(ShellBash) failed: %v", err)
|
|
}
|
|
|
|
// Read output
|
|
buf := make([]byte, 1024)
|
|
n, _ := r.Read(buf)
|
|
output := string(buf[:n])
|
|
|
|
if !strings.Contains(output, "_kportal") {
|
|
t.Error("Print output should contain _kportal")
|
|
}
|
|
}
|
|
|
|
func TestGetCompletionFilename(t *testing.T) {
|
|
tests := []struct {
|
|
shell Shell
|
|
expected string
|
|
}{
|
|
{ShellBash, "_kportal"},
|
|
{ShellZsh, "_kportal"},
|
|
{ShellFish, "kportal.fish"},
|
|
{Shell("unknown"), "_kportal"}, // Falls back
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
inst := &Installer{shell: tt.shell}
|
|
got := inst.getCompletionFilename()
|
|
if got != tt.expected {
|
|
t.Errorf("getCompletionFilename() for %v = %v, want %v", tt.shell, got, tt.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBashCompletionIncludesContextCompletion(t *testing.T) {
|
|
script, err := Generate(ShellBash)
|
|
if err != nil {
|
|
t.Fatalf("Generate(ShellBash) failed: %v", err)
|
|
}
|
|
|
|
// Should include kubectl context completion
|
|
if !strings.Contains(script, "kubectl config get-contexts") {
|
|
t.Error("Bash completion should include kubectl context completion")
|
|
}
|
|
}
|
|
|
|
func TestZshCompletionIncludesContextCompletion(t *testing.T) {
|
|
script, err := Generate(ShellZsh)
|
|
if err != nil {
|
|
t.Fatalf("Generate(ShellZsh) failed: %v", err)
|
|
}
|
|
|
|
// Should include kubectl context completion
|
|
if !strings.Contains(script, "kubectl config get-contexts") {
|
|
t.Error("Zsh completion should include kubectl context completion")
|
|
}
|
|
}
|
|
|
|
func TestFishCompletionIncludesContextCompletion(t *testing.T) {
|
|
script, err := Generate(ShellFish)
|
|
if err != nil {
|
|
t.Fatalf("Generate(ShellFish) failed: %v", err)
|
|
}
|
|
|
|
// Should include kubectl context completion
|
|
if !strings.Contains(script, "kubectl config get-contexts") {
|
|
t.Error("Fish completion should include kubectl context completion")
|
|
}
|
|
}
|
|
|
|
func TestAllFlagsPresent(t *testing.T) {
|
|
script, err := Generate(ShellBash)
|
|
if err != nil {
|
|
t.Fatalf("Generate(ShellBash) failed: %v", err)
|
|
}
|
|
|
|
// Check all main flags are present. The main command exposes single-dash
|
|
// -c/-v (no --config/--verbose long forms); word flags use --.
|
|
flags := []string{
|
|
"-c",
|
|
"-v",
|
|
"--version",
|
|
"--update",
|
|
"--check",
|
|
"--context",
|
|
"--headless",
|
|
"--log-format",
|
|
"--convert",
|
|
"--convert-output",
|
|
}
|
|
|
|
for _, flag := range flags {
|
|
if !strings.Contains(script, flag) {
|
|
t.Errorf("Bash completion missing flag: %s", flag)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSubcommandsPresent(t *testing.T) {
|
|
for _, shell := range []Shell{ShellBash, ShellZsh, ShellFish} {
|
|
script, err := Generate(shell)
|
|
if err != nil {
|
|
t.Fatalf("Generate(%v) failed: %v", shell, err)
|
|
}
|
|
|
|
for _, sub := range []string{"generate", "completion"} {
|
|
if !strings.Contains(script, sub) {
|
|
t.Errorf("%s completion missing %q subcommand", shell, sub)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGenerateFlagsPresent(t *testing.T) {
|
|
script, err := Generate(ShellBash)
|
|
if err != nil {
|
|
t.Fatalf("Generate(ShellBash) failed: %v", err)
|
|
}
|
|
|
|
generateFlags := []string{
|
|
"--context",
|
|
"--config",
|
|
"--dry-run",
|
|
}
|
|
|
|
for _, flag := range generateFlags {
|
|
if !strings.Contains(script, flag) {
|
|
t.Errorf("Bash completion missing generate flag: %s", flag)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCompletionScriptPermissions(t *testing.T) {
|
|
// Create temp file and verify permissions handling
|
|
tempDir := t.TempDir()
|
|
|
|
installer := &Installer{
|
|
shell: ShellBash,
|
|
prefixDir: tempDir,
|
|
}
|
|
|
|
filename := filepath.Join(tempDir, installer.getCompletionFilename())
|
|
|
|
// Write a test file
|
|
err := os.WriteFile(filename, []byte("test"), 0o600)
|
|
if err != nil {
|
|
t.Fatalf("Failed to write test file: %v", err)
|
|
}
|
|
|
|
// Verify file exists and is readable
|
|
data, err := os.ReadFile(filename)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read test file: %v", err)
|
|
}
|
|
|
|
if string(data) != "test" {
|
|
t.Error("File content mismatch")
|
|
}
|
|
}
|