Add per-node health checks with status indicators and scheduled probes.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
PingMethodICMP = "icmp"
|
||||
PingMethodTCP = "tcp"
|
||||
|
||||
defaultPingCount = "1"
|
||||
defaultPingWaitSec = "2"
|
||||
defaultTCPTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
// PingResult is the outcome of a reachability probe.
|
||||
type PingResult struct {
|
||||
OK bool
|
||||
Method string
|
||||
Error error
|
||||
}
|
||||
|
||||
type pingCommandFunc func(hostIP string) error
|
||||
type tcpDialFunc func(address string, timeout time.Duration) error
|
||||
|
||||
var (
|
||||
runICMPPing = defaultRunICMPPing
|
||||
dialTCP = defaultDialTCP
|
||||
)
|
||||
|
||||
// PingHost probes host reachability: ICMP first, then TCP :22 when ICMP is
|
||||
// unavailable or the host does not respond to ICMP.
|
||||
func PingHost(hostIP string) PingResult {
|
||||
hostIP = strings.TrimSpace(hostIP)
|
||||
if hostIP == "" {
|
||||
return PingResult{OK: false, Error: fmt.Errorf("host IP is required")}
|
||||
}
|
||||
|
||||
icmpErr := runICMPPing(hostIP)
|
||||
if icmpErr == nil {
|
||||
return PingResult{OK: true, Method: PingMethodICMP}
|
||||
}
|
||||
|
||||
address := net.JoinHostPort(hostIP, defaultSSHPort)
|
||||
tcpErr := dialTCP(address, defaultTCPTimeout)
|
||||
if tcpErr == nil {
|
||||
return PingResult{OK: true, Method: PingMethodTCP}
|
||||
}
|
||||
|
||||
return PingResult{
|
||||
OK: false,
|
||||
Method: PingMethodTCP,
|
||||
Error: fmt.Errorf(
|
||||
"icmp: %v; tcp %s: %w",
|
||||
icmpErr,
|
||||
address,
|
||||
tcpErr,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRunICMPPing(hostIP string) error {
|
||||
pingPath, err := exec.LookPath("ping")
|
||||
if err != nil {
|
||||
return fmt.Errorf("ping unavailable: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(pingPath, "-c", defaultPingCount, "-W", defaultPingWaitSec, hostIP)
|
||||
output, runErr := cmd.CombinedOutput()
|
||||
if runErr == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
combined := strings.TrimSpace(string(output) + " " + runErr.Error())
|
||||
return fmt.Errorf("%s", strings.TrimSpace(combined))
|
||||
}
|
||||
|
||||
func defaultDialTCP(address string, timeout time.Duration) error {
|
||||
conn, err := net.DialTimeout("tcp", address, timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = conn.Close()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPingHostUsesICMPWhenAvailable(t *testing.T) {
|
||||
originalPing := runICMPPing
|
||||
originalDial := dialTCP
|
||||
t.Cleanup(func() {
|
||||
runICMPPing = originalPing
|
||||
dialTCP = originalDial
|
||||
})
|
||||
|
||||
runICMPPing = func(hostIP string) error {
|
||||
if hostIP != "10.0.0.1" {
|
||||
t.Fatalf("host = %q", hostIP)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
dialTCP = func(address string, timeout time.Duration) error {
|
||||
t.Fatal("tcp should not be used when icmp succeeds")
|
||||
return nil
|
||||
}
|
||||
|
||||
result := PingHost("10.0.0.1")
|
||||
if !result.OK {
|
||||
t.Fatalf("expected ok, got %#v", result)
|
||||
}
|
||||
if result.Method != PingMethodICMP {
|
||||
t.Fatalf("method = %q", result.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingHostFallsBackToTCPWhenICMPFails(t *testing.T) {
|
||||
originalPing := runICMPPing
|
||||
originalDial := dialTCP
|
||||
t.Cleanup(func() {
|
||||
runICMPPing = originalPing
|
||||
dialTCP = originalDial
|
||||
})
|
||||
|
||||
runICMPPing = func(hostIP string) error {
|
||||
return errors.New("ping unavailable: executable file not found")
|
||||
}
|
||||
dialTCP = func(address string, timeout time.Duration) error {
|
||||
if address != "10.0.0.2:22" {
|
||||
t.Fatalf("address = %q", address)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
result := PingHost("10.0.0.2")
|
||||
if !result.OK {
|
||||
t.Fatalf("expected ok, got %#v", result)
|
||||
}
|
||||
if result.Method != PingMethodTCP {
|
||||
t.Fatalf("method = %q", result.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingHostFailsWhenBothFail(t *testing.T) {
|
||||
originalPing := runICMPPing
|
||||
originalDial := dialTCP
|
||||
t.Cleanup(func() {
|
||||
runICMPPing = originalPing
|
||||
dialTCP = originalDial
|
||||
})
|
||||
|
||||
runICMPPing = func(hostIP string) error {
|
||||
return errors.New("100% packet loss")
|
||||
}
|
||||
dialTCP = func(address string, timeout time.Duration) error {
|
||||
return errors.New("connection refused")
|
||||
}
|
||||
|
||||
result := PingHost("10.0.0.3")
|
||||
if result.OK {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
if result.Error == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if result.Method != PingMethodTCP {
|
||||
t.Fatalf("method = %q", result.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingHostRequiresHostIP(t *testing.T) {
|
||||
result := PingHost(" ")
|
||||
if result.OK || result.Error == nil {
|
||||
t.Fatalf("expected failure, got %#v", result)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user