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:
Anand HegdeandTwiN authored and GitHub committed 2026-09-30 09:32:30 -04:00
1 parent ae605f2919
commit 884f64d8e6
5 files changed
+152 -5

No files matched your search

+51
View File
@@ -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)
}
}
+5 -5
View File
@@ -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 {
+64
View File
@@ -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
+5
View File
@@ -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
} }
+27
View File
@@ -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