package healthcheck import ( "context" "net/http" "net/http/httptest" "net/url" "sync" "testing" "time" "github.com/containous/traefik/testhelpers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/vulcand/oxy/roundrobin" ) const healthCheckInterval = 100 * time.Millisecond type testHandler struct { done func() healthSequence []int } func TestSetBackendsConfiguration(t *testing.T) { testCases := []struct { desc string startHealthy bool healthSequence []int expectedNumRemovedServers int expectedNumUpsertedServers int expectedGaugeValue float64 }{ { desc: "healthy server staying healthy", startHealthy: true, healthSequence: []int{http.StatusOK}, expectedNumRemovedServers: 0, expectedNumUpsertedServers: 0, expectedGaugeValue: 1, }, { desc: "healthy server staying healthy (StatusNoContent)", startHealthy: true, healthSequence: []int{http.StatusNoContent}, expectedNumRemovedServers: 0, expectedNumUpsertedServers: 0, expectedGaugeValue: 1, }, { desc: "healthy server staying healthy (StatusPermanentRedirect)", startHealthy: true, healthSequence: []int{http.StatusPermanentRedirect}, expectedNumRemovedServers: 0, expectedNumUpsertedServers: 0, expectedGaugeValue: 1, }, { desc: "healthy server becoming sick", startHealthy: true, healthSequence: []int{http.StatusServiceUnavailable}, expectedNumRemovedServers: 1, expectedNumUpsertedServers: 0, expectedGaugeValue: 0, }, { desc: "sick server becoming healthy", startHealthy: false, healthSequence: []int{http.StatusOK}, expectedNumRemovedServers: 0, expectedNumUpsertedServers: 1, expectedGaugeValue: 1, }, { desc: "sick server staying sick", startHealthy: false, healthSequence: []int{http.StatusServiceUnavailable}, expectedNumRemovedServers: 0, expectedNumUpsertedServers: 0, expectedGaugeValue: 0, }, { desc: "healthy server toggling to sick and back to healthy", startHealthy: true, healthSequence: []int{http.StatusServiceUnavailable, http.StatusOK}, expectedNumRemovedServers: 1, expectedNumUpsertedServers: 1, expectedGaugeValue: 1, }, } for _, test := range testCases { test := test t.Run(test.desc, func(t *testing.T) { t.Parallel() // The context is passed to the health check and canonically cancelled by // the test server once all expected requests have been received. ctx, cancel := context.WithCancel(context.Background()) defer cancel() ts := newTestServer(cancel, test.healthSequence) defer ts.Close() lb := &testLoadBalancer{RWMutex: &sync.RWMutex{}} backend := NewBackendConfig(Options{ Path: "/path", Interval: healthCheckInterval, LB: lb, }, "backendName") serverURL := testhelpers.MustParseURL(ts.URL) if test.startHealthy { lb.servers = append(lb.servers, serverURL) } else { backend.disabledURLs = append(backend.disabledURLs, serverURL) } collectingMetrics := testhelpers.NewCollectingHealthCheckMetrics() check := HealthCheck{ Backends: make(map[string]*BackendConfig), metrics: collectingMetrics, } wg := sync.WaitGroup{} wg.Add(1) go func() { check.execute(ctx, backend) wg.Done() }() // Make test timeout dependent on number of expected requests, health // check interval, and a safety margin. timeout := time.Duration(len(test.healthSequence)*int(healthCheckInterval) + 500) select { case <-time.After(timeout): t.Fatal("test did not complete in time") case <-ctx.Done(): wg.Wait() } lb.Lock() defer lb.Unlock() assert.Equal(t, test.expectedNumRemovedServers, lb.numRemovedServers, "removed servers") assert.Equal(t, test.expectedNumUpsertedServers, lb.numUpsertedServers, "upserted servers") assert.Equal(t, test.expectedGaugeValue, collectingMetrics.Gauge.GaugeValue, "ServerUp Gauge") }) } } func TestNewRequest(t *testing.T) { testCases := []struct { desc string serverURL string options Options expected string }{ { desc: "no port override", serverURL: "http://backend1:80", options: Options{ Path: "/test", Port: 0, }, expected: "http://backend1:80/test", }, { desc: "port override", serverURL: "http://backend2:80", options: Options{ Path: "/test", Port: 8080, }, expected: "http://backend2:8080/test", }, { desc: "no port override with no port in server URL", serverURL: "http://backend1", options: Options{ Path: "/health", Port: 0, }, expected: "http://backend1/health", }, { desc: "port override with no port in server URL", serverURL: "http://backend2", options: Options{ Path: "/health", Port: 8080, }, expected: "http://backend2:8080/health", }, { desc: "scheme override", serverURL: "https://backend1:80", options: Options{ Scheme: "http", Path: "/test", Port: 0, }, expected: "http://backend1:80/test", }, } for _, test := range testCases { test := test t.Run(test.desc, func(t *testing.T) { t.Parallel() backend := NewBackendConfig(test.options, "backendName") u, err := url.Parse(test.serverURL) require.NoError(t, err) req, err := backend.newRequest(u) require.NoError(t, err, "failed to create new backend request") assert.Equal(t, test.expected, req.URL.String()) }) } } func TestAddHeadersAndHost(t *testing.T) { testCases := []struct { desc string serverURL string options Options expectedHostname string expectedHeader string }{ { desc: "override hostname", serverURL: "http://backend1:80", options: Options{ Hostname: "myhost", Path: "/", }, expectedHostname: "myhost", expectedHeader: "", }, { desc: "not override hostname", serverURL: "http://backend1:80", options: Options{ Hostname: "", Path: "/", }, expectedHostname: "backend1:80", expectedHeader: "", }, { desc: "custom header", serverURL: "http://backend1:80", options: Options{ Headers: map[string]string{"Custom-Header": "foo"}, Hostname: "", Path: "/", }, expectedHostname: "backend1:80", expectedHeader: "foo", }, { desc: "custom header with hostname override", serverURL: "http://backend1:80", options: Options{ Headers: map[string]string{"Custom-Header": "foo"}, Hostname: "myhost", Path: "/", }, expectedHostname: "myhost", expectedHeader: "foo", }, } for _, test := range testCases { test := test t.Run(test.desc, func(t *testing.T) { t.Parallel() backend := NewBackendConfig(test.options, "backendName") u, err := url.Parse(test.serverURL) require.NoError(t, err) req, err := backend.newRequest(u) require.NoError(t, err, "failed to create new backend request") req = backend.addHeadersAndHost(req) assert.Equal(t, "http://backend1:80/", req.URL.String()) assert.Equal(t, test.expectedHostname, req.Host) assert.Equal(t, test.expectedHeader, req.Header.Get("Custom-Header")) }) } } type testLoadBalancer struct { // RWMutex needed due to parallel test execution: Both the system-under-test // and the test assertions reference the counters. *sync.RWMutex numRemovedServers int numUpsertedServers int servers []*url.URL } func (lb *testLoadBalancer) ServeHTTP(w http.ResponseWriter, req *http.Request) { // noop } func (lb *testLoadBalancer) RemoveServer(u *url.URL) error { lb.Lock() defer lb.Unlock() lb.numRemovedServers++ lb.removeServer(u) return nil } func (lb *testLoadBalancer) UpsertServer(u *url.URL, options ...roundrobin.ServerOption) error { lb.Lock() defer lb.Unlock() lb.numUpsertedServers++ lb.servers = append(lb.servers, u) return nil } func (lb *testLoadBalancer) Servers() []*url.URL { return lb.servers } func (lb *testLoadBalancer) removeServer(u *url.URL) { var i int var serverURL *url.URL for i, serverURL = range lb.servers { if *serverURL == *u { break } } lb.servers = append(lb.servers[:i], lb.servers[i+1:]...) } func newTestServer(done func(), healthSequence []int) *httptest.Server { handler := &testHandler{ done: done, healthSequence: healthSequence, } return httptest.NewServer(handler) } // ServeHTTP returns HTTP response codes following a status sequences. // It calls the given 'done' function once all request health indicators have been depleted. func (th *testHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if len(th.healthSequence) == 0 { panic("received unexpected request") } w.WriteHeader(th.healthSequence[0]) th.healthSequence = th.healthSequence[1:] if len(th.healthSequence) == 0 { th.done() } }