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]>
353 lines
9.8 KiB
Go
353 lines
9.8 KiB
Go
package ui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/lukaszraczylo/kportal/internal/benchmark"
|
|
"github.com/lukaszraczylo/kportal/internal/config"
|
|
"github.com/lukaszraczylo/kportal/internal/k8s"
|
|
"github.com/lukaszraczylo/kportal/internal/logger"
|
|
)
|
|
|
|
const (
|
|
k8sAPITimeout = 10 * time.Second
|
|
)
|
|
|
|
// Messages sent from async commands back to the update loop
|
|
|
|
// ContextsLoadedMsg is sent when contexts have been loaded
|
|
type ContextsLoadedMsg struct {
|
|
err error
|
|
contexts []string
|
|
}
|
|
|
|
// NamespacesLoadedMsg is sent when namespaces have been loaded
|
|
type NamespacesLoadedMsg struct {
|
|
err error
|
|
namespaces []string
|
|
}
|
|
|
|
// PodsLoadedMsg is sent when pods have been loaded
|
|
type PodsLoadedMsg struct {
|
|
err error
|
|
pods []k8s.PodInfo
|
|
}
|
|
|
|
// ServicesLoadedMsg is sent when services have been loaded
|
|
type ServicesLoadedMsg struct {
|
|
err error
|
|
services []k8s.ServiceInfo
|
|
}
|
|
|
|
// SelectorValidatedMsg is sent when a selector has been validated
|
|
type SelectorValidatedMsg struct {
|
|
err error
|
|
pods []k8s.PodInfo
|
|
valid bool
|
|
}
|
|
|
|
// PortCheckedMsg is sent when a port's availability has been checked
|
|
type PortCheckedMsg struct {
|
|
message string
|
|
port int
|
|
available bool
|
|
}
|
|
|
|
// ForwardSavedMsg is sent when a forward has been saved to config
|
|
type ForwardSavedMsg struct {
|
|
err error
|
|
success bool
|
|
}
|
|
|
|
// ForwardsRemovedMsg is sent when forwards have been removed from config
|
|
type ForwardsRemovedMsg struct {
|
|
err error
|
|
count int
|
|
success bool
|
|
}
|
|
|
|
// WizardCompleteMsg signals that the wizard has completed
|
|
type WizardCompleteMsg struct{}
|
|
|
|
// Command functions (return tea.Cmd)
|
|
|
|
// loadContextsCmd loads available Kubernetes contexts
|
|
func loadContextsCmd(discovery *k8s.Discovery) tea.Cmd {
|
|
return func() tea.Msg {
|
|
contexts, err := discovery.ListContexts()
|
|
if err != nil {
|
|
return ContextsLoadedMsg{err: err}
|
|
}
|
|
return ContextsLoadedMsg{contexts: contexts}
|
|
}
|
|
}
|
|
|
|
// loadNamespacesCmd loads namespaces for the given context
|
|
func loadNamespacesCmd(discovery *k8s.Discovery, contextName string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), k8sAPITimeout)
|
|
defer cancel()
|
|
|
|
namespaces, err := discovery.ListNamespaces(ctx, contextName)
|
|
if err != nil {
|
|
return NamespacesLoadedMsg{err: err}
|
|
}
|
|
return NamespacesLoadedMsg{namespaces: namespaces}
|
|
}
|
|
}
|
|
|
|
// loadPodsCmd loads pods for the given context and namespace
|
|
func loadPodsCmd(discovery *k8s.Discovery, contextName, namespace string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), k8sAPITimeout)
|
|
defer cancel()
|
|
|
|
pods, err := discovery.ListPods(ctx, contextName, namespace)
|
|
if err != nil {
|
|
return PodsLoadedMsg{err: err}
|
|
}
|
|
return PodsLoadedMsg{pods: pods}
|
|
}
|
|
}
|
|
|
|
// loadServicesCmd loads services for the given context and namespace
|
|
func loadServicesCmd(discovery *k8s.Discovery, contextName, namespace string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), k8sAPITimeout)
|
|
defer cancel()
|
|
|
|
services, err := discovery.ListServices(ctx, contextName, namespace)
|
|
if err != nil {
|
|
return ServicesLoadedMsg{err: err}
|
|
}
|
|
return ServicesLoadedMsg{services: services}
|
|
}
|
|
}
|
|
|
|
// validateSelectorCmd validates a label selector and returns matching pods
|
|
func validateSelectorCmd(discovery *k8s.Discovery, contextName, namespace, selector string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), k8sAPITimeout)
|
|
defer cancel()
|
|
|
|
pods, err := discovery.ListPodsWithSelector(ctx, contextName, namespace, selector)
|
|
if err != nil {
|
|
return SelectorValidatedMsg{valid: false, err: err}
|
|
}
|
|
|
|
return SelectorValidatedMsg{
|
|
valid: len(pods) > 0,
|
|
pods: pods,
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkPortCmd checks if a local port is available.
|
|
// 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.
|
|
// 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)
|
|
if err == nil {
|
|
// Check all forwards in config for this port
|
|
allForwards := cfg.GetAllForwards()
|
|
for _, fwd := range allForwards {
|
|
if fwd.LocalPort != port {
|
|
continue
|
|
}
|
|
if excludeID != "" && fwd.ID() == excludeID {
|
|
continue
|
|
}
|
|
if !config.PortsCanConflict(fwd.GetContext(), targetContext, activeContexts) {
|
|
continue
|
|
}
|
|
return PortCheckedMsg{
|
|
port: port,
|
|
available: false,
|
|
message: fmt.Sprintf("✗ Port %d already assigned to %s", port, fwd.ID()),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Then check if port is available at OS level
|
|
available, processInfo, err := k8s.CheckPortAvailability(port)
|
|
|
|
msg := ""
|
|
if err != nil {
|
|
msg = fmt.Sprintf("✗ Error: %v", err)
|
|
} else if available {
|
|
msg = fmt.Sprintf("✓ Port %d available", port)
|
|
} else {
|
|
msg = fmt.Sprintf("✗ Port %d in use by %s", port, processInfo)
|
|
}
|
|
|
|
return PortCheckedMsg{
|
|
port: port,
|
|
available: available,
|
|
message: msg,
|
|
}
|
|
}
|
|
}
|
|
|
|
// saveForwardCmd saves a new forward to the configuration file
|
|
func saveForwardCmd(mutator *config.Mutator, contextName, namespace string, fwd config.Forward) tea.Cmd {
|
|
return func() tea.Msg {
|
|
err := mutator.AddForward(contextName, namespace, fwd)
|
|
return ForwardSavedMsg{
|
|
success: err == nil,
|
|
err: err,
|
|
}
|
|
}
|
|
}
|
|
|
|
// updateForwardCmd atomically updates an existing forward (used in edit mode)
|
|
func updateForwardCmd(mutator *config.Mutator, oldContextName, oldID, contextName, namespace string, fwd config.Forward) tea.Cmd {
|
|
return func() tea.Msg {
|
|
err := mutator.UpdateForwardInContext(oldContextName, oldID, contextName, namespace, fwd)
|
|
return ForwardSavedMsg{
|
|
success: err == nil,
|
|
err: err,
|
|
}
|
|
}
|
|
}
|
|
|
|
// removeForwardsCmd removes selected forwards from the configuration file
|
|
func removeForwardsCmd(mutator *config.Mutator, forwards []RemovableForward) tea.Cmd {
|
|
return func() tea.Msg {
|
|
// 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[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[forwardKey{contextName: ctx, id: fwd.ID()}]
|
|
})
|
|
|
|
return ForwardsRemovedMsg{
|
|
success: err == nil,
|
|
count: len(forwards),
|
|
err: err,
|
|
}
|
|
}
|
|
}
|
|
|
|
// removeForwardByIDCmd removes a single forward by its ID
|
|
func removeForwardByIDCmd(mutator *config.Mutator, contextName, id string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
err := mutator.RemoveForwardByIDInContext(contextName, id)
|
|
return ForwardsRemovedMsg{
|
|
success: err == nil,
|
|
count: 1,
|
|
err: err,
|
|
}
|
|
}
|
|
}
|
|
|
|
// BenchmarkCompleteMsg is sent when a benchmark run completes
|
|
type BenchmarkCompleteMsg struct {
|
|
Error error
|
|
Results *benchmark.Results
|
|
ForwardID string
|
|
}
|
|
|
|
// BenchmarkProgressMsg is sent periodically during benchmark execution
|
|
type BenchmarkProgressMsg struct {
|
|
ForwardID string
|
|
Completed int
|
|
Total int
|
|
}
|
|
|
|
// HTTPLogEntryMsg is sent when a new HTTP log entry is received
|
|
type HTTPLogEntryMsg struct {
|
|
Entry HTTPLogEntry
|
|
}
|
|
|
|
// clearCopyMessageMsg is sent to clear the copy confirmation message
|
|
type clearCopyMessageMsg struct{}
|
|
|
|
// listenBenchmarkProgressCmd listens for progress updates from the benchmark
|
|
func listenBenchmarkProgressCmd(progressCh <-chan BenchmarkProgressMsg) tea.Cmd {
|
|
return func() tea.Msg {
|
|
msg, ok := <-progressCh
|
|
if !ok {
|
|
// Channel closed, benchmark complete
|
|
return nil
|
|
}
|
|
return msg
|
|
}
|
|
}
|
|
|
|
// runBenchmarkCmd runs a benchmark against the given port forward
|
|
// It sends progress updates via tea.Batch until completion
|
|
// The ctx parameter allows the benchmark to be cancelled from outside
|
|
func runBenchmarkCmd(ctx context.Context, forwardID string, localPort int, urlPath, method string, concurrency, requests int, progressCh chan<- BenchmarkProgressMsg) tea.Cmd {
|
|
return func() tea.Msg {
|
|
runner := benchmark.NewRunner()
|
|
|
|
url := fmt.Sprintf("http://localhost:%d%s", localPort, urlPath)
|
|
cfg := benchmark.Config{
|
|
URL: url,
|
|
Method: method,
|
|
Concurrency: concurrency,
|
|
Requests: requests,
|
|
Timeout: 30 * time.Second,
|
|
ProgressCallback: func(completed, total int) {
|
|
// Recover from panics in the callback
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
logger.Debug("recovered from panic in progress callback", map[string]any{"panic": r})
|
|
}
|
|
}()
|
|
// Non-blocking send to progress channel
|
|
select {
|
|
case progressCh <- BenchmarkProgressMsg{
|
|
ForwardID: forwardID,
|
|
Completed: completed,
|
|
Total: total,
|
|
}:
|
|
default:
|
|
// Drop if channel is full
|
|
}
|
|
},
|
|
}
|
|
|
|
// Use the provided context with a timeout as a safety limit
|
|
benchCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
|
defer cancel()
|
|
|
|
results, err := runner.Run(benchCtx, forwardID, cfg)
|
|
|
|
// Close the progress channel when done
|
|
close(progressCh)
|
|
|
|
// Check if cancelled
|
|
if ctx.Err() != nil {
|
|
return BenchmarkCompleteMsg{
|
|
ForwardID: forwardID,
|
|
Results: nil,
|
|
Error: fmt.Errorf("benchmark cancelled"),
|
|
}
|
|
}
|
|
|
|
return BenchmarkCompleteMsg{
|
|
ForwardID: forwardID,
|
|
Results: results,
|
|
Error: err,
|
|
}
|
|
}
|
|
}
|