mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-06-05 23:03:40 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49acba5679 | |||
| 39fe4286b4 |
@@ -25,6 +25,7 @@ require (
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
|
||||
@@ -23,6 +23,8 @@ github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
|
||||
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
|
||||
@@ -357,6 +357,7 @@ func (m *Manager) startWorker(fwd config.Forward) error {
|
||||
m.healthChecker.Register(fwd.ID(), fwd.LocalPort, func(forwardID string, status healthcheck.Status, errorMsg string) {
|
||||
if m.statusUI != nil {
|
||||
m.statusUI.UpdateStatus(forwardID, string(status))
|
||||
|
||||
// Send error separately if there is one
|
||||
if (status == healthcheck.StatusUnhealthy || status == healthcheck.StatusStale) && errorMsg != "" {
|
||||
if ui, ok := m.statusUI.(interface{ SetError(id, msg string) }); ok {
|
||||
|
||||
@@ -92,14 +92,15 @@ func (w *ForwardWorker) Stop() {
|
||||
func (w *ForwardWorker) run() {
|
||||
defer close(w.doneChan)
|
||||
|
||||
// Start heartbeat goroutine to continuously send heartbeats to watchdog
|
||||
// This prevents false "hung worker" detection when connections are long-lived
|
||||
if w.watchdog != nil {
|
||||
go w.heartbeatLoop()
|
||||
}
|
||||
|
||||
backoff := retry.NewBackoff()
|
||||
|
||||
for {
|
||||
// Send heartbeat to watchdog to indicate we're alive
|
||||
if w.watchdog != nil {
|
||||
w.watchdog.Heartbeat(w.forward.ID())
|
||||
}
|
||||
|
||||
// Check if we should stop
|
||||
select {
|
||||
case <-w.ctx.Done():
|
||||
@@ -202,6 +203,26 @@ func (w *ForwardWorker) run() {
|
||||
}
|
||||
}
|
||||
|
||||
// heartbeatLoop sends periodic heartbeats to the watchdog to prove the worker is alive
|
||||
// This runs in a separate goroutine and continues throughout the worker's lifetime
|
||||
func (w *ForwardWorker) heartbeatLoop() {
|
||||
// Send heartbeats every 15 seconds (well within typical 60s watchdog timeout)
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Send immediate heartbeat
|
||||
w.watchdog.Heartbeat(w.forward.ID())
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
w.watchdog.Heartbeat(w.forward.ID())
|
||||
case <-w.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// establishForward establishes a port-forward connection.
|
||||
// This blocks until the connection is closed or an error occurs.
|
||||
func (w *ForwardWorker) establishForward(podName string) error {
|
||||
|
||||
@@ -201,6 +201,17 @@ func (c *Checker) GetStatus(forwardID string) (Status, bool) {
|
||||
return StatusUnhealthy, false
|
||||
}
|
||||
|
||||
// GetLastCheckTime returns the last health check time for a forward
|
||||
func (c *Checker) GetLastCheckTime(forwardID string) (time.Time, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if health, exists := c.ports[forwardID]; exists {
|
||||
return health.LastCheck, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// GetAllErrors returns all forwards with errors and their error messages
|
||||
func (c *Checker) GetAllErrors() map[string]string {
|
||||
c.mu.RLock()
|
||||
@@ -313,6 +324,12 @@ func (c *Checker) checkPort(forwardID string) {
|
||||
health.Status = newStatus
|
||||
health.LastCheck = now
|
||||
health.ErrorMessage = errorMsg
|
||||
|
||||
// Successful health check indicates connection is active
|
||||
// This prevents false positives where healthy connections are marked as idle
|
||||
if newStatus == StatusHealthy {
|
||||
health.LastActivity = now
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
|
||||
@@ -288,7 +288,8 @@ func (s *HealthCheckTestSuite) TestConnectionAgeDetection() {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdleTimeDetection tests max idle time detection
|
||||
// TestIdleTimeDetection tests that connections with passing health checks are NOT marked as stale
|
||||
// This verifies that successful health checks update LastActivity, preventing false idle detection
|
||||
func (s *HealthCheckTestSuite) TestIdleTimeDetection() {
|
||||
statusChanges := make(chan Status, 10)
|
||||
callback := func(forwardID string, status Status, errorMsg string) {
|
||||
@@ -307,26 +308,23 @@ func (s *HealthCheckTestSuite) TestIdleTimeDetection() {
|
||||
|
||||
checker.Register("test-forward", s.port, callback)
|
||||
|
||||
// Wait for initial healthy status
|
||||
var gotHealthy, gotStale bool
|
||||
timeout := time.After(1 * time.Second)
|
||||
// Wait long enough that idle time WOULD be exceeded if health checks didn't update LastActivity
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
for {
|
||||
select {
|
||||
case status := <-statusChanges:
|
||||
if status == StatusHealthy || status == StatusStarting {
|
||||
gotHealthy = true
|
||||
}
|
||||
if status == StatusStale {
|
||||
gotStale = true
|
||||
}
|
||||
if gotHealthy && gotStale {
|
||||
return // Test passed
|
||||
}
|
||||
case <-timeout:
|
||||
s.T().Fatalf("Expected StatusStale after max idle time exceeded. gotHealthy=%v, gotStale=%v",
|
||||
gotHealthy, gotStale)
|
||||
// Verify connection is still healthy, not stale
|
||||
// This proves that successful health checks are updating LastActivity
|
||||
status, exists := checker.GetStatus("test-forward")
|
||||
require.True(s.T(), exists)
|
||||
assert.Equal(s.T(), StatusHealthy, status, "Connection with passing health checks should NOT be marked as stale")
|
||||
|
||||
// Verify we never received a StatusStale callback
|
||||
select {
|
||||
case status := <-statusChanges:
|
||||
if status == StatusStale {
|
||||
s.T().Fatal("Connection should NOT be marked as stale when health checks are passing")
|
||||
}
|
||||
default:
|
||||
// No stale status - this is correct
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -292,12 +292,8 @@ func CheckPortAvailability(port int) (bool, string, error) {
|
||||
addr := fmt.Sprintf(":%d", port)
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
// Port is in use
|
||||
// Try to get process info (best-effort)
|
||||
processInfo := "unknown process"
|
||||
// Note: Getting process info requires platform-specific code
|
||||
// For now, just return a generic message
|
||||
return false, processInfo, nil
|
||||
// Port is in use - return error details
|
||||
return false, err.Error(), nil
|
||||
}
|
||||
|
||||
// Port is available, close the listener
|
||||
|
||||
@@ -144,8 +144,25 @@ func validateSelectorCmd(discovery *k8s.Discovery, contextName, namespace, selec
|
||||
}
|
||||
|
||||
// checkPortCmd checks if a local port is available
|
||||
func checkPortCmd(port int) tea.Cmd {
|
||||
func checkPortCmd(port int, configPath 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 {
|
||||
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 := ""
|
||||
|
||||
@@ -374,6 +374,11 @@ func (m model) handleAddWizardKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
||||
wizard := m.ui.addWizard
|
||||
|
||||
// Don't process Enter if we're currently loading
|
||||
if wizard.loading {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch wizard.step {
|
||||
case StepSelectContext:
|
||||
filteredContexts := wizard.getFilteredContexts()
|
||||
@@ -452,12 +457,14 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
||||
filteredServices := wizard.getFilteredServices()
|
||||
if wizard.cursor >= 0 && wizard.cursor < len(filteredServices) {
|
||||
wizard.resourceValue = filteredServices[wizard.cursor].Name
|
||||
|
||||
// Get ports from selected service (must do this BEFORE clearing search filter)
|
||||
wizard.detectedPorts = filteredServices[wizard.cursor].Ports
|
||||
|
||||
wizard.step = StepEnterRemotePort
|
||||
wizard.clearTextInput()
|
||||
wizard.clearSearchFilter()
|
||||
|
||||
// Get ports from selected service
|
||||
wizard.detectedPorts = filteredServices[wizard.cursor].Ports
|
||||
if len(wizard.detectedPorts) > 0 {
|
||||
wizard.inputMode = InputModeList
|
||||
wizard.cursor = 0
|
||||
@@ -500,14 +507,11 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
wizard.error = fmt.Errorf("invalid port number")
|
||||
} else {
|
||||
// Check port availability before proceeding
|
||||
wizard.localPort = port
|
||||
wizard.step = StepConfirmation
|
||||
wizard.clearTextInput()
|
||||
wizard.cursor = 0
|
||||
wizard.inputMode = InputModeList
|
||||
wizard.error = nil
|
||||
wizard.loading = true
|
||||
return m, checkPortCmd(port)
|
||||
wizard.error = nil
|
||||
return m, checkPortCmd(port, m.ui.configPath)
|
||||
}
|
||||
|
||||
case StepConfirmation:
|
||||
@@ -520,6 +524,12 @@ func (m model) handleAddWizardEnter() (tea.Model, tea.Cmd) {
|
||||
|
||||
// Handle button selection
|
||||
if wizard.cursor == 0 {
|
||||
// Check if port is available before saving
|
||||
if !wizard.portAvailable {
|
||||
wizard.error = fmt.Errorf("port %d is not available. Please choose a different port", wizard.localPort)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Confirmed - save the forward
|
||||
wizard.alias = wizard.textInput
|
||||
|
||||
@@ -771,6 +781,17 @@ func (m model) handlePortChecked(msg PortCheckedMsg) (tea.Model, tea.Cmd) {
|
||||
m.ui.addWizard.loading = false
|
||||
m.ui.addWizard.portAvailable = msg.available
|
||||
m.ui.addWizard.portCheckMsg = msg.message
|
||||
|
||||
// Only proceed to confirmation if port is available
|
||||
if msg.available {
|
||||
m.ui.addWizard.step = StepConfirmation
|
||||
m.ui.addWizard.clearTextInput()
|
||||
m.ui.addWizard.cursor = 0
|
||||
m.ui.addWizard.inputMode = InputModeList
|
||||
} else {
|
||||
// Port is not available - show error and stay on local port step
|
||||
m.ui.addWizard.error = fmt.Errorf("port %d is in use, please choose another port", msg.port)
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
|
||||
@@ -373,7 +373,7 @@ func (m model) renderEnterRemotePort() string {
|
||||
prefix = "▸ "
|
||||
b.WriteString(selectedStyle.Render(prefix + manualOption))
|
||||
} else {
|
||||
b.WriteString(mutedStyle.Render(prefix + manualOption))
|
||||
b.WriteString(prefix + mutedStyle.Render(manualOption))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
@@ -443,7 +443,7 @@ func (m model) renderEnterLocalPort() string {
|
||||
} else {
|
||||
b.WriteString(errorStyle.Render(wizard.portCheckMsg))
|
||||
}
|
||||
} else if wizard.textInput != "" && wizard.localPort > 0 {
|
||||
} else if wizard.textInput != "" {
|
||||
b.WriteString(mutedStyle.Render("Press Enter to check availability"))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user