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.
This commit is contained in:
TwiN 2022-07-28 20:07:53 -04:00
parent 319f460553
commit 1aa94a3365
13 changed files with 168 additions and 65 deletions

View File

@ -10,6 +10,7 @@ import (
"github.com/TwiN/gatus/v4/alerting/alert" "github.com/TwiN/gatus/v4/alerting/alert"
"github.com/TwiN/gatus/v4/alerting/provider" "github.com/TwiN/gatus/v4/alerting/provider"
"github.com/TwiN/gatus/v4/config/maintenance" "github.com/TwiN/gatus/v4/config/maintenance"
"github.com/TwiN/gatus/v4/config/remote"
"github.com/TwiN/gatus/v4/config/ui" "github.com/TwiN/gatus/v4/config/ui"
"github.com/TwiN/gatus/v4/config/web" "github.com/TwiN/gatus/v4/config/web"
"github.com/TwiN/gatus/v4/core" "github.com/TwiN/gatus/v4/core"
@ -85,6 +86,10 @@ type Config struct {
// Maintenance is the configuration for creating a maintenance window in which no alerts are sent // Maintenance is the configuration for creating a maintenance window in which no alerts are sent
Maintenance *maintenance.Config `yaml:"maintenance,omitempty"` Maintenance *maintenance.Config `yaml:"maintenance,omitempty"`
// Remote is the configuration for remote Gatus instances
// WARNING: This is in ALPHA and may change or be completely removed in the future
Remote *remote.Config `yaml:"remote,omitempty"`
filePath string // path to the file from which config was loaded from filePath string // path to the file from which config was loaded from
lastFileModTime time.Time // last modification time lastFileModTime time.Time // last modification time
} }
@ -185,10 +190,22 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
if err := validateStorageConfig(config); err != nil { if err := validateStorageConfig(config); err != nil {
return nil, err return nil, err
} }
if err := validateRemoteConfig(config); err != nil {
return nil, err
}
} }
return return
} }
func validateRemoteConfig(config *Config) error {
if config.Remote != nil {
if err := config.Remote.ValidateAndSetDefaults(); err != nil {
return err
}
}
return nil
}
func validateStorageConfig(config *Config) error { func validateStorageConfig(config *Config) error {
if config.Storage == nil { if config.Storage == nil {
config.Storage = &storage.Config{ config.Storage = &storage.Config{

38
config/remote/remote.go Normal file
View File

@ -0,0 +1,38 @@
package remote
import (
"log"
"github.com/TwiN/gatus/v4/client"
)
// NOTICE: This is an experimental alpha feature and may be updated/removed in future versions.
// For more information, see https://github.com/TwiN/gatus/issues/64
type Config struct {
// Instances is a list of remote instances to retrieve endpoint statuses from.
Instances []Instance `yaml:"instances,omitempty"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
}
type Instance struct {
EndpointPrefix string `yaml:"endpoint-prefix"`
URL string `yaml:"url"`
}
func (c *Config) ValidateAndSetDefaults() error {
if c.ClientConfig == nil {
c.ClientConfig = client.GetDefaultConfig()
} else {
if err := c.ClientConfig.ValidateAndSetDefaults(); err != nil {
return err
}
}
if len(c.Instances) > 0 {
log.Println("WARNING: Your configuration is using 'remote', which is in alpha and may be updated/removed in future versions.")
log.Println("WARNING: See https://github.com/TwiN/gatus/issues/64 for more information")
}
return nil
}

View File

@ -8,10 +8,9 @@ import (
"os" "os"
"time" "time"
"github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v4/config/ui" "github.com/TwiN/gatus/v4/config/ui"
"github.com/TwiN/gatus/v4/config/web"
"github.com/TwiN/gatus/v4/controller/handler" "github.com/TwiN/gatus/v4/controller/handler"
"github.com/TwiN/gatus/v4/security"
) )
var ( var (
@ -21,19 +20,19 @@ var (
) )
// Handle creates the router and starts the server // Handle creates the router and starts the server
func Handle(securityConfig *security.Config, webConfig *web.Config, uiConfig *ui.Config, enableMetrics bool) { func Handle(cfg *config.Config) {
var router http.Handler = handler.CreateRouter(ui.StaticFolder, securityConfig, uiConfig, enableMetrics) var router http.Handler = handler.CreateRouter(ui.StaticFolder, cfg)
if os.Getenv("ENVIRONMENT") == "dev" { if os.Getenv("ENVIRONMENT") == "dev" {
router = handler.DevelopmentCORS(router) router = handler.DevelopmentCORS(router)
} }
server = &http.Server{ server = &http.Server{
Addr: fmt.Sprintf("%s:%d", webConfig.Address, webConfig.Port), Addr: fmt.Sprintf("%s:%d", cfg.Web.Address, cfg.Web.Port),
Handler: router, Handler: router,
ReadTimeout: 15 * time.Second, ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second,
IdleTimeout: 15 * time.Second, IdleTimeout: 15 * time.Second,
} }
log.Println("[controller][Handle] Listening on " + webConfig.SocketAddress()) log.Println("[controller][Handle] Listening on " + cfg.Web.SocketAddress())
if os.Getenv("ROUTER_TEST") == "true" { if os.Getenv("ROUTER_TEST") == "true" {
return return
} }

View File

@ -32,7 +32,7 @@ func TestHandle(t *testing.T) {
_ = os.Setenv("ROUTER_TEST", "true") _ = os.Setenv("ROUTER_TEST", "true")
_ = os.Setenv("ENVIRONMENT", "dev") _ = os.Setenv("ENVIRONMENT", "dev")
defer os.Clearenv() defer os.Clearenv()
Handle(cfg.Security, cfg.Web, cfg.UI, cfg.Metrics) Handle(cfg)
defer Shutdown() defer Shutdown()
request, _ := http.NewRequest("GET", "/health", http.NoBody) request, _ := http.NewRequest("GET", "/health", http.NoBody)
responseRecorder := httptest.NewRecorder() responseRecorder := httptest.NewRecorder()

View File

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

View File

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

View File

@ -5,11 +5,16 @@ import (
"compress/gzip" "compress/gzip"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log" "log"
"net/http" "net/http"
"strings" "strings"
"time" "time"
"github.com/TwiN/gatus/v4/client"
"github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v4/config/remote"
"github.com/TwiN/gatus/v4/core"
"github.com/TwiN/gatus/v4/storage/store" "github.com/TwiN/gatus/v4/storage/store"
"github.com/TwiN/gatus/v4/storage/store/common" "github.com/TwiN/gatus/v4/storage/store/common"
"github.com/TwiN/gatus/v4/storage/store/common/paging" "github.com/TwiN/gatus/v4/storage/store/common/paging"
@ -28,7 +33,8 @@ var (
// EndpointStatuses handles requests to retrieve all EndpointStatus // EndpointStatuses handles requests to retrieve all EndpointStatus
// Due to the size of the response, this function leverages a cache. // Due to the size of the response, this function leverages a cache.
// Must not be wrapped by GzipHandler // Must not be wrapped by GzipHandler
func EndpointStatuses(writer http.ResponseWriter, r *http.Request) { func EndpointStatuses(cfg *config.Config) http.HandlerFunc {
return func(writer http.ResponseWriter, r *http.Request) {
page, pageSize := extractPageAndPageSizeFromRequest(r) page, pageSize := extractPageAndPageSizeFromRequest(r)
gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") gzipped := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
var exists bool var exists bool
@ -50,6 +56,13 @@ func EndpointStatuses(writer http.ResponseWriter, r *http.Request) {
http.Error(writer, err.Error(), http.StatusInternalServerError) http.Error(writer, err.Error(), http.StatusInternalServerError)
return return
} }
// ALPHA: Retrieve endpoint statuses from remote instances
if endpointStatusesFromRemote, err := getEndpointStatusesFromRemoteInstances(cfg.Remote); err != nil {
log.Printf("[handler][EndpointStatuses] Silently failed to retrieve endpoint statuses from remote: %s", err.Error())
} else if endpointStatusesFromRemote != nil {
endpointStatuses = append(endpointStatuses, endpointStatusesFromRemote...)
}
// Marshal endpoint statuses to JSON
data, err = json.Marshal(endpointStatuses) data, err = json.Marshal(endpointStatuses)
if err != nil { if err != nil {
log.Printf("[handler][EndpointStatuses] Unable to marshal object to JSON: %s", err.Error()) log.Printf("[handler][EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
@ -70,6 +83,39 @@ func EndpointStatuses(writer http.ResponseWriter, r *http.Request) {
writer.Header().Add("Content-Type", "application/json") writer.Header().Add("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK) writer.WriteHeader(http.StatusOK)
_, _ = writer.Write(data) _, _ = writer.Write(data)
}
}
func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*core.EndpointStatus, error) {
if remoteConfig == nil || len(remoteConfig.Instances) == 0 {
return nil, nil
}
var endpointStatusesFromAllRemotes []*core.EndpointStatus
httpClient := client.GetHTTPClient(remoteConfig.ClientConfig)
for _, instance := range remoteConfig.Instances {
response, err := httpClient.Get(instance.URL)
if err != nil {
return nil, err
}
body, err := io.ReadAll(response.Body)
if err != nil {
_ = response.Body.Close()
log.Printf("[handler][getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
continue
}
var endpointStatuses []*core.EndpointStatus
if err = json.Unmarshal(body, &endpointStatuses); err != nil {
_ = response.Body.Close()
log.Printf("[handler][getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
continue
}
_ = response.Body.Close()
for _, endpointStatus := range endpointStatuses {
endpointStatus.Name = instance.EndpointPrefix + endpointStatus.Name
}
endpointStatusesFromAllRemotes = append(endpointStatusesFromAllRemotes, endpointStatuses...)
}
return endpointStatusesFromAllRemotes, nil
} }
// EndpointStatus retrieves a single core.EndpointStatus by group and endpoint name // EndpointStatus retrieves a single core.EndpointStatus by group and endpoint name

View File

@ -97,7 +97,7 @@ func TestEndpointStatus(t *testing.T) {
} }
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &core.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()}) watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &core.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
router := CreateRouter("../../web/static", cfg.Security, nil, cfg.Metrics) router := CreateRouter("../../web/static", cfg)
type Scenario struct { type Scenario struct {
Name string Name string
@ -153,7 +153,7 @@ func TestEndpointStatuses(t *testing.T) {
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests // Can't be bothered dealing with timezone issues on the worker that runs the automated tests
firstResult.Timestamp = time.Time{} firstResult.Timestamp = time.Time{}
secondResult.Timestamp = time.Time{} secondResult.Timestamp = time.Time{}
router := CreateRouter("../../web/static", nil, nil, false) router := CreateRouter("../../web/static", &config.Config{Metrics: true})
type Scenario struct { type Scenario struct {
Name string Name string

View File

@ -4,10 +4,12 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/TwiN/gatus/v4/config"
) )
func TestFavIcon(t *testing.T) { func TestFavIcon(t *testing.T) {
router := CreateRouter("../../web/static", nil, nil, false) router := CreateRouter("../../web/static", &config.Config{})
type Scenario struct { type Scenario struct {
Name string Name string
Path string Path string

View File

@ -3,32 +3,31 @@ package handler
import ( import (
"net/http" "net/http"
"github.com/TwiN/gatus/v4/config/ui" "github.com/TwiN/gatus/v4/config"
"github.com/TwiN/gatus/v4/security"
"github.com/TwiN/health" "github.com/TwiN/health"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
) )
func CreateRouter(staticFolder string, securityConfig *security.Config, uiConfig *ui.Config, enabledMetrics bool) *mux.Router { func CreateRouter(staticFolder string, cfg *config.Config) *mux.Router {
router := mux.NewRouter() router := mux.NewRouter()
if enabledMetrics { if cfg.Metrics {
router.Handle("/metrics", promhttp.Handler()).Methods("GET") router.Handle("/metrics", promhttp.Handler()).Methods("GET")
} }
api := router.PathPrefix("/api").Subrouter() api := router.PathPrefix("/api").Subrouter()
protected := api.PathPrefix("/").Subrouter() protected := api.PathPrefix("/").Subrouter()
unprotected := api.PathPrefix("/").Subrouter() unprotected := api.PathPrefix("/").Subrouter()
if securityConfig != nil { if cfg.Security != nil {
if err := securityConfig.RegisterHandlers(router); err != nil { if err := cfg.Security.RegisterHandlers(router); err != nil {
panic(err) panic(err)
} }
if err := securityConfig.ApplySecurityMiddleware(protected); err != nil { if err := cfg.Security.ApplySecurityMiddleware(protected); err != nil {
panic(err) panic(err)
} }
} }
// Endpoints // Endpoints
unprotected.Handle("/v1/config", ConfigHandler{securityConfig: securityConfig}).Methods("GET") unprotected.Handle("/v1/config", ConfigHandler{securityConfig: cfg.Security}).Methods("GET")
protected.HandleFunc("/v1/endpoints/statuses", EndpointStatuses).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already protected.HandleFunc("/v1/endpoints/statuses", EndpointStatuses(cfg)).Methods("GET") // No GzipHandler for this one, because we cache the content as Gzipped already
protected.HandleFunc("/v1/endpoints/{key}/statuses", GzipHandlerFunc(EndpointStatus)).Methods("GET") protected.HandleFunc("/v1/endpoints/{key}/statuses", GzipHandlerFunc(EndpointStatus)).Methods("GET")
unprotected.HandleFunc("/v1/endpoints/{key}/health/badge.svg", HealthBadge).Methods("GET") unprotected.HandleFunc("/v1/endpoints/{key}/health/badge.svg", HealthBadge).Methods("GET")
unprotected.HandleFunc("/v1/endpoints/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET") unprotected.HandleFunc("/v1/endpoints/{key}/uptimes/{duration}/badge.svg", UptimeBadge).Methods("GET")
@ -38,8 +37,8 @@ func CreateRouter(staticFolder string, securityConfig *security.Config, uiConfig
router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET") router.Handle("/health", health.Handler().WithJSON(true)).Methods("GET")
router.HandleFunc("/favicon.ico", FavIcon(staticFolder)).Methods("GET") router.HandleFunc("/favicon.ico", FavIcon(staticFolder)).Methods("GET")
// SPA // SPA
router.HandleFunc("/endpoints/{name}", SinglePageApplication(staticFolder, uiConfig)).Methods("GET") router.HandleFunc("/endpoints/{name}", SinglePageApplication(staticFolder, cfg.UI)).Methods("GET")
router.HandleFunc("/", SinglePageApplication(staticFolder, uiConfig)).Methods("GET") router.HandleFunc("/", SinglePageApplication(staticFolder, cfg.UI)).Methods("GET")
// Everything else falls back on static content // Everything else falls back on static content
router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder)))) router.PathPrefix("/").Handler(GzipHandler(http.FileServer(http.Dir(staticFolder))))
return router return router

View File

@ -4,10 +4,12 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"github.com/TwiN/gatus/v4/config"
) )
func TestCreateRouter(t *testing.T) { func TestCreateRouter(t *testing.T) {
router := CreateRouter("../../web/static", nil, nil, true) router := CreateRouter("../../web/static", &config.Config{Metrics: true})
type Scenario struct { type Scenario struct {
Name string Name string
Path string Path string

View File

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

View File

@ -36,7 +36,7 @@ func main() {
} }
func start(cfg *config.Config) { func start(cfg *config.Config) {
go controller.Handle(cfg.Security, cfg.Web, cfg.UI, cfg.Metrics) go controller.Handle(cfg)
watchdog.Monitor(cfg) watchdog.Monitor(cfg)
go listenToConfigurationFileChanges(cfg) go listenToConfigurationFileChanges(cfg)
} }