Skip to content

Commit 75d6efc

Browse files
authored
test: [health] add status handler test (#896)
Signed-off-by: Chris Randles <randles.chris@gmail.com>
1 parent cbbe20f commit 75d6efc

File tree

5 files changed

+243
-14
lines changed

5 files changed

+243
-14
lines changed

pkg/backends/healthcheck/healthcheck.go

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,26 @@ import (
2525

2626
// HealthChecker defines the Health Checker interface
2727
type HealthChecker interface {
28-
Register(string, string, *ho.Options, *http.Client) (*Status, error)
29-
Unregister(string)
30-
Status(string) *Status
28+
// Register a health check Target
29+
Register(name string, description string, options *ho.Options, client *http.Client) (*Status, error)
30+
// Remove a health check Target
31+
Unregister(name string)
32+
// Resolve status of named Target
33+
Status(name string) *Status
34+
// Retrieve all Target statuses
3135
Statuses() StatusLookup
36+
// Shutdown the health checker
3237
Shutdown()
38+
// Listen to be notified that status updates or shutdown has occurred
3339
Subscribe(chan bool)
3440
}
3541

3642
// Lookup is a map of named Target references
3743
type Lookup map[string]*target
3844

45+
// StatusLookup is a map of named Status references
46+
type StatusLookup map[string]*Status
47+
3948
type healthChecker struct {
4049
targets Lookup
4150
statuses StatusLookup

pkg/backends/healthcheck/status.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,24 @@ type Status struct {
4848
prober func(http.ResponseWriter)
4949
}
5050

51-
// StatusLookup is a map of named Status references
52-
type StatusLookup map[string]*Status
51+
func NewStatus(
52+
name string,
53+
description,
54+
detail string,
55+
status int32,
56+
failingSince time.Time,
57+
prober func(http.ResponseWriter),
58+
) *Status {
59+
s := &Status{
60+
name: name,
61+
description: description,
62+
detail: detail,
63+
prober: prober,
64+
failingSince: failingSince,
65+
}
66+
s.status.Store(status)
67+
return s
68+
}
5369

5470
func (s *Status) String() string {
5571
sb := &strings.Builder{}

pkg/proxy/handlers/health/health.go

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -151,12 +151,17 @@ func (hs *healthStatus) Tabular() string {
151151
// This handler spins up an infinitely looping background goroutine ("builder")
152152
// that updates the status text in real-time. So long as the HealthChecker
153153
// is closed with ShutDown(), the builder goroutine will exit
154-
func StatusHandler(hc healthcheck.HealthChecker, backends backends.Backends) http.Handler {
154+
func StatusHandler(now func() time.Time, hc healthcheck.HealthChecker, backends backends.Backends) http.Handler {
155155
if hc == nil {
156156
return nil
157157
}
158-
hd := &healthDetail{} // stores the status text in JSON and Text
159-
go builder(hc, hd, backends) // listens for rebuild notifications and updates the texts
158+
hd := &healthDetail{} // stores the status text in JSON and Text
159+
ready := make(chan bool, 1)
160+
if now == nil {
161+
now = time.Now
162+
}
163+
go builder(now, hc, hd, backends, ready) // listens for rebuild notifications and updates the texts
164+
<-ready // wait for the builder to be ready before returning the handler
160165

161166
// the handler, when requested, simply prints out the static text stored in the healthDetail
162167
// which is being updated in real time by the builder.
@@ -196,8 +201,8 @@ func StatusHandler(hc healthcheck.HealthChecker, backends backends.Backends) htt
196201
})
197202
}
198203

199-
func builder(hc healthcheck.HealthChecker, hd *healthDetail, backends backends.Backends) {
200-
updateStatusText(hc, hd, backends) // setup the initial status page text
204+
func builder(now func() time.Time, hc healthcheck.HealthChecker, hd *healthDetail, backends backends.Backends, ready chan<- bool) {
205+
updateStatusText(now, hc, hd, backends) // setup the initial status page text
201206
notifier := make(chan bool, 32)
202207
for _, c := range hc.Statuses() {
203208
c.RegisterSubscriber(notifier)
@@ -206,22 +211,24 @@ func builder(hc healthcheck.HealthChecker, hd *healthDetail, backends backends.B
206211
hc.Subscribe(closer)
207212
for {
208213
select {
214+
case ready <- true:
215+
// signal that the builder is in its ready state
209216
case <-closer: // a bool comes over closer when the Health Checker is closing down, so the builder should as well
210217
return
211218
case <-notifier: // a bool comes over notifier when the status text should be rebuilt
212-
updateStatusText(hc, hd, backends)
219+
updateStatusText(now, hc, hd, backends)
213220
}
214221
}
215222
}
216223

217224
const title = "Trickster Backend Health Status"
218225

219-
func updateStatusText(hc healthcheck.HealthChecker, hd *healthDetail, backends backends.Backends) {
226+
func updateStatusText(now func() time.Time, hc healthcheck.HealthChecker, hd *healthDetail, backends backends.Backends) {
220227
updateLock.Lock()
221228
defer updateLock.Unlock()
222229

223230
// HTTP Spec prefers GMT in RFC1123 Headers
224-
lastModified := time.Now().Truncate(time.Second).In(time.FixedZone("GMT", 0))
231+
lastModified := now().Truncate(time.Second).In(time.FixedZone("GMT", 0))
225232
// use UTC in the response body
226233
ut := lastModified.String()[:20] + "UTC"
227234

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/*
2+
* Copyright 2018 The Trickster Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package health
18+
19+
import (
20+
"maps"
21+
"net/http"
22+
"net/http/httptest"
23+
"net/url"
24+
"testing"
25+
"time"
26+
27+
"github.com/stretchr/testify/require"
28+
"github.com/trickstercache/trickster/v2/pkg/backends"
29+
"github.com/trickstercache/trickster/v2/pkg/backends/healthcheck"
30+
ho "github.com/trickstercache/trickster/v2/pkg/backends/healthcheck/options"
31+
bo "github.com/trickstercache/trickster/v2/pkg/backends/options"
32+
"github.com/trickstercache/trickster/v2/pkg/cache"
33+
"github.com/trickstercache/trickster/v2/pkg/proxy/handlers"
34+
po "github.com/trickstercache/trickster/v2/pkg/proxy/paths/options"
35+
)
36+
37+
var _ healthcheck.HealthChecker = (*mockHealthChecker)(nil)
38+
39+
type mockTarget struct {
40+
description string
41+
options *ho.Options
42+
client *http.Client
43+
status *healthcheck.Status
44+
}
45+
46+
type mockHealthChecker struct {
47+
// map of registered targets, with configurable status
48+
targets map[string]mockTarget
49+
// if configured, return status for all targets
50+
globalStatus *healthcheck.Status
51+
// list of subscribers to notify of activity
52+
subscribers []chan bool
53+
}
54+
55+
func newMockHealthChecker() *mockHealthChecker {
56+
return &mockHealthChecker{
57+
targets: make(map[string]mockTarget),
58+
}
59+
}
60+
61+
func (m *mockHealthChecker) Register(name string, description string, options *ho.Options, client *http.Client) (*healthcheck.Status, error) {
62+
target := mockTarget{
63+
description: description,
64+
options: options,
65+
client: client,
66+
}
67+
if m.globalStatus != nil {
68+
target.status = m.globalStatus
69+
} else {
70+
target.status = healthcheck.NewStatus(name, description, "initializing", 0, time.Time{}, nil)
71+
}
72+
m.targets[name] = target
73+
return &healthcheck.Status{}, nil
74+
}
75+
76+
func (m *mockHealthChecker) Unregister(name string) {
77+
delete(m.targets, name)
78+
}
79+
80+
func (m *mockHealthChecker) Status(name string) *healthcheck.Status {
81+
if m.globalStatus != nil {
82+
return m.globalStatus
83+
}
84+
85+
if t, ok := m.targets[name]; ok {
86+
return t.status
87+
}
88+
return nil
89+
}
90+
91+
func (m *mockHealthChecker) Subscribe(ch chan bool) {
92+
m.subscribers = append(m.subscribers, ch)
93+
}
94+
95+
// no-op for mock
96+
func (m *mockHealthChecker) Statuses() healthcheck.StatusLookup {
97+
lookup := make(healthcheck.StatusLookup, len(m.targets))
98+
for name, t := range m.targets {
99+
status := t.status
100+
if t.status == nil {
101+
if m.globalStatus == nil {
102+
continue // no status or global status, skip
103+
}
104+
t.status = m.globalStatus
105+
}
106+
lookup[name] = status
107+
}
108+
return lookup
109+
}
110+
111+
func (m *mockHealthChecker) notify(status bool) {
112+
for _, ch := range m.subscribers {
113+
ch <- status
114+
}
115+
}
116+
117+
func (m *mockHealthChecker) Shutdown() {
118+
m.notify(true)
119+
// no-op for mock, no background processes to stop
120+
}
121+
122+
type mockBackend struct {
123+
name string
124+
handlers handlers.Lookup
125+
}
126+
127+
func (m *mockBackend) RegisterHandlers(hl handlers.Lookup) {
128+
maps.Copy(m.handlers, hl)
129+
}
130+
131+
func (m *mockBackend) Handlers() handlers.Lookup {
132+
return m.handlers
133+
}
134+
135+
func (m *mockBackend) DefaultPathConfigs(*bo.Options) po.List {
136+
return nil
137+
}
138+
139+
func (m *mockBackend) Configuration() *bo.Options {
140+
return nil
141+
}
142+
func (m *mockBackend) Name() string {
143+
return m.name
144+
}
145+
func (m *mockBackend) HTTPClient() *http.Client {
146+
return nil
147+
}
148+
func (m *mockBackend) SetCache(cache.Cache) {}
149+
func (m *mockBackend) Router() http.Handler {
150+
return nil
151+
}
152+
func (m *mockBackend) Cache() cache.Cache {
153+
return nil
154+
}
155+
func (m *mockBackend) BaseUpstreamURL() *url.URL {
156+
return nil
157+
}
158+
func (m *mockBackend) SetHealthCheckProbe(healthcheck.DemandProbe) {}
159+
func (m *mockBackend) HealthHandler(http.ResponseWriter, *http.Request) {}
160+
func (m *mockBackend) DefaultHealthCheckConfig() *ho.Options {
161+
return nil
162+
}
163+
func (m *mockBackend) HealthCheckHTTPClient() *http.Client {
164+
return nil
165+
}
166+
167+
func TestStatusHandler(t *testing.T) {
168+
const (
169+
name = "mock-backend"
170+
)
171+
172+
hc := newMockHealthChecker()
173+
hc.globalStatus = healthcheck.NewStatus(name, "mock health checker", "mock detail", 1, time.Time{}, nil)
174+
backends := make(backends.Backends)
175+
backends[name] = &mockBackend{
176+
name: name,
177+
}
178+
status, err := hc.Register(name, "test backend", &ho.Options{}, &http.Client{})
179+
require.NoError(t, err)
180+
require.NotNil(t, status)
181+
182+
sh := StatusHandler(func() time.Time {
183+
return time.Time{}
184+
}, hc, backends)
185+
require.NotNil(t, sh)
186+
187+
req, err := http.NewRequest("GET", "/", nil)
188+
req.Header.Set("Accept", "application/json")
189+
require.NoError(t, err)
190+
191+
w := httptest.NewRecorder()
192+
193+
sh.ServeHTTP(w, req)
194+
require.Equal(t, http.StatusOK, w.Result().StatusCode)
195+
expected := `{"title":"Trickster Backend Health Status","updateTime":"0001-01-01 00:00:00 UTC","available":[{"name":"mock-backend","provider":"mock health checker"}]}`
196+
require.Equal(t, expected, w.Body.String())
197+
}

pkg/routing/routing.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ func RegisterProxyRoutes(conf *config.Config, clients backends.Backends,
130130
func RegisterHealthHandler(router router.Router, path string,
131131
hc healthcheck.HealthChecker, backends backends.Backends,
132132
) {
133-
router.RegisterRoute(path, nil, nil, false, health.StatusHandler(hc, backends))
133+
router.RegisterRoute(path, nil, nil, false, health.StatusHandler(nil, hc, backends))
134134
}
135135

136136
func registerBackendRoutes(r router.Router, metricsRouter router.Router,

0 commit comments

Comments
 (0)