Skip to content

Commit 17e195e

Browse files
authored
Added support for route change events with webhook notifier (#560)
1 parent 9a01340 commit 17e195e

11 files changed

Lines changed: 403 additions & 53 deletions

AGENTS.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ Wakers/sleepers (`WakerFunc`/`SleeperFunc` in `routes.go`) are attached per rout
7575
- `github.com/gorilla/mux` — HTTP routing (API server)
7676
- `github.com/prometheus/client_golang` — Prometheus metrics
7777
- `golang.ngrok.com/ngrok` — ngrok tunnel integration
78-
- `github.com/stretchr/testify` — Test assertions
78+
- `github.com/stretchr/testify` — Test assertions and mocking
7979

8080
### Concurrency Model
8181

@@ -93,6 +93,11 @@ The maintainer is gradually moving the codebase **away from package-level global
9393
- **Scope helpers to their type.** Package-level functions that operate on or return a type's data should be methods on that type (or live in a nested package), not free functions in `server/`.
9494
- **Prefer a single config surface.** Where an `action` field can discriminate behavior (e.g. one webhook URL for both up/down), favor that over two parallel flags, matching the notifier webhook.
9595

96+
### Testing Conventions
97+
98+
- Use `testify` for assertions and mocking
99+
- Table-driven tests with subtests (`t.Run()`) for multiple scenarios
100+
96101
### Error Handling
97102

98103
- Wrap with context: `fmt.Errorf("message: %w", err)`

README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,54 @@ In this case the `status` is `"failed-backend-connection"` indicating that a bac
821821
}
822822
```
823823

824+
#### Route added
825+
826+
> [!NOTE] This event type is disabled by default
827+
828+
```json
829+
{
830+
"event": "route-added",
831+
"timestamp": "2025-04-20T22:26:30.2568775-05:00",
832+
"server": "localhost",
833+
"backend": "localhost:25566"
834+
}
835+
```
836+
837+
#### Route removed
838+
839+
> [!NOTE] This event type is disabled by default
840+
841+
```json
842+
{
843+
"event": "route-removed",
844+
"timestamp": "2025-04-20T22:26:30.2568775-05:00",
845+
"server": "localhost"
846+
}
847+
```
848+
849+
#### Default route set
850+
851+
> [!NOTE] This event type is disabled by default
852+
853+
```json
854+
{
855+
"event": "default-route-set",
856+
"timestamp": "2025-04-20T22:26:30.2568775-05:00",
857+
"backend": "localhost:25566"
858+
}
859+
```
860+
861+
#### Default route removed
862+
863+
> [!NOTE] This event type is disabled by default
864+
865+
```json
866+
{
867+
"event": "default-route-removed",
868+
"timestamp": "2025-04-20T22:26:30.2568775-05:00"
869+
}
870+
```
871+
824872
## Community Solutions
825873

826874
- **[MC Router Discovery](https://github.com/Seedloaf/mc-router-discovery):** A lightweight sidecar to ensure mc-router is in sync with your list of running servers.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ require (
88
github.com/go-kit/kit v0.13.0
99
github.com/gorilla/mux v1.8.1
1010
github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c
11-
github.com/itzg/go-flagsfiller v1.18.0
11+
github.com/itzg/go-flagsfiller v1.19.0
1212
github.com/juju/ratelimit v1.0.2
1313
github.com/pires/go-proxyproto v0.12.0
1414
github.com/pkg/errors v0.9.1

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ github.com/inconshreveable/log15/v3 v3.0.0-testing.5 h1:h4e0f3kjgg+RJBlKOabrohjH
8080
github.com/inconshreveable/log15/v3 v3.0.0-testing.5/go.mod h1:3GQg1SVrLoWGfRv/kAZMsdyU5cp8eFc1P3cw+Wwku94=
8181
github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c h1:qSHzRbhzK8RdXOsAdfDgO49TtqC1oZ+acxPrkfTxcCs=
8282
github.com/influxdata/influxdb1-client v0.0.0-20220302092344-a9ab5670611c/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
83-
github.com/itzg/go-flagsfiller v1.18.0 h1:dSxUZcvYQg5XKJ1bhZX7rLIQcz2mzlKNVZYXrvJrBAY=
84-
github.com/itzg/go-flagsfiller v1.18.0/go.mod h1:Zq4PnNALfrY73hVPlg7GExLxm3lVRu90895WVe8NQVg=
83+
github.com/itzg/go-flagsfiller v1.19.0 h1:CiT97rDZJrRvWoiVzbx/mRM+/fn+GQ6cRMMa/x3psBs=
84+
github.com/itzg/go-flagsfiller v1.19.0/go.mod h1:Zq4PnNALfrY73hVPlg7GExLxm3lVRu90895WVe8NQVg=
8585
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
8686
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
8787
github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA=

server/configs.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ package server
33
import "time"
44

55
type WebhookConfig struct {
6-
Url string `usage:"If set, a POST request that contains connection status notifications will be sent to this HTTP address"`
7-
RequireUser bool `default:"false" usage:"Indicates if the webhook will only be called if a user is connecting rather than just server list/ping"`
8-
Timeout time.Duration `default:"30s" usage:"Timeout for each connection status notification request"`
6+
Url string `usage:"If set, a POST request that contains connection status notifications will be sent to this HTTP address"`
7+
RequireUser bool `default:"false" usage:"Indicates if the webhook will only be called if a user is connecting rather than just server list/ping"`
8+
Timeout time.Duration `default:"30s" usage:"Timeout for each connection status notification request"`
9+
Events []WebhookEvent `default:"connect,disconnect" usage:"Comma delimited list of events to send to the webhook. Valid values are connect, disconnect, route-added, route-removed, default-route-set, default-route-removed"`
910
}
1011

1112
type AutoScale struct {

server/connector_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ func writeTestPacket(w io.Writer, packetID int32, payload func(w io.Writer)) err
132132
func TestConnectorMOTDFallback(t *testing.T) {
133133
routes := NewRoutes(t.Context())
134134
downScaler := NewDownScaler(false, 5*time.Second)
135-
routes.SetDownScaler(downScaler)
135+
routes.WithDownScaler(downScaler)
136136

137137
backendAddress := "127.0.0.1:0"
138138

server/routes.go

Lines changed: 83 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ type RoutesHandler interface {
4343
DeleteMapping(serverAddress string) bool
4444
}
4545

46+
type RoutesListener interface {
47+
// OnRouteAdded is called when a new route is added.
48+
OnRouteAdded(serverAddress string, backend string)
49+
// OnDefaultRouteSet is called when a default route is set.
50+
OnDefaultRouteSet(backend string)
51+
// OnRouteRemoved is called when a route is removed.
52+
OnRouteRemoved(serverAddress string)
53+
// OnDefaultRouteRemoved is called when a default route is removed (or un-set).
54+
OnDefaultRouteRemoved()
55+
}
56+
4657
type IRoutes interface {
4758
RoutesHandler
4859

@@ -60,31 +71,22 @@ type IRoutes interface {
6071
GetAsleepMOTD(serverAddress string) string
6172
GetLoadingMOTD(serverAddress string) string
6273
SimplifySRV(srvEnabled bool)
63-
SetDownScaler(downScaler IDownScaler)
74+
// BulkRegister registers a set of static mappings, attaching the scaler's waker/sleeper pair. nil-safe: a nil scaler registers without autoscaling.
75+
// Reset must be called separately and previous to this if you want to clear existing mappings.
76+
BulkRegister(scaler *WebhookScaler, mappings map[string]string)
77+
78+
WithDownScaler(downScaler IDownScaler) IRoutes
79+
WithListener(listener RoutesListener) IRoutes
6480
}
6581

6682
func NewRoutes(ctx context.Context) IRoutes {
6783
r := &routesImpl{
6884
ctx: ctx,
6985
mappings: make(map[string]mapping),
7086
}
71-
7287
return r
7388
}
7489

75-
func (r *routesImpl) SetDownScaler(downScaler IDownScaler) {
76-
r.downScaler = downScaler
77-
}
78-
79-
// registerStaticMappings registers each static mapping, attaching the scaler's
80-
// waker/sleeper pair. nil-safe: a nil scaler registers without autoscaling.
81-
func registerStaticMappings(routes RoutesHandler, scaler *WebhookScaler, mappings map[string]string) {
82-
for k, v := range mappings {
83-
waker, sleeper := scaler.routeFuncs(k, v)
84-
routes.CreateMapping(k, v, "", waker, sleeper, "", "")
85-
}
86-
}
87-
8890
type mapping struct {
8991
backend string
9092
waker WakerFunc
@@ -96,21 +98,62 @@ type mapping struct {
9698

9799
type routesImpl struct {
98100
sync.RWMutex
99-
ctx context.Context
100-
mappings map[string]mapping
101-
defaultRoute mapping
102-
simplifySRV bool
103-
downScaler IDownScaler
101+
ctx context.Context
102+
mappings map[string]mapping
103+
defaultRoute mapping
104+
simplifySRV bool
105+
downScaler IDownScaler
106+
routesListeners []RoutesListener
107+
}
108+
109+
// WithDownScaler sets the optional down scaler for the routes. The down scaler is used to scale down servers when they are no longer needed.
110+
// TODO this is a code smell because it creates a circular dependency between routes and down scaler. The down scaler needs to know about the routes to scale down servers, but the routes also need to know about the down scaler to start scaling down servers when they are no longer needed. This should be refactored in the future.
111+
func (r *routesImpl) WithDownScaler(downScaler IDownScaler) IRoutes {
112+
r.downScaler = downScaler
113+
return r
114+
}
115+
116+
// WithListener adds a listener to the routes. The listener will be notified of route changes.
117+
// It will also be notified of existing routes when added. This ensures listeners get a consistent and complete view of routes.
118+
func (r *routesImpl) WithListener(listener RoutesListener) IRoutes {
119+
r.Lock()
120+
defer r.Unlock()
121+
122+
r.routesListeners = append(r.routesListeners, listener)
123+
for server, backend := range r.mappings {
124+
listener.OnRouteAdded(server, backend.backend)
125+
}
126+
if r.defaultRoute.backend != "" {
127+
listener.OnDefaultRouteSet(r.defaultRoute.backend)
128+
}
129+
return r
104130
}
105131

106132
func (r *routesImpl) Reset() {
133+
r.Lock()
134+
defer r.Unlock()
135+
136+
for serverAddress := range r.mappings {
137+
for _, listener := range r.routesListeners {
138+
listener.OnRouteRemoved(serverAddress)
139+
}
140+
}
141+
107142
r.mappings = make(map[string]mapping)
143+
144+
for _, listener := range r.routesListeners {
145+
listener.OnDefaultRouteRemoved()
146+
}
147+
108148
if r.downScaler != nil {
109149
r.downScaler.Reset()
110150
}
111151
}
112152

113153
func (r *routesImpl) SetDefaultRoute(backend string, scalingTarget string, waker WakerFunc, sleeper SleeperFunc, asleepMOTD string, loadingMOTD string) {
154+
r.Lock()
155+
defer r.Unlock()
156+
114157
if scalingTarget == "" {
115158
scalingTarget = backend
116159
}
@@ -119,6 +162,10 @@ func (r *routesImpl) SetDefaultRoute(backend string, scalingTarget string, waker
119162
logrus.WithFields(logrus.Fields{
120163
"backend": backend,
121164
}).Info("Using default route")
165+
166+
for _, listener := range r.routesListeners {
167+
listener.OnDefaultRouteSet(backend)
168+
}
122169
}
123170

124171
func (r *routesImpl) GetDefaultRoute() (string, string, WakerFunc, SleeperFunc) {
@@ -245,6 +292,11 @@ func (r *routesImpl) DeleteMapping(serverAddress string) bool {
245292
if m, ok := r.mappings[serverAddress]; ok {
246293
r.downScaler.Cancel(m.scalingTarget)
247294
delete(r.mappings, serverAddress)
295+
296+
for _, listener := range r.routesListeners {
297+
listener.OnRouteRemoved(serverAddress)
298+
}
299+
248300
return true
249301
} else {
250302
return false
@@ -267,8 +319,19 @@ func (r *routesImpl) CreateMapping(serverAddress string, backend string, scaling
267319
}).Info("Created route mapping")
268320
r.mappings[serverAddress] = mapping{backend: backend, scalingTarget: scalingTarget, waker: waker, sleeper: sleeper, asleepMOTD: asleepMOTD, loadingMOTD: loadingMOTD}
269321

322+
for _, listener := range r.routesListeners {
323+
listener.OnRouteAdded(serverAddress, backend)
324+
}
325+
270326
// Trigger auto scale down when mapping is created to ensure servers are shut down if router restarts
271327
if r.downScaler != nil && scalingTarget != "" {
272328
r.downScaler.Start(r.ctx, scalingTarget, r)
273329
}
274330
}
331+
332+
func (r *routesImpl) BulkRegister(scaler *WebhookScaler, mappings map[string]string) {
333+
for k, v := range mappings {
334+
waker, sleeper := scaler.routeFuncs(k, v)
335+
r.CreateMapping(k, v, "", waker, sleeper, "", "")
336+
}
337+
}

server/routes_config_loader.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ func (r *RoutesConfigLoader) Load(routesConfigFileName string) error {
5252
return errors.Wrap(readErr, "Could not load the routes config file")
5353
}
5454

55-
registerStaticMappings(r.routes, r.scaler, config.Mappings)
55+
r.routes.BulkRegister(r.scaler, config.Mappings)
5656
waker, sleeper := r.scaler.routeFuncs("", config.DefaultServer)
5757
r.routes.SetDefaultRoute(config.DefaultServer, "", waker, sleeper, "", "")
5858
return nil
@@ -71,7 +71,7 @@ func (r *RoutesConfigLoader) Reload() error {
7171

7272
logrus.WithField("routesConfig", r.fileName).Info("Re-loading routes config file")
7373
r.routes.Reset()
74-
registerStaticMappings(r.routes, r.scaler, config.Mappings)
74+
r.routes.BulkRegister(r.scaler, config.Mappings)
7575
waker, sleeper := r.scaler.routeFuncs("", config.DefaultServer)
7676
r.routes.SetDefaultRoute(config.DefaultServer, "", waker, sleeper, "", "")
7777

0 commit comments

Comments
 (0)