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]>
496 lines
14 KiB
Go
496 lines
14 KiB
Go
package ui
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/lukaszraczylo/kportal/internal/k8s"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestMessageTypes tests the message type structures
|
|
func TestMessageTypes(t *testing.T) {
|
|
t.Run("ContextsLoadedMsg", func(t *testing.T) {
|
|
msg := ContextsLoadedMsg{
|
|
contexts: []string{"ctx1", "ctx2"},
|
|
}
|
|
assert.Len(t, msg.contexts, 2)
|
|
assert.Nil(t, msg.err)
|
|
|
|
errMsg := ContextsLoadedMsg{
|
|
err: assert.AnError,
|
|
}
|
|
assert.NotNil(t, errMsg.err)
|
|
})
|
|
|
|
t.Run("NamespacesLoadedMsg", func(t *testing.T) {
|
|
msg := NamespacesLoadedMsg{
|
|
namespaces: []string{"default", "kube-system"},
|
|
}
|
|
assert.Len(t, msg.namespaces, 2)
|
|
assert.Nil(t, msg.err)
|
|
})
|
|
|
|
t.Run("PodsLoadedMsg", func(t *testing.T) {
|
|
msg := PodsLoadedMsg{
|
|
pods: []k8s.PodInfo{
|
|
{Name: "pod1", Namespace: "default"},
|
|
{Name: "pod2", Namespace: "default"},
|
|
},
|
|
}
|
|
assert.Len(t, msg.pods, 2)
|
|
assert.Nil(t, msg.err)
|
|
})
|
|
|
|
t.Run("ServicesLoadedMsg", func(t *testing.T) {
|
|
msg := ServicesLoadedMsg{
|
|
services: []k8s.ServiceInfo{
|
|
{Name: "svc1", Namespace: "default"},
|
|
},
|
|
}
|
|
assert.Len(t, msg.services, 1)
|
|
assert.Nil(t, msg.err)
|
|
})
|
|
|
|
t.Run("SelectorValidatedMsg", func(t *testing.T) {
|
|
validMsg := SelectorValidatedMsg{
|
|
valid: true,
|
|
pods: []k8s.PodInfo{
|
|
{Name: "matched-pod"},
|
|
},
|
|
}
|
|
assert.True(t, validMsg.valid)
|
|
assert.Len(t, validMsg.pods, 1)
|
|
|
|
invalidMsg := SelectorValidatedMsg{
|
|
valid: false,
|
|
err: assert.AnError,
|
|
}
|
|
assert.False(t, invalidMsg.valid)
|
|
assert.NotNil(t, invalidMsg.err)
|
|
})
|
|
|
|
t.Run("PortCheckedMsg", func(t *testing.T) {
|
|
availableMsg := PortCheckedMsg{
|
|
port: 8080,
|
|
available: true,
|
|
message: "Port 8080 available",
|
|
}
|
|
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) {
|
|
successMsg := ForwardSavedMsg{success: true}
|
|
assert.True(t, successMsg.success)
|
|
|
|
failMsg := ForwardSavedMsg{success: false, err: assert.AnError}
|
|
assert.False(t, failMsg.success)
|
|
assert.NotNil(t, failMsg.err)
|
|
})
|
|
|
|
t.Run("ForwardsRemovedMsg", func(t *testing.T) {
|
|
msg := ForwardsRemovedMsg{
|
|
success: true,
|
|
count: 3,
|
|
}
|
|
assert.True(t, msg.success)
|
|
assert.Equal(t, 3, msg.count)
|
|
})
|
|
|
|
t.Run("WizardCompleteMsg", func(t *testing.T) {
|
|
msg := WizardCompleteMsg{}
|
|
assert.NotNil(t, msg)
|
|
})
|
|
|
|
t.Run("BenchmarkCompleteMsg", func(t *testing.T) {
|
|
msg := BenchmarkCompleteMsg{
|
|
ForwardID: "fwd-123",
|
|
}
|
|
assert.Equal(t, "fwd-123", msg.ForwardID)
|
|
assert.Nil(t, msg.Results)
|
|
assert.Nil(t, msg.Error)
|
|
})
|
|
|
|
t.Run("BenchmarkProgressMsg", func(t *testing.T) {
|
|
msg := BenchmarkProgressMsg{
|
|
ForwardID: "fwd-123",
|
|
Completed: 50,
|
|
Total: 100,
|
|
}
|
|
assert.Equal(t, "fwd-123", msg.ForwardID)
|
|
assert.Equal(t, 50, msg.Completed)
|
|
assert.Equal(t, 100, msg.Total)
|
|
})
|
|
|
|
t.Run("HTTPLogEntryMsg", func(t *testing.T) {
|
|
msg := HTTPLogEntryMsg{
|
|
Entry: HTTPLogEntry{
|
|
Method: "GET",
|
|
Path: "/api/test",
|
|
StatusCode: 200,
|
|
},
|
|
}
|
|
assert.Equal(t, "GET", msg.Entry.Method)
|
|
assert.Equal(t, "/api/test", msg.Entry.Path)
|
|
assert.Equal(t, 200, msg.Entry.StatusCode)
|
|
})
|
|
}
|
|
|
|
// TestCheckPortCmd tests the port availability check command
|
|
func TestCheckPortCmd_PortAvailability(t *testing.T) {
|
|
// Create a temporary config file for testing
|
|
tmpDir := t.TempDir()
|
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
|
|
|
// Create an empty config file
|
|
err := os.WriteFile(configPath, []byte("contexts: []\n"), 0600)
|
|
require.NoError(t, err)
|
|
|
|
// Test checking a random high port that should be available
|
|
cmd := checkPortCmd(59999, configPath, "", "test-ctx", nil)
|
|
msg := cmd()
|
|
|
|
portMsg, ok := msg.(PortCheckedMsg)
|
|
require.True(t, ok, "Expected PortCheckedMsg")
|
|
assert.Equal(t, 59999, portMsg.port)
|
|
// The port may or may not be available depending on the system,
|
|
// but we verify the message structure is correct
|
|
assert.NotEmpty(t, portMsg.message)
|
|
}
|
|
|
|
// TestCheckPortCmd_ConfigConflict tests port conflict detection in config
|
|
func TestCheckPortCmd_ConfigConflict(t *testing.T) {
|
|
// Create a temporary config file with a forward using port 8080
|
|
tmpDir := t.TempDir()
|
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
|
|
|
configContent := `contexts:
|
|
- name: test-ctx
|
|
namespaces:
|
|
- name: default
|
|
forwards:
|
|
- resource: pod/my-app
|
|
port: 80
|
|
localPort: 8080
|
|
`
|
|
err := os.WriteFile(configPath, []byte(configContent), 0600)
|
|
require.NoError(t, err)
|
|
|
|
// Test checking port that's already in config
|
|
cmd := checkPortCmd(8080, configPath, "", "test-ctx", nil)
|
|
msg := cmd()
|
|
|
|
portMsg, ok := msg.(PortCheckedMsg)
|
|
require.True(t, ok, "Expected PortCheckedMsg")
|
|
assert.Equal(t, 8080, portMsg.port)
|
|
assert.False(t, portMsg.available, "Port should not be available (in config)")
|
|
assert.Contains(t, portMsg.message, "already assigned")
|
|
}
|
|
|
|
// TestCheckPortCmd_ExcludeID_AllowsKeepingOwnPort verifies that in edit mode
|
|
// (excludeID set to the forward's own ID), the wizard does not falsely report
|
|
// the same local port as already in use by the forward being edited.
|
|
func TestCheckPortCmd_ExcludeID_AllowsKeepingOwnPort(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
|
|
|
configContent := `contexts:
|
|
- name: test-ctx
|
|
namespaces:
|
|
- name: default
|
|
forwards:
|
|
- resource: pod/my-app
|
|
port: 80
|
|
localPort: 8080
|
|
`
|
|
err := os.WriteFile(configPath, []byte(configContent), 0600)
|
|
require.NoError(t, err)
|
|
|
|
// The forward's ID format is "<context>/<namespace>/<resource>:<port>".
|
|
excludeID := "test-ctx/default/pod/my-app:8080"
|
|
|
|
cmd := checkPortCmd(8080, configPath, excludeID, "test-ctx", nil)
|
|
msg := cmd()
|
|
|
|
portMsg, ok := msg.(PortCheckedMsg)
|
|
require.True(t, ok, "Expected PortCheckedMsg")
|
|
assert.Equal(t, 8080, portMsg.port)
|
|
// The config-conflict path must skip the excluded ID. The OS-level port
|
|
// availability check still runs, so the result depends on whether 8080 is
|
|
// in use by some other process — the relevant assertion is that the
|
|
// message does NOT mention "already assigned" (which is the config check).
|
|
assert.NotContains(t, portMsg.message, "already assigned",
|
|
"excludeID should suppress the config self-conflict, but got %q", portMsg.message)
|
|
}
|
|
|
|
// TestCheckPortCmd_InvalidConfig tests behavior with invalid config file
|
|
func TestCheckPortCmd_InvalidConfig(t *testing.T) {
|
|
// Use a non-existent config path
|
|
cmd := checkPortCmd(59998, "/nonexistent/path/.kportal.yaml", "", "test-ctx", nil)
|
|
msg := cmd()
|
|
|
|
portMsg, ok := msg.(PortCheckedMsg)
|
|
require.True(t, ok, "Expected PortCheckedMsg")
|
|
// Should still return a result (just skip config check)
|
|
assert.Equal(t, 59998, portMsg.port)
|
|
assert.NotEmpty(t, portMsg.message)
|
|
}
|
|
|
|
// TestListenBenchmarkProgressCmd tests the progress listener command
|
|
func TestListenBenchmarkProgressCmd(t *testing.T) {
|
|
progressCh := make(chan BenchmarkProgressMsg, 1)
|
|
|
|
// Send a progress message
|
|
progressCh <- BenchmarkProgressMsg{
|
|
ForwardID: "fwd-123",
|
|
Completed: 25,
|
|
Total: 100,
|
|
}
|
|
|
|
cmd := listenBenchmarkProgressCmd(progressCh)
|
|
msg := cmd()
|
|
|
|
progressMsg, ok := msg.(BenchmarkProgressMsg)
|
|
require.True(t, ok, "Expected BenchmarkProgressMsg")
|
|
assert.Equal(t, "fwd-123", progressMsg.ForwardID)
|
|
assert.Equal(t, 25, progressMsg.Completed)
|
|
assert.Equal(t, 100, progressMsg.Total)
|
|
}
|
|
|
|
// TestListenBenchmarkProgressCmd_ChannelClosed tests behavior when channel closes
|
|
func TestListenBenchmarkProgressCmd_ChannelClosed(t *testing.T) {
|
|
progressCh := make(chan BenchmarkProgressMsg)
|
|
close(progressCh)
|
|
|
|
cmd := listenBenchmarkProgressCmd(progressCh)
|
|
msg := cmd()
|
|
|
|
assert.Nil(t, msg, "Should return nil when channel is closed")
|
|
}
|
|
|
|
// TestRunBenchmarkCmd_Cancellation tests benchmark cancellation
|
|
func TestRunBenchmarkCmd_Cancellation(t *testing.T) {
|
|
// Create a context that's already cancelled
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // Cancel immediately
|
|
|
|
progressCh := make(chan BenchmarkProgressMsg, 100)
|
|
|
|
cmd := runBenchmarkCmd(ctx, "fwd-123", 59997, "/", "GET", 1, 10, progressCh)
|
|
|
|
// Run with timeout to prevent hanging
|
|
done := make(chan bool, 1)
|
|
var msg any
|
|
go func() {
|
|
msg = cmd()
|
|
done <- true
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
// Command completed
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("runBenchmarkCmd timed out")
|
|
}
|
|
|
|
completeMsg, ok := msg.(BenchmarkCompleteMsg)
|
|
require.True(t, ok, "Expected BenchmarkCompleteMsg")
|
|
assert.Equal(t, "fwd-123", completeMsg.ForwardID)
|
|
// When cancelled, we expect either an error or the context cancellation message
|
|
// The benchmark may or may not have had time to process the cancellation
|
|
}
|
|
|
|
// TestK8sAPITimeout tests that the timeout constant is correct
|
|
func TestK8sAPITimeout(t *testing.T) {
|
|
assert.Equal(t, 10*time.Second, k8sAPITimeout)
|
|
}
|
|
|
|
// TestRemovableForwardStruct tests the RemovableForward structure used by commands
|
|
func TestRemovableForwardStruct(t *testing.T) {
|
|
rf := RemovableForward{
|
|
ID: "fwd-123",
|
|
Context: "prod",
|
|
Namespace: "default",
|
|
Resource: "pod/my-app",
|
|
Selector: "app=my-app",
|
|
Alias: "my-app",
|
|
Port: 80,
|
|
LocalPort: 8080,
|
|
}
|
|
|
|
assert.Equal(t, "fwd-123", rf.ID)
|
|
assert.Equal(t, "prod", rf.Context)
|
|
assert.Equal(t, "default", rf.Namespace)
|
|
assert.Equal(t, "pod/my-app", rf.Resource)
|
|
assert.Equal(t, "app=my-app", rf.Selector)
|
|
assert.Equal(t, "my-app", rf.Alias)
|
|
assert.Equal(t, 80, rf.Port)
|
|
assert.Equal(t, 8080, rf.LocalPort)
|
|
}
|
|
|
|
// TestBenchmarkProgressCallback tests the progress callback in runBenchmarkCmd
|
|
func TestBenchmarkProgressCallback(t *testing.T) {
|
|
// Test that progress channel handles blocking gracefully
|
|
progressCh := make(chan BenchmarkProgressMsg, 1) // Small buffer
|
|
|
|
// Fill the channel
|
|
progressCh <- BenchmarkProgressMsg{Completed: 1, Total: 100}
|
|
|
|
// Test non-blocking send by creating callback similar to runBenchmarkCmd
|
|
callback := func(completed, total int) {
|
|
select {
|
|
case progressCh <- BenchmarkProgressMsg{
|
|
ForwardID: "test",
|
|
Completed: completed,
|
|
Total: total,
|
|
}:
|
|
default:
|
|
// Drop if channel is full - should not block
|
|
}
|
|
}
|
|
|
|
// Should not block even with full channel
|
|
done := make(chan bool, 1)
|
|
go func() {
|
|
callback(50, 100) // This should not block
|
|
done <- true
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
// Success - didn't block
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Fatal("Callback blocked when channel was full")
|
|
}
|
|
}
|
|
|
|
// TestHTTPLogEntry tests the HTTPLogEntry structure
|
|
func TestHTTPLogEntry(t *testing.T) {
|
|
entry := HTTPLogEntry{
|
|
Timestamp: "2025-11-26T10:30:00Z",
|
|
Direction: "request",
|
|
Method: "POST",
|
|
Path: "/api/users",
|
|
StatusCode: 201,
|
|
LatencyMs: 150,
|
|
BodySize: 1024,
|
|
}
|
|
|
|
assert.Equal(t, "2025-11-26T10:30:00Z", entry.Timestamp)
|
|
assert.Equal(t, "request", entry.Direction)
|
|
assert.Equal(t, "POST", entry.Method)
|
|
assert.Equal(t, "/api/users", entry.Path)
|
|
assert.Equal(t, 201, entry.StatusCode)
|
|
assert.Equal(t, int64(150), entry.LatencyMs)
|
|
assert.Equal(t, 1024, entry.BodySize)
|
|
}
|
|
|
|
// TestHTTPLogSubscriberType tests the HTTPLogSubscriber function type
|
|
func TestHTTPLogSubscriberType(t *testing.T) {
|
|
// Test that our mock matches the type
|
|
mock := NewMockHTTPLogSubscriber()
|
|
subscriber := mock.GetSubscriberFunc()
|
|
|
|
// Test subscription
|
|
callCount := 0
|
|
cleanup := subscriber("fwd-123", func(entry HTTPLogEntry) {
|
|
callCount++
|
|
})
|
|
|
|
// Send an entry
|
|
mock.SendEntry("fwd-123", HTTPLogEntry{Method: "GET"})
|
|
assert.Equal(t, 1, callCount)
|
|
|
|
// Clean up
|
|
cleanup()
|
|
assert.Equal(t, 1, mock.CleanupCalls)
|
|
}
|
|
|
|
// TestCheckPortCmd_ContextScoping verifies the add/edit wizard does not report a
|
|
// port as taken when the only forward using it lives in a context that is not
|
|
// being forwarded.
|
|
func TestCheckPortCmd_ContextScoping(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
configPath := filepath.Join(tmpDir, ".kportal.yaml")
|
|
|
|
configContent := `contexts:
|
|
- name: team-a
|
|
namespaces:
|
|
- name: default
|
|
forwards:
|
|
- resource: pod/my-app
|
|
port: 80
|
|
localPort: 8080
|
|
- name: team-b
|
|
namespaces:
|
|
- name: default
|
|
forwards:
|
|
- resource: pod/other-app
|
|
port: 80
|
|
localPort: 9090
|
|
`
|
|
require.NoError(t, os.WriteFile(configPath, []byte(configContent), 0600))
|
|
|
|
tests := []struct {
|
|
name string
|
|
targetContext string
|
|
activeContexts []string
|
|
port int
|
|
wantAssignedMsg bool
|
|
}{
|
|
{
|
|
name: "port used only by a non-active context is free",
|
|
port: 9090, targetContext: "team-a", activeContexts: []string{"team-a"},
|
|
wantAssignedMsg: false,
|
|
},
|
|
{
|
|
name: "port used by the target context is taken",
|
|
port: 8080, targetContext: "team-a", activeContexts: []string{"team-a"},
|
|
wantAssignedMsg: true,
|
|
},
|
|
{
|
|
name: "both contexts active means the port is taken",
|
|
port: 9090, targetContext: "team-a", activeContexts: []string{"team-a", "team-b"},
|
|
wantAssignedMsg: true,
|
|
},
|
|
{
|
|
name: "no selection keeps the global check",
|
|
port: 9090, targetContext: "team-a", activeContexts: nil,
|
|
wantAssignedMsg: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
msg := checkPortCmd(tt.port, configPath, "", tt.targetContext, tt.activeContexts)()
|
|
|
|
portMsg, ok := msg.(PortCheckedMsg)
|
|
require.True(t, ok, "Expected PortCheckedMsg")
|
|
assert.Equal(t, tt.port, portMsg.port)
|
|
|
|
if tt.wantAssignedMsg {
|
|
assert.Contains(t, portMsg.message, "already assigned")
|
|
} else {
|
|
// The OS-level check still runs, so only assert that the
|
|
// in-config conflict path did not fire.
|
|
assert.NotContains(t, portMsg.message, "already assigned")
|
|
}
|
|
})
|
|
}
|
|
}
|