mirror of
https://github.com/caddyserver/caddy.git
synced 2026-03-07 17:45:50 -05:00
* reverseproxy: Track dynamic upstreams, enable passive healthchecking * Add tests for dynamic upstream tracking, admin endpoint, health checks
346 lines
9.4 KiB
Go
346 lines
9.4 KiB
Go
// Copyright 2015 Matthew Holt and The Caddy Authors
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package reverseproxy
|
|
|
|
import (
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/caddyserver/caddy/v2"
|
|
)
|
|
|
|
// resetDynamicHosts clears global dynamic host state between tests.
|
|
func resetDynamicHosts() {
|
|
dynamicHostsMu.Lock()
|
|
dynamicHosts = make(map[string]dynamicHostEntry)
|
|
dynamicHostsMu.Unlock()
|
|
// Reset the Once so cleanup goroutine tests can re-trigger if needed.
|
|
dynamicHostsCleanerOnce = sync.Once{}
|
|
}
|
|
|
|
// TestFillDynamicHostCreatesEntry verifies that calling fillDynamicHost on a
|
|
// new address inserts an entry into dynamicHosts and assigns a non-nil Host.
|
|
func TestFillDynamicHostCreatesEntry(t *testing.T) {
|
|
resetDynamicHosts()
|
|
|
|
u := &Upstream{Dial: "192.0.2.1:80"}
|
|
u.fillDynamicHost()
|
|
|
|
if u.Host == nil {
|
|
t.Fatal("expected Host to be set after fillDynamicHost")
|
|
}
|
|
|
|
dynamicHostsMu.RLock()
|
|
entry, ok := dynamicHosts["192.0.2.1:80"]
|
|
dynamicHostsMu.RUnlock()
|
|
|
|
if !ok {
|
|
t.Fatal("expected entry in dynamicHosts map")
|
|
}
|
|
if entry.host != u.Host {
|
|
t.Error("dynamicHosts entry host should be the same pointer assigned to Upstream.Host")
|
|
}
|
|
if entry.lastSeen.IsZero() {
|
|
t.Error("expected lastSeen to be set")
|
|
}
|
|
}
|
|
|
|
// TestFillDynamicHostReusesSameHost verifies that two calls for the same address
|
|
// return the exact same *Host pointer so that state (e.g. fail counts) is shared.
|
|
func TestFillDynamicHostReusesSameHost(t *testing.T) {
|
|
resetDynamicHosts()
|
|
|
|
u1 := &Upstream{Dial: "192.0.2.2:80"}
|
|
u1.fillDynamicHost()
|
|
|
|
u2 := &Upstream{Dial: "192.0.2.2:80"}
|
|
u2.fillDynamicHost()
|
|
|
|
if u1.Host != u2.Host {
|
|
t.Error("expected both upstreams to share the same *Host pointer")
|
|
}
|
|
}
|
|
|
|
// TestFillDynamicHostUpdatesLastSeen verifies that a second call for the same
|
|
// address advances the lastSeen timestamp.
|
|
func TestFillDynamicHostUpdatesLastSeen(t *testing.T) {
|
|
resetDynamicHosts()
|
|
|
|
u := &Upstream{Dial: "192.0.2.3:80"}
|
|
u.fillDynamicHost()
|
|
|
|
dynamicHostsMu.RLock()
|
|
first := dynamicHosts["192.0.2.3:80"].lastSeen
|
|
dynamicHostsMu.RUnlock()
|
|
|
|
// Ensure measurable time passes.
|
|
time.Sleep(2 * time.Millisecond)
|
|
|
|
u2 := &Upstream{Dial: "192.0.2.3:80"}
|
|
u2.fillDynamicHost()
|
|
|
|
dynamicHostsMu.RLock()
|
|
second := dynamicHosts["192.0.2.3:80"].lastSeen
|
|
dynamicHostsMu.RUnlock()
|
|
|
|
if !second.After(first) {
|
|
t.Error("expected lastSeen to be updated on second fillDynamicHost call")
|
|
}
|
|
}
|
|
|
|
// TestFillDynamicHostIndependentAddresses verifies that different addresses get
|
|
// independent Host entries.
|
|
func TestFillDynamicHostIndependentAddresses(t *testing.T) {
|
|
resetDynamicHosts()
|
|
|
|
u1 := &Upstream{Dial: "192.0.2.4:80"}
|
|
u1.fillDynamicHost()
|
|
|
|
u2 := &Upstream{Dial: "192.0.2.5:80"}
|
|
u2.fillDynamicHost()
|
|
|
|
if u1.Host == u2.Host {
|
|
t.Error("different addresses should have different *Host entries")
|
|
}
|
|
}
|
|
|
|
// TestFillDynamicHostPreservesFailCount verifies that fail counts on a dynamic
|
|
// host survive across multiple fillDynamicHost calls (simulating sequential
|
|
// requests), which is the core behaviour fixed by this change.
|
|
func TestFillDynamicHostPreservesFailCount(t *testing.T) {
|
|
resetDynamicHosts()
|
|
|
|
// First "request": provision and record a failure.
|
|
u1 := &Upstream{Dial: "192.0.2.6:80"}
|
|
u1.fillDynamicHost()
|
|
_ = u1.Host.countFail(1)
|
|
|
|
if u1.Host.Fails() != 1 {
|
|
t.Fatalf("expected 1 fail, got %d", u1.Host.Fails())
|
|
}
|
|
|
|
// Second "request": provision the same address again (new *Upstream, same address).
|
|
u2 := &Upstream{Dial: "192.0.2.6:80"}
|
|
u2.fillDynamicHost()
|
|
|
|
if u2.Host.Fails() != 1 {
|
|
t.Errorf("expected fail count to persist across fillDynamicHost calls, got %d", u2.Host.Fails())
|
|
}
|
|
}
|
|
|
|
// TestProvisionUpstreamDynamic verifies that provisionUpstream with dynamic=true
|
|
// uses fillDynamicHost (not the UsagePool) and sets healthCheckPolicy /
|
|
// MaxRequests correctly from handler config.
|
|
func TestProvisionUpstreamDynamic(t *testing.T) {
|
|
resetDynamicHosts()
|
|
|
|
passive := &PassiveHealthChecks{
|
|
FailDuration: caddy.Duration(10 * time.Second),
|
|
MaxFails: 3,
|
|
UnhealthyRequestCount: 5,
|
|
}
|
|
h := Handler{
|
|
HealthChecks: &HealthChecks{
|
|
Passive: passive,
|
|
},
|
|
}
|
|
|
|
u := &Upstream{Dial: "192.0.2.7:80"}
|
|
h.provisionUpstream(u, true)
|
|
|
|
if u.Host == nil {
|
|
t.Fatal("Host should be set after provisionUpstream")
|
|
}
|
|
if u.healthCheckPolicy != passive {
|
|
t.Error("healthCheckPolicy should point to the handler's PassiveHealthChecks")
|
|
}
|
|
if u.MaxRequests != 5 {
|
|
t.Errorf("expected MaxRequests=5 from UnhealthyRequestCount, got %d", u.MaxRequests)
|
|
}
|
|
|
|
// Must be in dynamicHosts, not in the static UsagePool.
|
|
dynamicHostsMu.RLock()
|
|
_, inDynamic := dynamicHosts["192.0.2.7:80"]
|
|
dynamicHostsMu.RUnlock()
|
|
if !inDynamic {
|
|
t.Error("dynamic upstream should be stored in dynamicHosts")
|
|
}
|
|
_, inPool := hosts.References("192.0.2.7:80")
|
|
if inPool {
|
|
t.Error("dynamic upstream should NOT be stored in the static UsagePool")
|
|
}
|
|
}
|
|
|
|
// TestProvisionUpstreamStatic verifies that provisionUpstream with dynamic=false
|
|
// uses the UsagePool and does NOT insert into dynamicHosts.
|
|
func TestProvisionUpstreamStatic(t *testing.T) {
|
|
resetDynamicHosts()
|
|
|
|
h := Handler{}
|
|
|
|
u := &Upstream{Dial: "192.0.2.8:80"}
|
|
h.provisionUpstream(u, false)
|
|
|
|
if u.Host == nil {
|
|
t.Fatal("Host should be set after provisionUpstream")
|
|
}
|
|
|
|
refs, inPool := hosts.References("192.0.2.8:80")
|
|
if !inPool {
|
|
t.Error("static upstream should be in the UsagePool")
|
|
}
|
|
if refs != 1 {
|
|
t.Errorf("expected ref count 1, got %d", refs)
|
|
}
|
|
|
|
dynamicHostsMu.RLock()
|
|
_, inDynamic := dynamicHosts["192.0.2.8:80"]
|
|
dynamicHostsMu.RUnlock()
|
|
if inDynamic {
|
|
t.Error("static upstream should NOT be in dynamicHosts")
|
|
}
|
|
|
|
// Clean up the pool entry we just added.
|
|
_, _ = hosts.Delete("192.0.2.8:80")
|
|
}
|
|
|
|
// TestDynamicHostHealthyConsultsFails verifies the end-to-end passive health
|
|
// check path: after enough failures are recorded against a dynamic upstream's
|
|
// shared *Host, Healthy() returns false for a newly provisioned *Upstream with
|
|
// the same address.
|
|
func TestDynamicHostHealthyConsultsFails(t *testing.T) {
|
|
resetDynamicHosts()
|
|
|
|
passive := &PassiveHealthChecks{
|
|
FailDuration: caddy.Duration(time.Minute),
|
|
MaxFails: 2,
|
|
}
|
|
h := Handler{
|
|
HealthChecks: &HealthChecks{Passive: passive},
|
|
}
|
|
|
|
// First request: provision and record two failures.
|
|
u1 := &Upstream{Dial: "192.0.2.9:80"}
|
|
h.provisionUpstream(u1, true)
|
|
|
|
_ = u1.Host.countFail(1)
|
|
_ = u1.Host.countFail(1)
|
|
|
|
// Second request: fresh *Upstream, same address.
|
|
u2 := &Upstream{Dial: "192.0.2.9:80"}
|
|
h.provisionUpstream(u2, true)
|
|
|
|
if u2.Healthy() {
|
|
t.Error("upstream should be unhealthy after MaxFails failures have been recorded against its shared Host")
|
|
}
|
|
}
|
|
|
|
// TestDynamicHostCleanupEvictsStaleEntries verifies that the cleanup sweep
|
|
// removes entries whose lastSeen is older than dynamicHostIdleExpiry.
|
|
func TestDynamicHostCleanupEvictsStaleEntries(t *testing.T) {
|
|
resetDynamicHosts()
|
|
|
|
const addr = "192.0.2.10:80"
|
|
|
|
// Insert an entry directly with a lastSeen far in the past.
|
|
dynamicHostsMu.Lock()
|
|
dynamicHosts[addr] = dynamicHostEntry{
|
|
host: new(Host),
|
|
lastSeen: time.Now().Add(-2 * dynamicHostIdleExpiry),
|
|
}
|
|
dynamicHostsMu.Unlock()
|
|
|
|
// Run the cleanup logic inline (same logic as the goroutine).
|
|
dynamicHostsMu.Lock()
|
|
for a, entry := range dynamicHosts {
|
|
if time.Since(entry.lastSeen) > dynamicHostIdleExpiry {
|
|
delete(dynamicHosts, a)
|
|
}
|
|
}
|
|
dynamicHostsMu.Unlock()
|
|
|
|
dynamicHostsMu.RLock()
|
|
_, stillPresent := dynamicHosts[addr]
|
|
dynamicHostsMu.RUnlock()
|
|
|
|
if stillPresent {
|
|
t.Error("stale dynamic host entry should have been evicted by cleanup sweep")
|
|
}
|
|
}
|
|
|
|
// TestDynamicHostCleanupRetainsFreshEntries verifies that the cleanup sweep
|
|
// keeps entries whose lastSeen is within dynamicHostIdleExpiry.
|
|
func TestDynamicHostCleanupRetainsFreshEntries(t *testing.T) {
|
|
resetDynamicHosts()
|
|
|
|
const addr = "192.0.2.11:80"
|
|
|
|
dynamicHostsMu.Lock()
|
|
dynamicHosts[addr] = dynamicHostEntry{
|
|
host: new(Host),
|
|
lastSeen: time.Now(),
|
|
}
|
|
dynamicHostsMu.Unlock()
|
|
|
|
// Run the cleanup logic inline.
|
|
dynamicHostsMu.Lock()
|
|
for a, entry := range dynamicHosts {
|
|
if time.Since(entry.lastSeen) > dynamicHostIdleExpiry {
|
|
delete(dynamicHosts, a)
|
|
}
|
|
}
|
|
dynamicHostsMu.Unlock()
|
|
|
|
dynamicHostsMu.RLock()
|
|
_, stillPresent := dynamicHosts[addr]
|
|
dynamicHostsMu.RUnlock()
|
|
|
|
if !stillPresent {
|
|
t.Error("fresh dynamic host entry should be retained by cleanup sweep")
|
|
}
|
|
}
|
|
|
|
// TestDynamicHostConcurrentFillHost verifies that concurrent calls to
|
|
// fillDynamicHost for the same address all get the same *Host pointer and
|
|
// don't race (run with -race).
|
|
func TestDynamicHostConcurrentFillHost(t *testing.T) {
|
|
resetDynamicHosts()
|
|
|
|
const addr = "192.0.2.12:80"
|
|
const goroutines = 50
|
|
|
|
var wg sync.WaitGroup
|
|
hosts := make([]*Host, goroutines)
|
|
|
|
for i := range goroutines {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
u := &Upstream{Dial: addr}
|
|
u.fillDynamicHost()
|
|
hosts[idx] = u.Host
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
first := hosts[0]
|
|
for i, h := range hosts {
|
|
if h != first {
|
|
t.Errorf("goroutine %d got a different *Host pointer; expected all to share the same entry", i)
|
|
}
|
|
}
|
|
}
|