Files
kportal/internal/ui/mocks_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

277 lines
7.1 KiB
Go

package ui
import (
"context"
"sync"
"github.com/lukaszraczylo/kportal/internal/config"
"github.com/lukaszraczylo/kportal/internal/k8s"
)
// MockDiscovery is a mock implementation of DiscoveryInterface for testing
type MockDiscovery struct {
ListPodsErr error
ListServicesErr error
ListPodsWithSelectorErr error
ListContextsErr error
GetCurrentContextErr error
ListNamespacesErr error
LastSelector string
CurrentContext string
LastNamespace string
LastContextName string
PodsWithSelector []k8s.PodInfo
Services []k8s.ServiceInfo
Pods []k8s.PodInfo
Namespaces []string
Contexts []string
ListContextsCalls int
GetCurrentContextCalls int
ListNamespacesCalls int
ListPodsCalls int
ListPodsWithSelectorCalls int
ListServicesCalls int
mu sync.Mutex
}
func NewMockDiscovery() *MockDiscovery {
return &MockDiscovery{
Contexts: []string{"default", "production", "staging"},
Namespaces: []string{"default", "kube-system"},
}
}
func (m *MockDiscovery) ListContexts() ([]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.ListContextsCalls++
return m.Contexts, m.ListContextsErr
}
func (m *MockDiscovery) GetCurrentContext() (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.GetCurrentContextCalls++
if m.CurrentContext == "" {
return "default", m.GetCurrentContextErr
}
return m.CurrentContext, m.GetCurrentContextErr
}
func (m *MockDiscovery) ListNamespaces(ctx context.Context, contextName string) ([]string, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.ListNamespacesCalls++
m.LastContextName = contextName
return m.Namespaces, m.ListNamespacesErr
}
func (m *MockDiscovery) ListPods(ctx context.Context, contextName, namespace string) ([]k8s.PodInfo, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.ListPodsCalls++
m.LastContextName = contextName
m.LastNamespace = namespace
return m.Pods, m.ListPodsErr
}
func (m *MockDiscovery) ListPodsWithSelector(ctx context.Context, contextName, namespace, selector string) ([]k8s.PodInfo, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.ListPodsWithSelectorCalls++
m.LastContextName = contextName
m.LastNamespace = namespace
m.LastSelector = selector
return m.PodsWithSelector, m.ListPodsWithSelectorErr
}
func (m *MockDiscovery) ListServices(ctx context.Context, contextName, namespace string) ([]k8s.ServiceInfo, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.ListServicesCalls++
m.LastContextName = contextName
m.LastNamespace = namespace
return m.Services, m.ListServicesErr
}
// MockMutator is a mock implementation of MutatorInterface for testing
type MockMutator struct {
RemoveForwardByIDErr error
UpdateForwardErr error
AddForwardErr error
RemoveForwardsErr error
LastPredicate func(ctx, ns string, fwd config.Forward) bool
LastContextName string
LastOldID string
LastNamespaceName string
LastRemovedID string
LastRemovedContext string
LastOldContext string
Forwards []struct {
Context string
Namespace string
Forward config.Forward
}
LastForward config.Forward
RemoveForwardByIDCalls int
UpdateForwardCalls int
RemoveForwardsCalls int
AddForwardCalls int
mu sync.Mutex
}
func NewMockMutator() *MockMutator {
return &MockMutator{}
}
func (m *MockMutator) AddForward(contextName, namespaceName string, fwd config.Forward) error {
m.mu.Lock()
defer m.mu.Unlock()
m.AddForwardCalls++
m.LastContextName = contextName
m.LastNamespaceName = namespaceName
m.LastForward = fwd
if m.AddForwardErr == nil {
m.Forwards = append(m.Forwards, struct {
Context string
Namespace string
Forward config.Forward
}{contextName, namespaceName, fwd})
}
return m.AddForwardErr
}
func (m *MockMutator) RemoveForwards(predicate func(ctx, ns string, fwd config.Forward) bool) error {
m.mu.Lock()
defer m.mu.Unlock()
m.RemoveForwardsCalls++
m.LastPredicate = predicate
return m.RemoveForwardsErr
}
func (m *MockMutator) RemoveForwardByID(id string) error {
m.mu.Lock()
defer m.mu.Unlock()
m.RemoveForwardByIDCalls++
m.LastRemovedID = id
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()
m.UpdateForwardCalls++
m.LastOldID = oldID
m.LastContextName = newContextName
m.LastNamespaceName = newNamespaceName
m.LastForward = newFwd
return m.UpdateForwardErr
}
// MockHTTPLogSubscriber is a mock for HTTP log subscription
type MockHTTPLogSubscriber struct {
Subscriptions map[string]func(HTTPLogEntry)
CleanupCalls int
mu sync.Mutex
ShouldFail bool
}
func NewMockHTTPLogSubscriber() *MockHTTPLogSubscriber {
return &MockHTTPLogSubscriber{
Subscriptions: make(map[string]func(HTTPLogEntry)),
}
}
// Subscribe returns a cleanup function
func (m *MockHTTPLogSubscriber) Subscribe(forwardID string, callback func(HTTPLogEntry)) func() {
m.mu.Lock()
defer m.mu.Unlock()
m.Subscriptions[forwardID] = callback
return func() {
m.mu.Lock()
defer m.mu.Unlock()
m.CleanupCalls++
delete(m.Subscriptions, forwardID)
}
}
// SendEntry sends an entry to a subscribed callback (for testing)
func (m *MockHTTPLogSubscriber) SendEntry(forwardID string, entry HTTPLogEntry) {
m.mu.Lock()
callback, exists := m.Subscriptions[forwardID]
m.mu.Unlock()
if exists && callback != nil {
callback(entry)
}
}
// GetSubscriberFunc returns the function signature expected by the UI
func (m *MockHTTPLogSubscriber) GetSubscriberFunc() HTTPLogSubscriber {
return func(forwardID string, callback func(entry HTTPLogEntry)) func() {
return m.Subscribe(forwardID, callback)
}
}
// MockToggleCallback tracks toggle callback invocations
type MockToggleCallback struct {
Calls []struct {
ID string
Enable bool
}
mu sync.Mutex
}
func NewMockToggleCallback() *MockToggleCallback {
return &MockToggleCallback{}
}
func (m *MockToggleCallback) Callback(id string, enable bool) {
m.mu.Lock()
defer m.mu.Unlock()
m.Calls = append(m.Calls, struct {
ID string
Enable bool
}{id, enable})
}
func (m *MockToggleCallback) GetFunc() func(string, bool) {
return m.Callback
}
func (m *MockToggleCallback) CallCount() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.Calls)
}
func (m *MockToggleCallback) LastCall() (string, bool, bool) {
m.mu.Lock()
defer m.mu.Unlock()
if len(m.Calls) == 0 {
return "", false, false
}
last := m.Calls[len(m.Calls)-1]
return last.ID, last.Enable, true
}