Skip to content

Commit 15e6107

Browse files
committed
feat(remote): Implement lazy distributed feature (#64)
THIS IS AN EXPERIMENTAL FEATURE/IMPLEMENTATION, AND IT MAY BE REMOVED IN THE FUTURE. Note that for now, it will be an undocumented feature.
1 parent 2b44833 commit 15e6107

13 files changed

+164
-61
lines changed

config/config.go

+17
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/TwiN/gatus/v4/alerting/alert"
1111
"github.com/TwiN/gatus/v4/alerting/provider"
1212
"github.com/TwiN/gatus/v4/config/maintenance"
13+
"github.com/TwiN/gatus/v4/config/remote"
1314
"github.com/TwiN/gatus/v4/config/ui"
1415
"github.com/TwiN/gatus/v4/config/web"
1516
"github.com/TwiN/gatus/v4/core"
@@ -85,6 +86,10 @@ type Config struct {
8586
// Maintenance is the configuration for creating a maintenance window in which no alerts are sent
8687
Maintenance *maintenance.Config `yaml:"maintenance,omitempty"`
8788

89+
// Remote is the configuration for remote Gatus instances
90+
// WARNING: This is in ALPHA and may change or be completely removed in the future
91+
Remote *remote.Config `yaml:"remote,omitempty"`
92+
8893
filePath string // path to the file from which config was loaded from
8994
lastFileModTime time.Time // last modification time
9095
}
@@ -185,10 +190,22 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
185190
if err := validateStorageConfig(config); err != nil {
186191
return nil, err
187192
}
193+
if err := validateRemoteConfig(config); err != nil {
194+
return nil, err
195+
}
188196
}
189197
return
190198
}
191199

200+
func validateRemoteConfig(config *Config) error {
201+
if config.Remote != nil {
202+
if err := config.Remote.ValidateAndSetDefaults(); err != nil {
203+
return err
204+
}
205+
}
206+
return nil
207+
}
208+
192209
func validateStorageConfig(config *Config) error {
193210
if config.Storage == nil {
194211
config.Storage = &storage.Config{

config/remote/remote.go

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package remote
2+
3+
import (
4+
"log"
5+
6+
"github.com/TwiN/gatus/v4/client"
7+
)
8+
9+
// NOTICE: This is an experimental alpha feature and may be updated/removed in future versions.
10+
// For more information, see https://github.com/TwiN/gatus/issues/64
11+
12+
type Config struct {
13+
// Instances is a list of remote instances to retrieve endpoint statuses from.
14+
Instances []Instance `yaml:"instances,omitempty"`
15+
16+
// ClientConfig is the configuration of the client used to communicate with the provider's target
17+
ClientConfig *client.Config `yaml:"client,omitempty"`
18+
}
19+
20+
type Instance struct {
21+
EndpointPrefix string `yaml:"endpoint-prefix"`
22+
URL string `yaml:"url"`
23+
}
24+
25+
func (c *Config) ValidateAndSetDefaults() error {
26+
if c.ClientConfig == nil {
27+
c.ClientConfig = client.GetDefaultConfig()
28+
} else {
29+
if err := c.ClientConfig.ValidateAndSetDefaults(); err != nil {
30+
return err
31+
}
32+
}
33+
if len(c.Instances) > 0 {
34+
log.Println("WARNING: Your configuration is using 'remote', which is in alpha and may be updated/removed in future versions.")
35+
log.Println("WARNING: See https://github.com/TwiN/gatus/issues/64 for more information")
36+
}
37+
return nil
38+
}

controller/controller.go

+5-6
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,9 @@ import (
88
"os"
99
"time"
1010

11+
"github.com/TwiN/gatus/v4/config"
1112
"github.com/TwiN/gatus/v4/config/ui"
12-
"github.com/TwiN/gatus/v4/config/web"
1313
"github.com/TwiN/gatus/v4/controller/handler"
14-
"github.com/TwiN/gatus/v4/security"
1514
)
1615

1716
var (
@@ -21,19 +20,19 @@ var (
2120
)
2221

2322
// Handle creates the router and starts the server
24-
func Handle(securityConfig *security.Config, webConfig *web.Config, uiConfig *ui.Config, enableMetrics bool) {
25-
var router http.Handler = handler.CreateRouter(ui.StaticFolder, securityConfig, uiConfig, enableMetrics)
23+
func Handle(cfg *config.Config) {
24+
var router http.Handler = handler.CreateRouter(ui.StaticFolder, cfg)
2625
if os.Getenv("ENVIRONMENT") == "dev" {
2726
router = handler.DevelopmentCORS(router)
2827
}
2928
server = &http.Server{
30-
Addr: fmt.Sprintf("%s:%d", webConfig.Address, webConfig.Port),
29+
Addr: fmt.Sprintf("%s:%d", cfg.Web.Address, cfg.Web.Port),
3130
Handler: router,
3231
ReadTimeout: 15 * time.Second,
3332
WriteTimeout: 15 * time.Second,
3433
IdleTimeout: 15 * time.Second,
3534
}
36-
log.Println("[controller][Handle] Listening on " + webConfig.SocketAddress())
35+
log.Println("[controller][Handle] Listening on " + cfg.Web.SocketAddress())
3736
if os.Getenv("ROUTER_TEST") == "true" {
3837
return
3938
}

controller/controller_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func TestHandle(t *testing.T) {
3232
_ = os.Setenv("ROUTER_TEST", "true")
3333
_ = os.Setenv("ENVIRONMENT", "dev")
3434
defer os.Clearenv()
35-
Handle(cfg.Security, cfg.Web, cfg.UI, cfg.Metrics)
35+
Handle(cfg)
3636
defer Shutdown()
3737
request, _ := http.NewRequest("GET", "/health", http.NoBody)
3838
responseRecorder := httptest.NewRecorder()

controller/handler/badge_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func TestBadge(t *testing.T) {
3131
}
3232
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
3333
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
34-
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
34+
router := CreateRouter("../../web/static", cfg)
3535
type Scenario struct {
3636
Name string
3737
Path string

controller/handler/chart_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func TestResponseTimeChart(t *testing.T) {
3030
}
3131
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
3232
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
33-
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
33+
router := CreateRouter("../../web/static", cfg)
3434
type Scenario struct {
3535
Name string
3636
Path string

controller/handler/endpoint_status.go

+81-35
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@ import (
55
"compress/gzip"
66
"encoding/json"
77
"fmt"
8+
"io"
89
"log"
910
"net/http"
1011
"strings"
1112
"time"
1213

14+
"github.com/TwiN/gatus/v4/client"
15+
"github.com/TwiN/gatus/v4/config"
16+
"github.com/TwiN/gatus/v4/config/remote"
17+
"github.com/TwiN/gatus/v4/core"
1318
"github.com/TwiN/gatus/v4/storage/store"
1419
"github.com/TwiN/gatus/v4/storage/store/common"
1520
"github.com/TwiN/gatus/v4/storage/store/common/paging"
@@ -28,48 +33,89 @@ var (
2833
// EndpointStatuses handles requests to retrieve all EndpointStatus
2934
// Due to the size of the response, this function leverages a cache.
3035
// Must not be wrapped by GzipHandler
31-
func EndpointStatuses(writer http.ResponseWriter, r *http.Request) {
32-
page, pageSize := extractPageAndPageSizeFromRequest(r)
33-
gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
34-
var exists bool
35-
var value interface{}
36-
if gzipped {
37-
writer.Header().Set("Content-Encoding", "gzip")
38-
value, exists = cache.Get(fmt.Sprintf("endpoint-status-%d-%d-gzipped", page, pageSize))
39-
} else {
40-
value, exists = cache.Get(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize))
36+
func EndpointStatuses(cfg *config.Config) http.HandlerFunc {
37+
return func(writer http.ResponseWriter, r *http.Request) {
38+
page, pageSize := extractPageAndPageSizeFromRequest(r)
39+
gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
40+
var exists bool
41+
var value interface{}
42+
if gzipped {
43+
writer.Header().Set("Content-Encoding", "gzip")
44+
value, exists = cache.Get(fmt.Sprintf("endpoint-status-%d-%d-gzipped", page, pageSize))
45+
} else {
46+
value, exists = cache.Get(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize))
47+
}
48+
var data []byte
49+
if !exists {
50+
var err error
51+
buffer := &bytes.Buffer{}
52+
gzipWriter := gzip.NewWriter(buffer)
53+
endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
54+
if err != nil {
55+
log.Printf("[handler][EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error())
56+
http.Error(writer, err.Error(), http.StatusInternalServerError)
57+
return
58+
}
59+
// ALPHA: Retrieve endpoint statuses from remote instances
60+
if endpointStatusesFromRemote, err := getEndpointStatusesFromRemoteInstances(cfg.Remote); err != nil {
61+
log.Printf("[handler][EndpointStatuses] Silently failed to retrieve endpoint statuses from remote: %s", err.Error())
62+
} else if endpointStatusesFromRemote != nil {
63+
endpointStatuses = append(endpointStatuses, endpointStatusesFromRemote...)
64+
}
65+
// Marshal endpoint statuses to JSON
66+
data, err = json.Marshal(endpointStatuses)
67+
if err != nil {
68+
log.Printf("[handler][EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
69+
http.Error(writer, "unable to marshal object to JSON", http.StatusInternalServerError)
70+
return
71+
}
72+
_, _ = gzipWriter.Write(data)
73+
_ = gzipWriter.Close()
74+
gzippedData := buffer.Bytes()
75+
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL)
76+
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d-gzipped", page, pageSize), gzippedData, cacheTTL)
77+
if gzipped {
78+
data = gzippedData
79+
}
80+
} else {
81+
data = value.([]byte)
82+
}
83+
writer.Header().Add("Content-Type", "application/json")
84+
writer.WriteHeader(http.StatusOK)
85+
_, _ = writer.Write(data)
86+
}
87+
}
88+
89+
func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*core.EndpointStatus, error) {
90+
if remoteConfig == nil || len(remoteConfig.Instances) == 0 {
91+
return nil, nil
4192
}
42-
var data []byte
43-
if !exists {
44-
var err error
45-
buffer := &bytes.Buffer{}
46-
gzipWriter := gzip.NewWriter(buffer)
47-
endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
93+
var endpointStatusesFromAllRemotes []*core.EndpointStatus
94+
httpClient := client.GetHTTPClient(remoteConfig.ClientConfig)
95+
for _, instance := range remoteConfig.Instances {
96+
response, err := httpClient.Get(instance.URL)
4897
if err != nil {
49-
log.Printf("[handler][EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error())
50-
http.Error(writer, err.Error(), http.StatusInternalServerError)
51-
return
98+
return nil, err
5299
}
53-
data, err = json.Marshal(endpointStatuses)
100+
body, err := io.ReadAll(response.Body)
54101
if err != nil {
55-
log.Printf("[handler][EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
56-
http.Error(writer, "unable to marshal object to JSON", http.StatusInternalServerError)
57-
return
102+
_ = response.Body.Close()
103+
log.Printf("[handler][getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
104+
continue
58105
}
59-
_, _ = gzipWriter.Write(data)
60-
_ = gzipWriter.Close()
61-
gzippedData := buffer.Bytes()
62-
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL)
63-
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d-gzipped", page, pageSize), gzippedData, cacheTTL)
64-
if gzipped {
65-
data = gzippedData
106+
var endpointStatuses []*core.EndpointStatus
107+
if err = json.Unmarshal(body, &endpointStatuses); err != nil {
108+
_ = response.Body.Close()
109+
log.Printf("[handler][getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
110+
continue
66111
}
67-
} else {
68-
data = value.([]byte)
112+
_ = response.Body.Close()
113+
for _, endpointStatus := range endpointStatuses {
114+
endpointStatus.Name = instance.EndpointPrefix + endpointStatus.Name
115+
}
116+
endpointStatusesFromAllRemotes = append(endpointStatusesFromAllRemotes, endpointStatuses...)
69117
}
70-
writer.Header().Add("Content-Type", "application/json")
71-
writer.WriteHeader(http.StatusOK)
72-
_, _ = writer.Write(data)
118+
return endpointStatusesFromAllRemotes, nil
73119
}
74120

75121
// EndpointStatus retrieves a single core.EndpointStatus by group and endpoint name

controller/handler/endpoint_status_test.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ func TestEndpointStatus(t *testing.T) {
9797
}
9898
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
9999
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
100-
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
100+
router := CreateRouter("../../web/static", cfg)
101101

102102
type Scenario struct {
103103
Name string
@@ -153,7 +153,7 @@ func TestEndpointStatuses(t *testing.T) {
153153
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
154154
firstResult.Timestamp = time.Time{}
155155
secondResult.Timestamp = time.Time{}
156-
router := CreateRouter("../../web/static", nil, nil, false)
156+
router := CreateRouter("../../web/static", &config.Config{Metrics: true})
157157

158158
type Scenario struct {
159159
Name string

controller/handler/favicon_test.go

+3-1
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import (
44
"net/http"
55
"net/http/httptest"
66
"testing"
7+
8+
"github.com/TwiN/gatus/v4/config"
79
)
810

911
func TestFavIcon(t *testing.T) {
10-
router := CreateRouter("../../web/static", nil, nil, false)
12+
router := CreateRouter("../../web/static", &config.Config{})
1113
type Scenario struct {
1214
Name string
1315
Path string

controller/handler/handler.go

+10-11
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,31 @@ package handler
33
import (
44
"net/http"
55

6-
"github.com/TwiN/gatus/v4/config/ui"
7-
"github.com/TwiN/gatus/v4/security"
6+
"github.com/TwiN/gatus/v4/config"
87
"github.com/TwiN/health"
98
"github.com/gorilla/mux"
109
"github.com/prometheus/client_golang/prometheus/promhttp"
1110
)
1211

13-
func CreateRouter(staticFolder string, securityConfig *security.Config, uiConfig *ui.Config, enabledMetrics bool) *mux.Router {
12+
func CreateRouter(staticFolder string, cfg *config.Config) *mux.Router {
1413
router := mux.NewRouter()
15-
if enabledMetrics {
14+
if cfg.Metrics {
1615
router.Handle("/metrics", promhttp.Handler()).Methods("GET")
1716
}
1817
api := router.PathPrefix("/api").Subrouter()
1918
protected := api.PathPrefix("/").Subrouter()
2019
unprotected := api.PathPrefix("/").Subrouter()
21-
if securityConfig != nil {
22-
if err := securityConfig.RegisterHandlers(router); err != nil {
20+
if cfg.Security != nil {
21+
if err := cfg.Security.RegisterHandlers(router); err != nil {
2322
panic(err)
2423
}
25-
if err := securityConfig.ApplySecurityMiddleware(protected); err != nil {
24+
if err := cfg.Security.ApplySecurityMiddleware(protected); err != nil {
2625
panic(err)
2726
}
2827
}
2928
// Endpoints
30-
unprotected.Handle("/v1/config", ConfigHandler{securityConfig: securityConfig}).Methods("GET")
31-
protected.HandleFunc("/v1/endpoints/statuses", EndpointStatuses).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already
29+
unprotected.Handle("/v1/config", ConfigHandler{securityConfig: cfg.Security}).Methods("GET")
30+
protected.HandleFunc("/v1/endpoints/statuses", EndpointStatuses(cfg)).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already
3231
protected.HandleFunc("/v1/endpoints/{key}/statuses", GzipHandlerFunc(EndpointStatus)).Methods("GET")
3332
unprotected.HandleFunc("/v1/endpoints/{key}/health/badge.svg", HealthBadge).Methods("GET")
3433
unprotected.HandleFunc("/v1/endpoints/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET")
@@ -38,8 +37,8 @@ func CreateRouter(staticFolder string, securityConfig *security.Config, uiConfig
3837
router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET")
3938
router.HandleFunc("/favicon.ico", FavIcon(staticFolder)).Methods("GET")
4039
// SPA
41-
router.HandleFunc("/endpoints/{name}", SinglePageApplication(staticFolder, uiConfig)).Methods("GET")
42-
router.HandleFunc("/", SinglePageApplication(staticFolder, uiConfig)).Methods("GET")
40+
router.HandleFunc("/endpoints/{name}", SinglePageApplication(staticFolder, cfg.UI)).Methods("GET")
41+
router.HandleFunc("/", SinglePageApplication(staticFolder, cfg.UI)).Methods("GET")
4342
// Everything else falls back on static content
4443
router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder))))
4544
return router

controller/handler/handler_test.go

+3-1
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import (
44
"net/http"
55
"net/http/httptest"
66
"testing"
7+
8+
"github.com/TwiN/gatus/v4/config"
79
)
810

911
func TestCreateRouter(t *testing.T) {
10-
router := CreateRouter("../../web/static", nil, nil, true)
12+
router := CreateRouter("../../web/static", &config.Config{Metrics: true})
1113
type Scenario struct {
1214
Name string
1315
Path string

controller/handler/spa_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ func TestSinglePageApplication(t *testing.T) {
3030
}
3131
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
3232
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
33-
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics)
33+
router := CreateRouter("../../web/static", cfg)
3434
type Scenario struct {
3535
Name string
3636
Path string

0 commit comments

Comments
 (0)