Add the health engine: through-proxy probes, thresholds, channel-fed transitions

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-09 15:01:10 +02:00
parent 71c00c40d1
commit 801a9fbe5f
10 changed files with 1350 additions and 34 deletions

66
internal/health/probe.go Normal file
View File

@@ -0,0 +1,66 @@
// Package health actively probes every proxy by fetching a URL through the
// proxy itself, keeps per-proxy threshold state, and pushes status-affecting
// transitions to the reconciler over a channel. The engine owns health
// state; the reconciler owns its representation in the Proxy's status.
package health
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"net/url"
"slices"
"time"
crawlv1alpha1 "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1"
)
// probeResult is the outcome of a single through-the-proxy probe.
type probeResult struct {
ok bool
latency time.Duration
err error
}
// probe fetches hc.ProbeURL through the proxy at proxyURL. For an https
// probe URL the transport issues CONNECT to the proxy and TLS-handshakes
// through the tunnel; a proxy that accepts TCP but cannot egress answers
// CONNECT with a non-200, which client.Do surfaces as an error, not a
// response — so success requires err == nil AND an expected status code.
// tlsCfg is nil in production (system roots); tests and private-CA setups
// inject their own.
func probe(ctx context.Context, proxyURL *url.URL, hc crawlv1alpha1.HealthCheckSpec, tlsCfg *tls.Config) probeResult {
timeout := time.Duration(hc.TimeoutSeconds) * time.Second
transport := &http.Transport{
Proxy: http.ProxyURL(proxyURL),
// Load-bearing: with keep-alives on, net/http caches the established
// CONNECT tunnel and later probes would never re-exercise CONNECT —
// exactly the failure this probe exists to catch.
DisableKeepAlives: true,
ForceAttemptHTTP2: false,
TLSHandshakeTimeout: timeout,
ResponseHeaderTimeout: timeout,
TLSClientConfig: tlsCfg,
DialContext: (&net.Dialer{Timeout: timeout}).DialContext,
}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: timeout}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, hc.ProbeURL, nil)
if err != nil {
return probeResult{err: fmt.Errorf("building probe request: %w", err)}
}
start := time.Now()
resp, err := client.Do(req)
latency := time.Since(start)
if err != nil {
return probeResult{latency: latency, err: err}
}
defer func() { _ = resp.Body.Close() }()
if !slices.Contains(hc.ExpectedStatusCodes, int32(resp.StatusCode)) {
return probeResult{latency: latency, err: fmt.Errorf("unexpected status %d", resp.StatusCode)}
}
return probeResult{ok: true, latency: latency}
}