From 884f64d8e6d796654ca92b6855aa502151af1f00 Mon Sep 17 00:00:00 2001 From: Anand Hegde Date: Wed, 30 Sep 2026 19:02:30 +0530 Subject: [PATCH] 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 --- api/external_endpoint_test.go | 51 ++++++++++++++++++ config/endpoint/endpoint.go | 10 ++-- config/endpoint/endpoint_test.go | 64 +++++++++++++++++++++++ config/endpoint/external_endpoint.go | 5 ++ config/endpoint/external_endpoint_test.go | 27 ++++++++++ 5 files changed, 152 insertions(+), 5 deletions(-) diff --git a/api/external_endpoint_test.go b/api/external_endpoint_test.go index 9f1bd3a7..8a02b994 100644 --- a/api/external_endpoint_test.go +++ b/api/external_endpoint_test.go @@ -4,6 +4,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/TwiN/gatus/v5/alerting" "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) + } +} diff --git a/config/endpoint/endpoint.go b/config/endpoint/endpoint.go index 0e8c77b7..149ef44d 100644 --- a/config/endpoint/endpoint.go +++ b/config/endpoint/endpoint.go @@ -241,6 +241,11 @@ func (e *Endpoint) ValidateAndSetDefaults() error { return fmt.Errorf("%v: %w", ErrInvalidConditionFormat, err) } } + for _, maintenanceWindow := range e.MaintenanceWindows { + if err := maintenanceWindow.ValidateAndSetDefaults(); err != nil { + return err + } + } if e.DNSConfig != nil { return e.DNSConfig.ValidateAndSetDefault() } @@ -250,11 +255,6 @@ func (e *Endpoint) ValidateAndSetDefaults() error { if e.Type() == TypeUNKNOWN { return ErrUnknownEndpointType } - for _, maintenanceWindow := range e.MaintenanceWindows { - if err := maintenanceWindow.ValidateAndSetDefaults(); err != nil { - return err - } - } // Make sure that the request can be created _, err := http.NewRequest(e.Method, e.URL, bytes.NewBuffer([]byte(e.getParsedBody()))) if err != nil { diff --git a/config/endpoint/endpoint_test.go b/config/endpoint/endpoint_test.go index d9b60f75..d36c9e5d 100644 --- a/config/endpoint/endpoint_test.go +++ b/config/endpoint/endpoint_test.go @@ -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) { scenarios := []struct { endpoint *Endpoint diff --git a/config/endpoint/external_endpoint.go b/config/endpoint/external_endpoint.go index 6db95611..a59ad57a 100644 --- a/config/endpoint/external_endpoint.go +++ b/config/endpoint/external_endpoint.go @@ -62,6 +62,11 @@ func (externalEndpoint *ExternalEndpoint) ValidateAndSetDefaults() error { // If the heartbeat interval is set (non-0), it must be at least 10 seconds. return ErrExternalEndpointHeartbeatIntervalTooLow } + for _, maintenanceWindow := range externalEndpoint.MaintenanceWindows { + if err := maintenanceWindow.ValidateAndSetDefaults(); err != nil { + return err + } + } return nil } diff --git a/config/endpoint/external_endpoint_test.go b/config/endpoint/external_endpoint_test.go index 0e619e57..a2b05701 100644 --- a/config/endpoint/external_endpoint_test.go +++ b/config/endpoint/external_endpoint_test.go @@ -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) { tests := []struct { name string