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]>
This commit is contained in:
2026-10-02 02:33:29 +01:00
committed by GitHub
co-authored by André Cruz
parent 4748bc4be5
commit 4d263319ab
24 changed files with 1761 additions and 143 deletions
+40 -27
View File
@@ -69,33 +69,35 @@ type HTTPLogSubscriber func(forwardID string, callback func(entry HTTPLogEntry))
// BubbleTeaUI is a bubbletea-based terminal UI
type BubbleTeaUI struct {
discovery *k8s.Discovery
program *tea.Program
forwards map[string]*ForwardStatus
columns []ResolvedColumn
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
version string
forwardOrder []string
viewMode ViewMode
deleteConfirmCursor int
selectedIndex int
mu sync.RWMutex
deleteConfirming bool
updateAvailable 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
deleteConfirmContext string
deleteConfirmAlias string
version string
forwardOrder []string
columns []ResolvedColumn
activeContexts []string
viewMode ViewMode
deleteConfirmCursor int
selectedIndex int
mu sync.RWMutex
deleteConfirming bool
updateAvailable bool
}
// bubbletea model
@@ -140,6 +142,16 @@ func (ui *BubbleTeaUI) SetWizardDependencies(discovery *k8s.Discovery, mutator *
ui.configPath = configPath
}
// SetActiveContexts records the contexts selected with --context so the add
// and edit wizards only report local-port conflicts against forwards that
// could actually be running. An empty selection means every context is active.
func (ui *BubbleTeaUI) SetActiveContexts(names []string) {
ui.mu.Lock()
defer ui.mu.Unlock()
ui.activeContexts = names
}
// SetHTTPLogSubscriber sets the function to subscribe to HTTP logs
func (ui *BubbleTeaUI) SetHTTPLogSubscriber(subscriber HTTPLogSubscriber) {
ui.mu.Lock()
@@ -900,6 +912,7 @@ func (ui *BubbleTeaUI) moveSelection(delta int) {
func (ui *BubbleTeaUI) resetDeleteConfirmation() {
ui.deleteConfirming = false
ui.deleteConfirmID = ""
ui.deleteConfirmContext = ""
ui.deleteConfirmAlias = ""
ui.deleteConfirmCursor = 0
}
+77 -4
View File
@@ -162,7 +162,7 @@ func TestCheckPortCmd_PortAvailability(t *testing.T) {
require.NoError(t, err)
// Test checking a random high port that should be available
cmd := checkPortCmd(59999, configPath, "")
cmd := checkPortCmd(59999, configPath, "", "test-ctx", nil)
msg := cmd()
portMsg, ok := msg.(PortCheckedMsg)
@@ -192,7 +192,7 @@ func TestCheckPortCmd_ConfigConflict(t *testing.T) {
require.NoError(t, err)
// Test checking port that's already in config
cmd := checkPortCmd(8080, configPath, "")
cmd := checkPortCmd(8080, configPath, "", "test-ctx", nil)
msg := cmd()
portMsg, ok := msg.(PortCheckedMsg)
@@ -224,7 +224,7 @@ func TestCheckPortCmd_ExcludeID_AllowsKeepingOwnPort(t *testing.T) {
// The forward's ID format is "<context>/<namespace>/<resource>:<port>".
excludeID := "test-ctx/default/pod/my-app:8080"
cmd := checkPortCmd(8080, configPath, excludeID)
cmd := checkPortCmd(8080, configPath, excludeID, "test-ctx", nil)
msg := cmd()
portMsg, ok := msg.(PortCheckedMsg)
@@ -241,7 +241,7 @@ func TestCheckPortCmd_ExcludeID_AllowsKeepingOwnPort(t *testing.T) {
// 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", "")
cmd := checkPortCmd(59998, "/nonexistent/path/.kportal.yaml", "", "test-ctx", nil)
msg := cmd()
portMsg, ok := msg.(PortCheckedMsg)
@@ -420,3 +420,76 @@ func TestHTTPLogSubscriberType(t *testing.T) {
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")
}
})
}
}
+6
View File
@@ -36,6 +36,12 @@ func (f *fakeMutator) RemoveForwards(predicate func(ctx, ns string, fwd config.F
return nil
}
func (f *fakeMutator) RemoveForwardByID(id string) error { return nil }
func (f *fakeMutator) RemoveForwardByIDInContext(contextName, id string) error {
return nil
}
func (f *fakeMutator) UpdateForwardInContext(oldCtx, oldID, newCtx, newNS string, newFwd config.Forward) error {
return nil
}
func (f *fakeMutator) UpdateForward(oldID, newCtx, newNS string, newFwd config.Forward) error {
return nil
}
+2
View File
@@ -24,7 +24,9 @@ type MutatorInterface interface {
AddForward(contextName, namespaceName string, fwd config.Forward) error
RemoveForwards(predicate func(ctx, ns string, fwd config.Forward) bool) error
RemoveForwardByID(id string) error
RemoveForwardByIDInContext(contextName, id string) error
UpdateForward(oldID, newContextName, newNamespaceName string, newFwd config.Forward) error
UpdateForwardInContext(oldContextName, oldID, newContextName, newNamespaceName string, newFwd config.Forward) error
}
// Compile-time checks to ensure real types implement interfaces
+18
View File
@@ -105,6 +105,8 @@ type MockMutator struct {
LastOldID string
LastNamespaceName string
LastRemovedID string
LastRemovedContext string
LastOldContext string
Forwards []struct {
Context string
Namespace string
@@ -157,6 +159,22 @@ func (m *MockMutator) RemoveForwardByID(id string) error {
return m.RemoveForwardByIDErr
}
func (m *MockMutator) RemoveForwardByIDInContext(contextName, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.RemoveForwardByIDCalls++
m.LastRemovedContext = contextName
m.LastRemovedID = id
return m.RemoveForwardByIDErr
}
func (m *MockMutator) UpdateForwardInContext(oldContextName, oldID, newContextName, newNamespaceName string, newFwd config.Forward) error {
m.mu.Lock()
m.LastOldContext = oldContextName
m.mu.Unlock()
return m.UpdateForward(oldID, newContextName, newNamespaceName, newFwd)
}
func (m *MockMutator) UpdateForward(oldID, newContextName, newNamespaceName string, newFwd config.Forward) error {
m.mu.Lock()
defer m.mu.Unlock()
+19 -9
View File
@@ -149,7 +149,11 @@ func validateSelectorCmd(discovery *k8s.Discovery, contextName, namespace, selec
// excludeID, when non-empty, is the ID of a forward to ignore during the
// in-config conflict scan. Used in edit mode so the wizard does not flag the
// forward being edited as conflicting with itself.
func checkPortCmd(port int, configPath, excludeID string) tea.Cmd {
// targetContext is the context the forward is being added to or edited in, and
// activeContexts the --context selection; together they decide which existing
// forwards could actually compete for the port. A forward in a context that is
// never running alongside targetContext does not conflict.
func checkPortCmd(port int, configPath, excludeID, targetContext string, activeContexts []string) tea.Cmd {
return func() tea.Msg {
// First check if port is already in the configuration
cfg, err := config.LoadConfig(configPath)
@@ -163,6 +167,9 @@ func checkPortCmd(port int, configPath, excludeID string) tea.Cmd {
if excludeID != "" && fwd.ID() == excludeID {
continue
}
if !config.PortsCanConflict(fwd.GetContext(), targetContext, activeContexts) {
continue
}
return PortCheckedMsg{
port: port,
available: false,
@@ -203,9 +210,9 @@ func saveForwardCmd(mutator *config.Mutator, contextName, namespace string, fwd
}
// updateForwardCmd atomically updates an existing forward (used in edit mode)
func updateForwardCmd(mutator *config.Mutator, oldID, contextName, namespace string, fwd config.Forward) tea.Cmd {
func updateForwardCmd(mutator *config.Mutator, oldContextName, oldID, contextName, namespace string, fwd config.Forward) tea.Cmd {
return func() tea.Msg {
err := mutator.UpdateForward(oldID, contextName, namespace, fwd)
err := mutator.UpdateForwardInContext(oldContextName, oldID, contextName, namespace, fwd)
return ForwardSavedMsg{
success: err == nil,
err: err,
@@ -216,15 +223,18 @@ func updateForwardCmd(mutator *config.Mutator, oldID, contextName, namespace str
// removeForwardsCmd removes selected forwards from the configuration file
func removeForwardsCmd(mutator *config.Mutator, forwards []RemovableForward) tea.Cmd {
return func() tea.Msg {
// Create a map of IDs to remove
idsToRemove := make(map[string]bool)
// Key on context as well as ID: Forward.ID() is only "alias:localPort"
// when an alias is set, which is not unique across contexts once they
// are allowed to reuse local ports.
type forwardKey struct{ contextName, id string }
idsToRemove := make(map[forwardKey]bool)
for _, fwd := range forwards {
idsToRemove[fwd.ID] = true
idsToRemove[forwardKey{contextName: fwd.Context, id: fwd.ID}] = true
}
// Remove forwards matching the IDs
err := mutator.RemoveForwards(func(ctx, ns string, fwd config.Forward) bool {
return idsToRemove[fwd.ID()]
return idsToRemove[forwardKey{contextName: ctx, id: fwd.ID()}]
})
return ForwardsRemovedMsg{
@@ -236,9 +246,9 @@ func removeForwardsCmd(mutator *config.Mutator, forwards []RemovableForward) tea
}
// removeForwardByIDCmd removes a single forward by its ID
func removeForwardByIDCmd(mutator *config.Mutator, id string) tea.Cmd {
func removeForwardByIDCmd(mutator *config.Mutator, contextName, id string) tea.Cmd {
return func() tea.Msg {
err := mutator.RemoveForwardByID(id)
err := mutator.RemoveForwardByIDInContext(contextName, id)
return ForwardsRemovedMsg{
success: err == nil,
count: 1,
+6 -3
View File
@@ -135,6 +135,7 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Mark as edit mode and store original ID
m.ui.addWizard.isEditing = true
m.ui.addWizard.originalID = selectedID
m.ui.addWizard.originalContext = selectedForward.Context
// Start at the remote port step (skip context/namespace/resource selection)
m.ui.addWizard.step = StepEnterRemotePort
@@ -187,6 +188,7 @@ func (m model) handleMainViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Show confirmation dialog
m.ui.deleteConfirming = true
m.ui.deleteConfirmID = selectedID
m.ui.deleteConfirmContext = selectedForward.Context
m.ui.deleteConfirmAlias = selectedForward.Alias
m.ui.deleteConfirmCursor = 1 // Default to "No" for safety
@@ -312,9 +314,10 @@ func (m model) handleDeleteConfirmation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Confirm deletion (either Enter on Yes or pressing 'y')
if m.ui.deleteConfirmCursor == 0 || msg.String() == "y" {
id := m.ui.deleteConfirmID
contextName := m.ui.deleteConfirmContext
m.ui.resetDeleteConfirmation()
m.ui.mu.Unlock()
return m, removeForwardByIDCmd(m.ui.mutator, id)
return m, removeForwardByIDCmd(m.ui.mutator, contextName, id)
}
// Enter on No = cancel
m.ui.resetDeleteConfirmation()
@@ -691,7 +694,7 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
if wizard.isEditing {
excludeID = wizard.originalID
}
return m, checkPortCmd(port, m.ui.configPath, excludeID)
return m, checkPortCmd(port, m.ui.configPath, excludeID, wizard.selectedContext, m.ui.activeContexts)
}
case StepConfirmation:
@@ -749,7 +752,7 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
// If editing, use atomic update operation
if wizard.isEditing {
return m, updateForwardCmd(m.ui.mutator, wizard.originalID, wizard.selectedContext, wizard.selectedNamespace, fwd)
return m, updateForwardCmd(m.ui.mutator, wizard.originalContext, wizard.originalID, wizard.selectedContext, wizard.selectedNamespace, fwd)
}
return m, saveForwardCmd(m.ui.mutator, wizard.selectedContext, wizard.selectedNamespace, fwd)
+1 -1
View File
@@ -1685,7 +1685,7 @@ func TestRemoveForwardByIDCmd_Success(t *testing.T) {
require.NoError(t, mutator.AddForward("ctx", "ns", fwdKeep))
id := "ctx/ns/pod/app:18083"
cmd := removeForwardByIDCmd(mutator, id)
cmd := removeForwardByIDCmd(mutator, "", id)
msg := cmd()
removedMsg, ok := msg.(ForwardsRemovedMsg)
require.True(t, ok)
+1
View File
@@ -115,6 +115,7 @@ type AddWizardState struct {
httpLogOriginal *config.HTTPLogSpec
resourceValue string
originalID string
originalContext string
portCheckMsg string
alias string
textInput string