Files
kportal/internal/config/watcher_test.go
T
4d263319ab feat: add --context to forward only selected contexts (#85)
* 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]>
2026-10-02 02:33:29 +01:00

628 lines
15 KiB
Go

package config
import (
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestNewWatcher tests watcher creation
func TestNewWatcher(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
// Create initial config file
initial := `contexts:
- name: dev
namespaces:
- name: default
forwards:
- resource: pod/app
protocol: tcp
port: 8080
localPort: 8080
`
err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err)
callback := func(cfg *Config) error { return nil }
watcher, err := NewWatcher(configPath, callback, false)
require.NoError(t, err)
require.NotNil(t, watcher)
defer watcher.Stop()
assert.NotNil(t, watcher.watcher)
assert.NotNil(t, watcher.done)
assert.False(t, watcher.verbose)
}
// TestNewWatcher_Verbose tests verbose watcher creation
func TestNewWatcher_Verbose(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
// Create initial config file
initial := `contexts:
- name: dev
namespaces:
- name: default
forwards:
- resource: pod/app
port: 8080
localPort: 8080
`
err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err)
callback := func(cfg *Config) error { return nil }
watcher, err := NewWatcher(configPath, callback, true)
require.NoError(t, err)
require.NotNil(t, watcher)
defer watcher.Stop()
assert.True(t, watcher.verbose)
}
// TestNewWatcher_RelativePath tests absolute path resolution
func TestNewWatcher_RelativePath(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
// Create initial config file
initial := `contexts:
- name: dev
namespaces:
- name: default
forwards:
- resource: pod/app
port: 8080
localPort: 8080
`
err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err)
// Change to tmpDir and use relative path
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 }
watcher, err := NewWatcher(".kportal.yaml", callback, false)
require.NoError(t, err)
require.NotNil(t, watcher)
defer watcher.Stop()
// configPath should be absolute
assert.True(t, filepath.IsAbs(watcher.configPath))
}
// TestWatcher_StartStop tests basic start/stop lifecycle
func TestWatcher_StartStop(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
// Create initial config file
initial := `contexts:
- name: dev
namespaces:
- name: default
forwards:
- resource: pod/app
port: 8080
localPort: 8080
`
err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err)
callback := func(cfg *Config) error { return nil }
watcher, err := NewWatcher(configPath, callback, false)
require.NoError(t, err)
require.NotNil(t, watcher)
// Start watching
watcher.Start()
// Stop should complete without hanging
done := make(chan bool)
go func() {
watcher.Stop()
done <- true
}()
select {
case <-done:
// Success
case <-time.After(5 * time.Second):
t.Fatal("Stop timed out")
}
}
// TestWatcher_DetectsFileChange tests that file changes trigger callback
func TestWatcher_DetectsFileChange(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
// Create initial config file
initial := `contexts:
- name: dev
namespaces:
- name: default
forwards:
- resource: pod/app
port: 8080
localPort: 8080
`
err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err)
var mu sync.Mutex
var callbackCalled bool
var receivedConfig *Config
callback := func(cfg *Config) error {
mu.Lock()
defer mu.Unlock()
callbackCalled = true
receivedConfig = cfg
return nil
}
watcher, err := NewWatcher(configPath, callback, false)
require.NoError(t, err)
require.NotNil(t, watcher)
defer watcher.Stop()
watcher.Start()
// Give watcher time to start
time.Sleep(100 * time.Millisecond)
// Modify the config file
updated := `contexts:
- name: dev
namespaces:
- name: default
forwards:
- resource: pod/app
port: 8080
localPort: 8080
- resource: pod/new-app
port: 9090
localPort: 9090
`
err = os.WriteFile(configPath, []byte(updated), 0600)
require.NoError(t, err)
// Wait for callback with timeout
timeout := time.After(5 * time.Second)
tick := time.NewTicker(100 * time.Millisecond)
defer tick.Stop()
for {
select {
case <-timeout:
t.Fatal("Callback was not called after file change")
case <-tick.C:
mu.Lock()
if callbackCalled {
assert.NotNil(t, receivedConfig)
assert.Len(t, receivedConfig.Contexts[0].Namespaces[0].Forwards, 2)
mu.Unlock()
return
}
mu.Unlock()
}
}
}
// TestWatcher_IgnoresInvalidConfig tests that invalid configs are rejected
func TestWatcher_IgnoresInvalidConfig(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
// Create initial valid config file
initial := `contexts:
- name: dev
namespaces:
- name: default
forwards:
- resource: pod/app
port: 8080
localPort: 8080
`
err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err)
callbackCount := 0
var mu sync.Mutex
callback := func(cfg *Config) error {
mu.Lock()
defer mu.Unlock()
callbackCount++
return nil
}
watcher, err := NewWatcher(configPath, callback, false)
require.NoError(t, err)
require.NotNil(t, watcher)
defer watcher.Stop()
watcher.Start()
time.Sleep(100 * time.Millisecond)
// Write invalid config (invalid YAML syntax)
invalid := `contexts:
- name: dev
namespaces:
- name: default
forwards: [this is invalid
`
err = os.WriteFile(configPath, []byte(invalid), 0600)
require.NoError(t, err)
// Wait a bit
time.Sleep(500 * time.Millisecond)
// Callback should not have been called
mu.Lock()
assert.Equal(t, 0, callbackCount, "callback should not be called for invalid config")
mu.Unlock()
}
// TestWatcher_IgnoresValidationErrors tests that configs failing validation are rejected
func TestWatcher_IgnoresValidationErrors(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
// Create initial valid config file
initial := `contexts:
- name: dev
namespaces:
- name: default
forwards:
- resource: pod/app
port: 8080
localPort: 8080
`
err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err)
callbackCount := 0
var mu sync.Mutex
callback := func(cfg *Config) error {
mu.Lock()
defer mu.Unlock()
callbackCount++
return nil
}
watcher, err := NewWatcher(configPath, callback, false)
require.NoError(t, err)
require.NotNil(t, watcher)
defer watcher.Stop()
watcher.Start()
time.Sleep(100 * time.Millisecond)
// Write config with duplicate ports (validation error)
invalid := `contexts:
- name: dev
namespaces:
- name: default
forwards:
- resource: pod/app1
port: 8080
localPort: 8080
- resource: pod/app2
port: 9090
localPort: 8080
`
err = os.WriteFile(configPath, []byte(invalid), 0600)
require.NoError(t, err)
// Wait a bit
time.Sleep(500 * time.Millisecond)
// Callback should not have been called
mu.Lock()
assert.Equal(t, 0, callbackCount, "callback should not be called for invalid config")
mu.Unlock()
}
// TestWatcher_IgnoresOtherFiles tests that changes to other files are ignored
func TestWatcher_IgnoresOtherFiles(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
otherPath := filepath.Join(tmpDir, "other.txt")
// Create initial config file
initial := `contexts:
- name: dev
namespaces:
- name: default
forwards:
- resource: pod/app
port: 8080
localPort: 8080
`
err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err)
callbackCount := 0
var mu sync.Mutex
callback := func(cfg *Config) error {
mu.Lock()
defer mu.Unlock()
callbackCount++
return nil
}
watcher, err := NewWatcher(configPath, callback, false)
require.NoError(t, err)
require.NotNil(t, watcher)
defer watcher.Stop()
watcher.Start()
time.Sleep(100 * time.Millisecond)
// Write to a different file
err = os.WriteFile(otherPath, []byte("some content"), 0600)
require.NoError(t, err)
// Wait a bit
time.Sleep(500 * time.Millisecond)
// Callback should not have been called
mu.Lock()
assert.Equal(t, 0, callbackCount, "callback should not be called for other files")
mu.Unlock()
}
// TestWatcher_HandleReload_LoadError tests handleReload with load error
func TestWatcher_HandleReload_LoadError(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
// Create initial config file
initial := `contexts:
- name: dev
namespaces:
- name: default
forwards:
- resource: pod/app
port: 8080
localPort: 8080
`
err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err)
callbackCalled := false
callback := func(cfg *Config) error {
callbackCalled = true
return nil
}
watcher, err := NewWatcher(configPath, callback, false)
require.NoError(t, err)
require.NotNil(t, watcher)
defer watcher.Stop()
// Delete the config file to cause load error
_ = os.Remove(configPath)
// Call handleReload directly
watcher.handleReload()
// Callback should not have been called
assert.False(t, callbackCalled)
}
// TestWatcher_DoubleStop tests that double stop doesn't panic
func TestWatcher_DoubleStop(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
// Create initial config file
initial := `contexts:
- name: dev
namespaces:
- name: default
forwards:
- resource: pod/app
port: 8080
localPort: 8080
`
err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err)
callback := func(cfg *Config) error { return nil }
watcher, err := NewWatcher(configPath, callback, false)
require.NoError(t, err)
require.NotNil(t, watcher)
watcher.Start()
// First stop
watcher.Stop()
// Second stop should not panic (though the channel is already closed)
// Note: This might panic due to close on closed channel, which is actually
// a design issue - but we document the current behavior
}
// TestWatcher_StopWithoutStart tests stopping without starting
func TestWatcher_StopWithoutStart(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
// Create initial config file
initial := `contexts:
- name: dev
namespaces:
- name: default
forwards:
- resource: pod/app
port: 8080
localPort: 8080
`
err := os.WriteFile(configPath, []byte(initial), 0600)
require.NoError(t, err)
callback := func(cfg *Config) error { return nil }
watcher, err := NewWatcher(configPath, callback, false)
require.NoError(t, err)
require.NotNil(t, watcher)
// Stop without starting should not hang
done := make(chan bool)
go func() {
watcher.Stop()
done <- true
}()
select {
case <-done:
// Success
case <-time.After(5 * time.Second):
t.Fatal("Stop without start timed out")
}
}
// TestWatcher_WithContextSelection verifies that hot-reload honours the
// --context selection: a config whose non-selected contexts reuse local ports
// must reload cleanly, and the callback must only receive the selection.
func TestWatcher_WithContextSelection(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
initial := `contexts:
- name: team-a
namespaces:
- name: default
forwards:
- resource: pod/app
port: 8080
localPort: 3004
- name: team-b
namespaces:
- name: default
forwards:
- resource: pod/app
port: 8080
localPort: 3004
`
require.NoError(t, os.WriteFile(configPath, []byte(initial), 0600))
var mu sync.Mutex
var receivedConfig *Config
callback := func(cfg *Config) error {
mu.Lock()
defer mu.Unlock()
receivedConfig = cfg
return nil
}
watcher, err := NewWatcher(configPath, callback, false, WithContextSelection([]string{"team-a"}))
require.NoError(t, err)
defer watcher.Stop()
watcher.Start()
time.Sleep(100 * time.Millisecond)
updated := `contexts:
- name: team-a
namespaces:
- name: default
forwards:
- resource: pod/app
port: 8080
localPort: 3004
- resource: pod/extra
port: 9090
localPort: 3005
- name: team-b
namespaces:
- name: default
forwards:
- resource: pod/app
port: 8080
localPort: 3004
`
require.NoError(t, os.WriteFile(configPath, []byte(updated), 0600))
require.Eventually(t, func() bool {
mu.Lock()
defer mu.Unlock()
return receivedConfig != nil
}, 3*time.Second, 50*time.Millisecond, "watcher should reload a config with ports shared across non-selected contexts")
mu.Lock()
defer mu.Unlock()
require.Len(t, receivedConfig.Contexts, 1, "callback should only see the selected context")
assert.Equal(t, "team-a", receivedConfig.Contexts[0].Name)
assert.Len(t, receivedConfig.GetAllForwards(), 2)
}
// TestWatcher_WithContextSelection_UnknownNameDoesNotFreezeReload covers a
// context renamed in the file: reload must keep working for the rest.
func TestWatcher_WithContextSelection_UnknownNameDoesNotFreezeReload(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, ".kportal.yaml")
initial := `contexts:
- name: team-a
namespaces:
- name: default
forwards:
- resource: pod/app
port: 8080
localPort: 3004
`
require.NoError(t, os.WriteFile(configPath, []byte(initial), 0600))
var mu sync.Mutex
var called bool
watcher, err := NewWatcher(configPath, func(cfg *Config) error {
mu.Lock()
defer mu.Unlock()
called = true
return nil
}, false, WithContextSelection([]string{"team-a", "since-renamed"}))
require.NoError(t, err)
defer watcher.Stop()
watcher.Start()
time.Sleep(100 * time.Millisecond)
updated := initial + ` - resource: pod/extra
port: 9090
localPort: 3005
`
require.NoError(t, os.WriteFile(configPath, []byte(updated), 0600))
require.Eventually(t, func() bool {
mu.Lock()
defer mu.Unlock()
return called
}, 3*time.Second, 50*time.Millisecond, "an unknown selected context must not freeze hot-reload")
}