Add the mock provider with a real CONNECT proxy per port (Step 3)
An in-memory provider.Provider whose state (Provisioning -> Running -> Terminated -> purged) is a pure function of an injectable clock, not background timers, so it's deterministic under tests and correct under real time with no goroutine lifecycle to leak. Once an instance is observed Running, it lazily acquires a real HTTP CONNECT proxy listener so the health engine's through-the-proxy probe (later steps) genuinely tunnels a request end to end, instead of the healthcheck being simulated or bypassed for local development. Redesigned the listener sharing model from what the plan assumed: the plan's "one loopback IP per instance" doesn't work on macOS (only 127.0.0.1 binds without a privileged ifconfig alias, unlike Linux where the whole 127.0.0.0/8 routes to loopback by default), and there's no channel for a provider to report a port back to the reconciler anyway (EffectivePort() is spec-only). Instances now share one real listener per port, reference-counted at the package level rather than per Provider instance, since a bound TCP port is a genuinely process-global OS resource -- two separately configured mock-typed provider entries must not both try to bind the same default port. Fault injection wired both ways: MockConfig.FailNextCreates/FailWith for demos, InjectCreateFailures(n, class) for tests. Create is idempotent by name. Caught and fixed a real test flake (not a logic bug): the freePort test helper asked the OS for a free port via bind-then-close, a TOCTOU race under t.Parallel() that let two tests collide on the same "free" port. Replaced it with a monotonic counter, since these tests only need uniqueness within the test run. internal/provider/mock at 91.1% coverage, including an end-to-end test that opens real sockets: Create -> Get past provisionDelay -> a real http.Client tunnelling a CONNECT through the mock to a real TLS origin. make test green across the whole repo. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
171
internal/provider/mock/proxy.go
Normal file
171
internal/provider/mock/proxy.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package mock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mockProxyIP is the address every real listener binds to. Instances don't
|
||||
// get distinct addresses (unlike a real cloud provider): only 127.0.0.1 is
|
||||
// guaranteed bindable without elevated privileges across platforms — macOS
|
||||
// does not, by default, route the rest of 127.0.0.0/8 the way Linux does.
|
||||
// Instances sharing one port are told apart by which listener they share,
|
||||
// not by IP.
|
||||
const mockProxyIP = "127.0.0.1"
|
||||
|
||||
// connectProxy is a minimal HTTP proxy: it tunnels CONNECT requests
|
||||
// (hijack + bidirectional copy) and forwards plain absolute-form HTTP
|
||||
// requests. It exists so the health engine's through-the-proxy probe
|
||||
// genuinely exercises a CONNECT tunnel against the mock provider, rather
|
||||
// than the healthcheck being simulated or bypassed for local development.
|
||||
type connectProxy struct {
|
||||
ln net.Listener
|
||||
sv *http.Server
|
||||
}
|
||||
|
||||
func newConnectProxy(addr string) (*connectProxy, error) {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mock proxy: listen %s: %w", addr, err)
|
||||
}
|
||||
sv := &http.Server{Handler: http.HandlerFunc(handleProxyRequest)}
|
||||
go func() {
|
||||
// Serve returns http.ErrServerClosed on a clean Close; there is no
|
||||
// caller left to report anything else to by the time it returns.
|
||||
_ = sv.Serve(ln)
|
||||
}()
|
||||
return &connectProxy{ln: ln, sv: sv}, nil
|
||||
}
|
||||
|
||||
func (c *connectProxy) close() {
|
||||
_ = c.sv.Close()
|
||||
}
|
||||
|
||||
func handleProxyRequest(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodConnect {
|
||||
handleConnect(w, r)
|
||||
return
|
||||
}
|
||||
handleForward(w, r)
|
||||
}
|
||||
|
||||
// handleConnect implements the CONNECT tunnel: dial the real destination,
|
||||
// hijack the client connection, and splice the two together. This is the
|
||||
// exact mechanism the health engine's default https:// probe URL depends
|
||||
// on — a proxy that TCP-accepts but can't actually tunnel must fail here,
|
||||
// not succeed.
|
||||
func handleConnect(w http.ResponseWriter, r *http.Request) {
|
||||
dst, err := net.DialTimeout("tcp", r.Host, 10*time.Second)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
hijacker, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "hijack unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
src, buf, err := hijacker.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
if _, err := src.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Any bytes the client already sent past the CONNECT request line
|
||||
// before we hijacked are sitting in buf's reader; forward them before
|
||||
// starting the raw splice loop.
|
||||
if n := buf.Reader.Buffered(); n > 0 {
|
||||
if _, err := io.CopyN(dst, buf.Reader, int64(n)); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
done := make(chan struct{}, 2)
|
||||
go func() { io.Copy(dst, src); done <- struct{}{} }()
|
||||
go func() { io.Copy(src, dst); done <- struct{}{} }()
|
||||
<-done
|
||||
}
|
||||
|
||||
// handleForward proxies a plain absolute-form HTTP request. CONNECT is the
|
||||
// path the health engine's default probe exercises, but a probeURL
|
||||
// override using plain http:// should work too.
|
||||
func handleForward(w http.ResponseWriter, r *http.Request) {
|
||||
outReq := r.Clone(r.Context())
|
||||
outReq.RequestURI = ""
|
||||
resp, err := http.DefaultTransport.RoundTrip(outReq)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for k, vv := range resp.Header {
|
||||
for _, v := range vv {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
// sharedProxies tracks one real listener per port, reference-counted
|
||||
// across every mock.Provider in the process. This is package-level rather
|
||||
// than a field on Provider because a bound TCP port is a process-global OS
|
||||
// resource: two independently configured mock-typed provider entries (e.g.
|
||||
// two named "mock" instances in providers-config.yaml) must not both try
|
||||
// to bind 127.0.0.1:<port> — the second bind would simply fail. Sharing by
|
||||
// port, refcounted, means any number of instances across any number of
|
||||
// Provider values can use the same port safely, and the listener is torn
|
||||
// down once nothing needs it anymore.
|
||||
var sharedProxies = struct {
|
||||
mu sync.Mutex
|
||||
byPort map[int32]*sharedProxyEntry
|
||||
}{byPort: make(map[int32]*sharedProxyEntry)}
|
||||
|
||||
type sharedProxyEntry struct {
|
||||
proxy *connectProxy
|
||||
refs int
|
||||
}
|
||||
|
||||
// acquireProxy returns a release func for a real listener on mockProxyIP:
|
||||
// port, starting one if this is the first acquire for that port. Safe to
|
||||
// call concurrently; each returned release func must be called exactly
|
||||
// once.
|
||||
func acquireProxy(port int32) (release func(), err error) {
|
||||
sharedProxies.mu.Lock()
|
||||
defer sharedProxies.mu.Unlock()
|
||||
|
||||
entry, ok := sharedProxies.byPort[port]
|
||||
if !ok {
|
||||
p, err := newConnectProxy(fmt.Sprintf("%s:%d", mockProxyIP, port))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry = &sharedProxyEntry{proxy: p}
|
||||
sharedProxies.byPort[port] = entry
|
||||
}
|
||||
entry.refs++
|
||||
|
||||
var once sync.Once
|
||||
release = func() {
|
||||
once.Do(func() {
|
||||
sharedProxies.mu.Lock()
|
||||
defer sharedProxies.mu.Unlock()
|
||||
entry.refs--
|
||||
if entry.refs <= 0 {
|
||||
entry.proxy.close()
|
||||
delete(sharedProxies.byPort, port)
|
||||
}
|
||||
})
|
||||
}
|
||||
return release, nil
|
||||
}
|
||||
Reference in New Issue
Block a user