Skip to content

Commit

Permalink
feat: Implement push-based external endpoints (#724)
Browse files Browse the repository at this point in the history
* refactor: Move SSH outside of endpoint.go
* refactor: Use pointers for Alert receivers
* feat: Implement push-based external endpoints
* Fix failing tests
* Validate external endpoints on start
* Add tests for external endpoints
* refactor some error equality checks
* Improve docs and refactor some code
* Fix UI-related issues with external endpoints
  • Loading branch information
TwiN authored Apr 9, 2024
1 parent cacfbc0 commit f54c45e
Show file tree
Hide file tree
Showing 28 changed files with 808 additions and 189 deletions.
196 changes: 143 additions & 53 deletions README.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions alerting/alert/alert.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func (alert *Alert) ValidateAndSetDefaults() error {
}

// GetDescription retrieves the description of the alert
func (alert Alert) GetDescription() string {
func (alert *Alert) GetDescription() string {
if alert.Description == nil {
return ""
}
Expand All @@ -80,15 +80,15 @@ func (alert Alert) GetDescription() string {

// IsEnabled returns whether an alert is enabled or not
// Returns true if not set
func (alert Alert) IsEnabled() bool {
func (alert *Alert) IsEnabled() bool {
if alert.Enabled == nil {
return true
}
return *alert.Enabled
}

// IsSendingOnResolved returns whether an alert is sending on resolve or not
func (alert Alert) IsSendingOnResolved() bool {
func (alert *Alert) IsSendingOnResolved() bool {
if alert.SendOnResolved == nil {
return false
}
Expand Down
19 changes: 10 additions & 9 deletions alerting/alert/alert_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package alert

import (
"errors"
"testing"
)

Expand Down Expand Up @@ -38,7 +39,7 @@ func TestAlert_ValidateAndSetDefaults(t *testing.T) {
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
if err := scenario.alert.ValidateAndSetDefaults(); err != scenario.expectedError {
if err := scenario.alert.ValidateAndSetDefaults(); !errors.Is(err, scenario.expectedError) {
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
}
if scenario.alert.SuccessThreshold != scenario.expectedSuccessThreshold {
Expand All @@ -52,34 +53,34 @@ func TestAlert_ValidateAndSetDefaults(t *testing.T) {
}

func TestAlert_IsEnabled(t *testing.T) {
if !(Alert{Enabled: nil}).IsEnabled() {
if !(&Alert{Enabled: nil}).IsEnabled() {
t.Error("alert.IsEnabled() should've returned true, because Enabled was set to nil")
}
if value := false; (Alert{Enabled: &value}).IsEnabled() {
if value := false; (&Alert{Enabled: &value}).IsEnabled() {
t.Error("alert.IsEnabled() should've returned false, because Enabled was set to false")
}
if value := true; !(Alert{Enabled: &value}).IsEnabled() {
if value := true; !(&Alert{Enabled: &value}).IsEnabled() {
t.Error("alert.IsEnabled() should've returned true, because Enabled was set to true")
}
}

func TestAlert_GetDescription(t *testing.T) {
if (Alert{Description: nil}).GetDescription() != "" {
if (&Alert{Description: nil}).GetDescription() != "" {
t.Error("alert.GetDescription() should've returned an empty string, because Description was set to nil")
}
if value := "description"; (Alert{Description: &value}).GetDescription() != value {
if value := "description"; (&Alert{Description: &value}).GetDescription() != value {
t.Error("alert.GetDescription() should've returned false, because Description was set to 'description'")
}
}

func TestAlert_IsSendingOnResolved(t *testing.T) {
if (Alert{SendOnResolved: nil}).IsSendingOnResolved() {
if (&Alert{SendOnResolved: nil}).IsSendingOnResolved() {
t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to nil")
}
if value := false; (Alert{SendOnResolved: &value}).IsSendingOnResolved() {
if value := false; (&Alert{SendOnResolved: &value}).IsSendingOnResolved() {
t.Error("alert.IsSendingOnResolved() should've returned false, because SendOnResolved was set to false")
}
if value := true; !(Alert{SendOnResolved: &value}).IsSendingOnResolved() {
if value := true; !(&Alert{SendOnResolved: &value}).IsSendingOnResolved() {
t.Error("alert.IsSendingOnResolved() should've returned true, because SendOnResolved was set to true")
}
}
2 changes: 2 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration/badge.svg", UptimeBadge)
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/badge.svg", ResponseTimeBadge(cfg))
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart)
// This endpoint requires authz with bearer token, so technically it is protected
unprotectedAPIRouter.Post("/v1/endpoints/:key/external", CreateExternalEndpointResult(cfg))
// SPA
app.Get("/", SinglePageApplication(cfg.UI))
app.Get("/endpoints/:name", SinglePageApplication(cfg.UI))
Expand Down
29 changes: 17 additions & 12 deletions api/badge.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ package api

import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"

"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core/ui"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store/common/paging"
Expand Down Expand Up @@ -52,9 +54,9 @@ func UptimeBadge(c *fiber.Ctx) error {
key := c.Params("key")
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
if err != nil {
if err == common.ErrEndpointNotFound {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
} else if err == common.ErrInvalidTimeRange {
} else if errors.Is(err, common.ErrInvalidTimeRange) {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
Expand All @@ -68,7 +70,7 @@ func UptimeBadge(c *fiber.Ctx) error {
// ResponseTimeBadge handles the automatic generation of badge based on the group name and endpoint name passed.
//
// Valid values for :duration -> 7d, 24h, 1h
func ResponseTimeBadge(config *config.Config) fiber.Handler {
func ResponseTimeBadge(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
duration := c.Params("duration")
var from time.Time
Expand All @@ -85,17 +87,17 @@ func ResponseTimeBadge(config *config.Config) fiber.Handler {
key := c.Params("key")
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
if err != nil {
if err == common.ErrEndpointNotFound {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
} else if err == common.ErrInvalidTimeRange {
} else if errors.Is(err, common.ErrInvalidTimeRange) {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
}
c.Set("Content-Type", "image/svg+xml")
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
c.Set("Expires", "0")
return c.Status(200).Send(generateResponseTimeBadgeSVG(duration, averageResponseTime, key, config))
return c.Status(200).Send(generateResponseTimeBadgeSVG(duration, averageResponseTime, key, cfg))
}
}

Expand All @@ -105,9 +107,9 @@ func HealthBadge(c *fiber.Ctx) error {
pagingConfig := paging.NewEndpointStatusParams()
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
if err != nil {
if err == common.ErrEndpointNotFound {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
} else if err == common.ErrInvalidTimeRange {
} else if errors.Is(err, common.ErrInvalidTimeRange) {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
Expand All @@ -131,9 +133,9 @@ func HealthBadgeShields(c *fiber.Ctx) error {
pagingConfig := paging.NewEndpointStatusParams()
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
if err != nil {
if err == common.ErrEndpointNotFound {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
} else if err == common.ErrInvalidTimeRange {
} else if errors.Is(err, common.ErrInvalidTimeRange) {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
Expand Down Expand Up @@ -271,10 +273,13 @@ func generateResponseTimeBadgeSVG(duration string, averageResponseTime int, key
}

func getBadgeColorFromResponseTime(responseTime int, key string, cfg *config.Config) string {
endpoint := cfg.GetEndpointByKey(key)
thresholds := ui.GetDefaultConfig().Badge.ResponseTime.Thresholds
if endpoint := cfg.GetEndpointByKey(key); endpoint != nil {
thresholds = endpoint.UIConfig.Badge.ResponseTime.Thresholds
}
// the threshold config requires 5 values, so we can be sure it's set here
for i := 0; i < 5; i++ {
if responseTime <= endpoint.UIConfig.Badge.ResponseTime.Thresholds[i] {
if responseTime <= thresholds[i] {
return badgeColors[i]
}
}
Expand Down
5 changes: 3 additions & 2 deletions api/chart.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package api

import (
"errors"
"log"
"math"
"net/http"
Expand Down Expand Up @@ -42,9 +43,9 @@ func ResponseTimeChart(c *fiber.Ctx) error {
}
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(c.Params("key"), from, time.Now())
if err != nil {
if err == common.ErrEndpointNotFound {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
} else if err == common.ErrInvalidTimeRange {
} else if errors.Is(err, common.ErrInvalidTimeRange) {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
Expand Down
4 changes: 2 additions & 2 deletions api/endpoint_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,13 @@ func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*cor
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())
log.Printf("[api.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())
log.Printf("[api.getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
continue
}
_ = response.Body.Close()
Expand Down
67 changes: 67 additions & 0 deletions api/external_endpoint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package api

import (
"errors"
"log"
"strings"
"time"

"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/core"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/watchdog"
"github.com/gofiber/fiber/v2"
)

func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
// Check if the success query parameter is present
success, exists := c.Queries()["success"]
if !exists || (success != "true" && success != "false") {
return c.Status(400).SendString("missing or invalid success query parameter")
}
// Check if the authorization bearer token header is correct
authorizationHeader := string(c.Request().Header.Peek("Authorization"))
if !strings.HasPrefix(authorizationHeader, "Bearer ") {
return c.Status(401).SendString("invalid Authorization header")
}
token := strings.TrimSpace(strings.TrimPrefix(authorizationHeader, "Bearer "))
if len(token) == 0 {
return c.Status(401).SendString("bearer token must not be empty")
}
key := c.Params("key")
externalEndpoint := cfg.GetExternalEndpointByKey(key)
if externalEndpoint == nil {
log.Printf("[api.CreateExternalEndpointResult] External endpoint with key=%s not found", key)
return c.Status(404).SendString("not found")
}
if externalEndpoint.Token != token {
log.Printf("[api.CreateExternalEndpointResult] Invalid token for external endpoint with key=%s", key)
return c.Status(401).SendString("invalid token")
}
// Persist the result in the storage
result := &core.Result{
Timestamp: time.Now(),
Success: c.QueryBool("success"),
Errors: []string{},
}
convertedEndpoint := externalEndpoint.ToEndpoint()
if err := store.Get().Insert(convertedEndpoint, result); err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
}
log.Printf("[api.CreateExternalEndpointResult] Failed to insert result in storage: %s", err.Error())
return c.Status(500).SendString(err.Error())
}
log.Printf("[api.CreateExternalEndpointResult] Successfully inserted result for external endpoint with key=%s and success=%s", c.Params("key"), success)
// Check if an alert should be triggered or resolved
if !cfg.Maintenance.IsUnderMaintenance() {
watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting, cfg.Debug)
externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
}
// Return the result
return c.Status(200).SendString("")
}
}
Loading

0 comments on commit f54c45e

Please sign in to comment.