Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions pkg/listen/healthcheck.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package listen

import (
"fmt"
"net"
"net/url"
"time"
)

// ServerHealthStatus represents the health status of the target server
type ServerHealthStatus int

const (
HealthUnknown ServerHealthStatus = iota
HealthHealthy // TCP connection successful
HealthUnreachable // Connection refused or timeout
)

// HealthCheckResult contains the result of a health check
type HealthCheckResult struct {
Status ServerHealthStatus
Healthy bool
Error error
Timestamp time.Time
Duration time.Duration
}

// CheckServerHealth performs a TCP connection check to the target URL
func CheckServerHealth(targetURL *url.URL, timeout time.Duration) HealthCheckResult {
start := time.Now()

host := targetURL.Hostname()
port := targetURL.Port()

// Default ports if not specified
if port == "" {
if targetURL.Scheme == "https" {
port = "443"
} else {
port = "80"
}
}

address := net.JoinHostPort(host, port)

conn, err := net.DialTimeout("tcp", address, timeout)
duration := time.Since(start)

result := HealthCheckResult{
Timestamp: start,
Duration: duration,
}

if err != nil {
result.Healthy = false
result.Error = err
result.Status = HealthUnreachable
return result
}

// Successfully connected - server is healthy
conn.Close()
result.Healthy = true
result.Status = HealthHealthy
return result
}

// FormatHealthMessage creates a user-friendly health status message
func FormatHealthMessage(result HealthCheckResult, targetURL *url.URL) string {
if result.Healthy {
return fmt.Sprintf("✓ Local server is reachable at %s", targetURL.String())
}

return fmt.Sprintf("⚠ Warning: Cannot connect to local server at %s\n %s\n The server may not be running. Webhooks will fail until the server starts.",
targetURL.String(),
result.Error.Error())
}
180 changes: 180 additions & 0 deletions pkg/listen/healthcheck_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package listen

import (
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)

func TestCheckServerHealth_HealthyServer(t *testing.T) {
// Start a test HTTP server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

// Parse server URL
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("Failed to parse server URL: %v", err)
}

// Perform health check
result := CheckServerHealth(serverURL, 3*time.Second)

// Verify result
if !result.Healthy {
t.Errorf("Expected server to be healthy, got unhealthy")
}
if result.Status != HealthHealthy {
t.Errorf("Expected status HealthHealthy, got %v", result.Status)
}
if result.Error != nil {
t.Errorf("Expected no error, got: %v", result.Error)
}
if result.Duration <= 0 {
t.Errorf("Expected positive duration, got: %v", result.Duration)
}
}

func TestCheckServerHealth_UnreachableServer(t *testing.T) {
// Use a URL that should not be listening
targetURL, err := url.Parse("http://localhost:59999")
if err != nil {
t.Fatalf("Failed to parse URL: %v", err)
}

// Perform health check
result := CheckServerHealth(targetURL, 1*time.Second)

// Verify result
if result.Healthy {
t.Errorf("Expected server to be unhealthy, got healthy")
}
if result.Status != HealthUnreachable {
t.Errorf("Expected status HealthUnreachable, got %v", result.Status)
}
if result.Error == nil {
t.Errorf("Expected error, got nil")
}
}

func TestCheckServerHealth_DefaultPorts(t *testing.T) {
testCases := []struct {
name string
urlString string
expectedPort string
}{
{
name: "HTTP default port",
urlString: "http://localhost",
expectedPort: "80",
},
{
name: "HTTPS default port",
urlString: "https://localhost",
expectedPort: "443",
},
{
name: "Explicit port",
urlString: "http://localhost:8080",
expectedPort: "8080",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
targetURL, err := url.Parse(tc.urlString)
if err != nil {
t.Fatalf("Failed to parse URL: %v", err)
}

// Start a listener on the expected port to verify we're checking the right one
listener, err := net.Listen("tcp", "localhost:"+tc.expectedPort)
if err != nil {
t.Skipf("Cannot bind to port %s: %v", tc.expectedPort, err)
}
defer listener.Close()

// Perform health check
result := CheckServerHealth(targetURL, 1*time.Second)

// Should be healthy since we have a listener
if !result.Healthy {
t.Errorf("Expected server to be healthy on port %s, got unhealthy: %v", tc.expectedPort, result.Error)
}
})
}
}

func TestCheckServerHealth_Timeout(t *testing.T) {
// Create a listener that accepts but doesn't respond
listener, err := net.Listen("tcp", "localhost:0")
if err != nil {
t.Fatalf("Failed to create listener: %v", err)
}
defer listener.Close()

// Get the actual port
addr := listener.Addr().(*net.TCPAddr)
targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", addr.Port))
if err != nil {
t.Fatalf("Failed to parse URL: %v", err)
}

// Use a very short timeout for fast test execution
result := CheckServerHealth(targetURL, 10*time.Millisecond)

// The connection should succeed since we have a listener
// This test mainly verifies that timeout is respected
if result.Duration > 1*time.Second {
t.Errorf("Health check took too long: %v", result.Duration)
}
}

func TestFormatHealthMessage_Healthy(t *testing.T) {
targetURL, _ := url.Parse("http://localhost:3000")
result := HealthCheckResult{
Status: HealthHealthy,
Healthy: true,
}

msg := FormatHealthMessage(result, targetURL)

if len(msg) == 0 {
t.Errorf("Expected non-empty message")
}
if !strings.Contains(msg, "✓") {
t.Errorf("Expected message to contain ✓")
}
if !strings.Contains(msg, "Local server is reachable") {
t.Errorf("Expected message to contain 'Local server is reachable'")
}
}

func TestFormatHealthMessage_Unhealthy(t *testing.T) {
targetURL, _ := url.Parse("http://localhost:3000")
result := HealthCheckResult{
Status: HealthUnreachable,
Healthy: false,
Error: net.ErrClosed,
}

msg := FormatHealthMessage(result, targetURL)

if len(msg) == 0 {
t.Errorf("Expected non-empty message")
}
// Should contain warning indicator
if !strings.Contains(msg, "⚠") {
t.Errorf("Expected message to contain ⚠")
}
if !strings.Contains(msg, "Warning") {
t.Errorf("Expected message to contain 'Warning'")
}
}
23 changes: 23 additions & 0 deletions pkg/listen/listen.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"os"
"regexp"
"strings"
"time"

"github.com/hookdeck/hookdeck-cli/pkg/config"
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
Expand Down Expand Up @@ -122,6 +123,28 @@ Specify a single destination to update the path. For example, pass a connection
return err
}

// Perform initial health check on target server
healthCheckTimeout := 3 * time.Second
healthResult := CheckServerHealth(URL, healthCheckTimeout)

// For all output modes, warn if server isn't reachable
if !healthResult.Healthy {
warningMsg := FormatHealthMessage(healthResult, URL)

if flags.Output == "interactive" {
// Interactive mode will show warning before TUI starts
fmt.Println()
fmt.Println(warningMsg)
fmt.Println()
time.Sleep(2 * time.Second) // Give user time to see warning before TUI starts
} else {
// Compact/quiet modes: print warning before connection info
fmt.Println()
fmt.Println(warningMsg)
fmt.Println()
}
}

// Start proxy
// For non-interactive modes, print connection info before starting
if flags.Output == "compact" || flags.Output == "quiet" {
Expand Down
Loading