-
Notifications
You must be signed in to change notification settings - Fork 15
feat: add TCP-based local server health checks to listen command #186
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 10 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
613faa4
feat(listen): add TCP-based local server health checks
leggetter 1d05a10
chore: Fix PR #186 feedback
leggetter dfea4ee
chore: use "events" and not "webhooks" in message
leggetter fbe7f5a
refactor(listen): fix remaining PR feedback issues
leggetter 7e0453c
refactor(listen): remove unused lastHealthCheck field
leggetter 45b0212
Move server health status from status bar to connection header in TUI
leggetter 9a21f87
Improve server health warning visibility and styling
leggetter e841e0a
chore: Fix warning capitalization for consistency
leggetter 3e4996a
refactor(listen): update output mode description and enhance connecti…
leggetter c8c33df
Fix ticker resource leak in health check monitor
leggetter 2eefc89
refactor: address PR review feedback for healthcheck and proxy
leggetter File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| package listen | ||
|
|
||
| import ( | ||
| "net/url" | ||
| "time" | ||
|
|
||
| "github.com/hookdeck/hookdeck-cli/pkg/listen/healthcheck" | ||
| ) | ||
|
|
||
| // Re-export types and constants from healthcheck subpackage for backward compatibility | ||
| type ServerHealthStatus = healthcheck.ServerHealthStatus | ||
| type HealthCheckResult = healthcheck.HealthCheckResult | ||
|
|
||
| const ( | ||
| HealthHealthy = healthcheck.HealthHealthy | ||
| HealthUnreachable = healthcheck.HealthUnreachable | ||
| ) | ||
|
|
||
| // CheckServerHealth performs a TCP connection check to the target URL | ||
| // This is a wrapper around the healthcheck package function for backward compatibility | ||
| func CheckServerHealth(targetURL *url.URL, timeout time.Duration) HealthCheckResult { | ||
| return healthcheck.CheckServerHealth(targetURL, timeout) | ||
| } | ||
|
|
||
| // FormatHealthMessage creates a user-friendly health status message | ||
| // This is a wrapper around the healthcheck package function for backward compatibility | ||
| func FormatHealthMessage(result HealthCheckResult, targetURL *url.URL) string { | ||
| return healthcheck.FormatHealthMessage(result, targetURL) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| package healthcheck | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "net" | ||
| "net/url" | ||
| "os" | ||
| "time" | ||
|
|
||
| "github.com/hookdeck/hookdeck-cli/pkg/ansi" | ||
| ) | ||
|
|
||
| // ServerHealthStatus represents the health status of the target server | ||
| type ServerHealthStatus int | ||
|
|
||
| const ( | ||
| HealthHealthy ServerHealthStatus = iota // 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()) | ||
| } | ||
|
|
||
| color := ansi.Color(os.Stdout) | ||
| errorMessage := "unknown error" | ||
| if result.Error != nil { | ||
| errorMessage = result.Error.Error() | ||
| } | ||
| return fmt.Sprintf("%s Cannot connect to local server at %s\n %s\n The server may not be running. Events will fail until the server starts.", | ||
| color.Yellow("● Warning:"), | ||
| targetURL.String(), | ||
| errorMessage) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,172 @@ | ||
| package healthcheck | ||
|
|
||
| import ( | ||
| "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 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'") | ||
| } | ||
| } | ||
|
|
||
| func TestFormatHealthMessage_NilError(t *testing.T) { | ||
| targetURL, _ := url.Parse("http://localhost:3000") | ||
| result := HealthCheckResult{ | ||
| Status: HealthUnreachable, | ||
| Healthy: false, | ||
| Error: nil, // Nil error should not cause panic | ||
| } | ||
|
|
||
| msg := FormatHealthMessage(result, targetURL) | ||
|
|
||
| if len(msg) == 0 { | ||
| t.Errorf("Expected non-empty message") | ||
| } | ||
| if !strings.Contains(msg, "unknown error") { | ||
| t.Errorf("Expected message to contain 'unknown error' when error is nil") | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
small nit but I think the comment is sort of a leftover during refactors, there's no compat issue here