fix(maintenance): validate maintenance windows for external, DNS and SSH endpoints (#1804)
* fix(maintenance): validate maintenance windows for external, DNS and SSH endpoints ExternalEndpoint.ValidateAndSetDefaults never called maintenance.Config.ValidateAndSetDefaults, so the window's start time and timezone were never parsed. IsUnderMaintenance then treated every window as starting at midnight in the server's local timezone, so alerts were still sent during the configured window. Endpoint.ValidateAndSetDefaults had the same problem for DNS and SSH endpoints, which returned before reaching the maintenance window loop. The loop now runs before those early returns. Fixes #1551 Made by an AI agent (Claude Code, model Claude Opus 5). * test(api): assert external endpoint maintenance window suppresses alerting Pushes a failing result for an external endpoint whose maintenance window (Europe/Berlin) is currently active and checks that alerting is skipped, exercising the same maintenance check CreateExternalEndpointResult uses. --------- Co-authored-by: TwiN <twin@linux.com>
This commit is contained in:
1 parent
ae605f2919
commit
884f64d8e6
5 files changed
+152
-5
No files matched your search
@@ -4,6 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/TwiN/gatus/v5/alerting"
|
"github.com/TwiN/gatus/v5/alerting"
|
||||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||||
@@ -174,3 +175,53 @@ func TestCreateExternalEndpointResult(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateExternalEndpointResultUnderEndpointMaintenanceWindow(t *testing.T) {
|
||||||
|
defer store.Get().Clear()
|
||||||
|
defer cache.Clear()
|
||||||
|
location, err := time.LoadLocation("Europe/Berlin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
externalEndpoint := &endpoint.ExternalEndpoint{
|
||||||
|
Name: "n",
|
||||||
|
Group: "g",
|
||||||
|
Token: "token",
|
||||||
|
Alerts: []*alert.Alert{
|
||||||
|
{
|
||||||
|
Type: alert.TypeDiscord,
|
||||||
|
FailureThreshold: 2,
|
||||||
|
SuccessThreshold: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MaintenanceWindows: []*maintenance.Config{{
|
||||||
|
Start: time.Now().In(location).Add(-time.Minute).Format("15:04"),
|
||||||
|
Duration: time.Hour,
|
||||||
|
Timezone: "Europe/Berlin",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if err := externalEndpoint.ValidateAndSetDefaults(); err != nil {
|
||||||
|
t.Fatal("did not expect an error, got", err)
|
||||||
|
}
|
||||||
|
cfg := &config.Config{
|
||||||
|
Alerting: &alerting.Config{
|
||||||
|
Discord: &discord.AlertProvider{},
|
||||||
|
},
|
||||||
|
ExternalEndpoints: []*endpoint.ExternalEndpoint{externalEndpoint},
|
||||||
|
Maintenance: &maintenance.Config{},
|
||||||
|
}
|
||||||
|
router := New(cfg).Router()
|
||||||
|
request := httptest.NewRequest("POST", "/api/v1/endpoints/g_n/external?success=false", http.NoBody)
|
||||||
|
request.Header.Set("Authorization", "Bearer token")
|
||||||
|
response, err := router.Test(request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != 200 {
|
||||||
|
t.Fatalf("expected 200, got %d", response.StatusCode)
|
||||||
|
}
|
||||||
|
if externalEndpoint.NumberOfFailuresInARow != 0 {
|
||||||
|
t.Errorf("expected alerting to be skipped because the endpoint is under maintenance, but NumberOfFailuresInARow is %d", externalEndpoint.NumberOfFailuresInARow)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -241,6 +241,11 @@ func (e *Endpoint) ValidateAndSetDefaults() error {
|
|||||||
return fmt.Errorf("%v: %w", ErrInvalidConditionFormat, err)
|
return fmt.Errorf("%v: %w", ErrInvalidConditionFormat, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, maintenanceWindow := range e.MaintenanceWindows {
|
||||||
|
if err := maintenanceWindow.ValidateAndSetDefaults(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if e.DNSConfig != nil {
|
if e.DNSConfig != nil {
|
||||||
return e.DNSConfig.ValidateAndSetDefault()
|
return e.DNSConfig.ValidateAndSetDefault()
|
||||||
}
|
}
|
||||||
@@ -250,11 +255,6 @@ func (e *Endpoint) ValidateAndSetDefaults() error {
|
|||||||
if e.Type() == TypeUNKNOWN {
|
if e.Type() == TypeUNKNOWN {
|
||||||
return ErrUnknownEndpointType
|
return ErrUnknownEndpointType
|
||||||
}
|
}
|
||||||
for _, maintenanceWindow := range e.MaintenanceWindows {
|
|
||||||
if err := maintenanceWindow.ValidateAndSetDefaults(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Make sure that the request can be created
|
// Make sure that the request can be created
|
||||||
_, err := http.NewRequest(e.Method, e.URL, bytes.NewBuffer([]byte(e.getParsedBody())))
|
_, err := http.NewRequest(e.Method, e.URL, bytes.NewBuffer([]byte(e.getParsedBody())))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -616,6 +616,70 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEndpoint_ValidateAndSetDefaultsWithMaintenanceWindowOnDNSAndSSH(t *testing.T) {
|
||||||
|
scenarios := []struct {
|
||||||
|
name string
|
||||||
|
endpoint func(windows []*maintenance.Config) *Endpoint
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "dns",
|
||||||
|
endpoint: func(windows []*maintenance.Config) *Endpoint {
|
||||||
|
return &Endpoint{
|
||||||
|
Name: "dns-test",
|
||||||
|
URL: "8.8.8.8",
|
||||||
|
DNSConfig: &dns.Config{QueryType: "A", QueryName: "example.com"},
|
||||||
|
Conditions: []Condition{Condition("[DNS_RCODE] == NOERROR")},
|
||||||
|
MaintenanceWindows: windows,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssh",
|
||||||
|
endpoint: func(windows []*maintenance.Config) *Endpoint {
|
||||||
|
return &Endpoint{
|
||||||
|
Name: "ssh-test",
|
||||||
|
URL: "ssh://example.com:22",
|
||||||
|
SSHConfig: &ssh.Config{Username: "username", Password: "password"},
|
||||||
|
Conditions: []Condition{Condition("[CONNECTED] == true")},
|
||||||
|
MaintenanceWindows: windows,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
t.Run(scenario.name+"-invalid-maintenance-window", func(t *testing.T) {
|
||||||
|
endpoint := scenario.endpoint([]*maintenance.Config{{Start: "25:00", Duration: time.Hour}})
|
||||||
|
if err := endpoint.ValidateAndSetDefaults(); err == nil {
|
||||||
|
t.Error("expected an error because the maintenance window has an invalid start, got none")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run(scenario.name+"-under-maintenance", func(t *testing.T) {
|
||||||
|
window := newMaintenanceWindowThatStartedAMinuteAgo(t, "Europe/Berlin")
|
||||||
|
endpoint := scenario.endpoint([]*maintenance.Config{window})
|
||||||
|
if err := endpoint.ValidateAndSetDefaults(); err != nil {
|
||||||
|
t.Fatal("did not expect an error, got", err)
|
||||||
|
}
|
||||||
|
if !endpoint.MaintenanceWindows[0].IsUnderMaintenance() {
|
||||||
|
t.Errorf("expected endpoint to be under maintenance (window starting at %s %s)", window.Start, window.Timezone)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMaintenanceWindowThatStartedAMinuteAgo returns a one-hour maintenance window whose start
|
||||||
|
// is one minute before the current time in the given timezone
|
||||||
|
func newMaintenanceWindowThatStartedAMinuteAgo(t *testing.T, timezone string) *maintenance.Config {
|
||||||
|
location, err := time.LoadLocation(timezone)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &maintenance.Config{
|
||||||
|
Start: time.Now().In(location).Add(-time.Minute).Format("15:04"),
|
||||||
|
Duration: time.Hour,
|
||||||
|
Timezone: timezone,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEndpoint_ValidateAndSetDefaultsWithSimpleErrors(t *testing.T) {
|
func TestEndpoint_ValidateAndSetDefaultsWithSimpleErrors(t *testing.T) {
|
||||||
scenarios := []struct {
|
scenarios := []struct {
|
||||||
endpoint *Endpoint
|
endpoint *Endpoint
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ func (externalEndpoint *ExternalEndpoint) ValidateAndSetDefaults() error {
|
|||||||
// If the heartbeat interval is set (non-0), it must be at least 10 seconds.
|
// If the heartbeat interval is set (non-0), it must be at least 10 seconds.
|
||||||
return ErrExternalEndpointHeartbeatIntervalTooLow
|
return ErrExternalEndpointHeartbeatIntervalTooLow
|
||||||
}
|
}
|
||||||
|
for _, maintenanceWindow := range externalEndpoint.MaintenanceWindows {
|
||||||
|
if err := maintenanceWindow.ValidateAndSetDefaults(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,33 @@ func TestExternalEndpoint_ValidateAndSetDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExternalEndpoint_ValidateAndSetDefaultsWithMaintenanceWindow(t *testing.T) {
|
||||||
|
t.Run("invalid-maintenance-window", func(t *testing.T) {
|
||||||
|
externalEndpoint := &ExternalEndpoint{
|
||||||
|
Name: "test-endpoint",
|
||||||
|
Token: "valid-token",
|
||||||
|
MaintenanceWindows: []*maintenance.Config{{Start: "25:00", Duration: time.Hour}},
|
||||||
|
}
|
||||||
|
if err := externalEndpoint.ValidateAndSetDefaults(); err == nil {
|
||||||
|
t.Error("expected an error because the maintenance window has an invalid start, got none")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("under-maintenance", func(t *testing.T) {
|
||||||
|
window := newMaintenanceWindowThatStartedAMinuteAgo(t, "Europe/Berlin")
|
||||||
|
externalEndpoint := &ExternalEndpoint{
|
||||||
|
Name: "test-endpoint",
|
||||||
|
Token: "valid-token",
|
||||||
|
MaintenanceWindows: []*maintenance.Config{window},
|
||||||
|
}
|
||||||
|
if err := externalEndpoint.ValidateAndSetDefaults(); err != nil {
|
||||||
|
t.Fatal("did not expect an error, got", err)
|
||||||
|
}
|
||||||
|
if !externalEndpoint.MaintenanceWindows[0].IsUnderMaintenance() {
|
||||||
|
t.Errorf("expected external endpoint to be under maintenance (window starting at %s %s)", window.Start, window.Timezone)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestExternalEndpoint_IsEnabled(t *testing.T) {
|
func TestExternalEndpoint_IsEnabled(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
Reference in new issue
Block a user