feat(store): detect overlapping maintenance windows
Warn via log when creating a maintenance window that overlaps with an existing one for the same monitor, parent group, or global scope. Handles both timed and indefinite windows.
This commit was merged in pull request #151.
This commit is contained in:
@@ -652,6 +652,48 @@ func (s *SQLStore) GetAllMaintenanceWindows(ctx context.Context, limit int) ([]m
|
|||||||
return windows, rows.Err()
|
return windows, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) GetOverlappingMaintenanceWindows(ctx context.Context, monitorID int, startTime, endTime time.Time) ([]models.MaintenanceWindow, error) {
|
||||||
|
var timeClause string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
if endTime.IsZero() {
|
||||||
|
timeClause = "(end_time IS NULL OR end_time > ?)"
|
||||||
|
args = append(args, startTime)
|
||||||
|
} else {
|
||||||
|
timeClause = "(end_time IS NULL OR end_time > ?) AND start_time < ?"
|
||||||
|
args = append(args, startTime, endTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
var scopeClause string
|
||||||
|
if monitorID == 0 {
|
||||||
|
scopeClause = "1=1"
|
||||||
|
} else {
|
||||||
|
scopeClause = "(monitor_id = ? OR monitor_id = 0 OR monitor_id IN (SELECT parent_id FROM sites WHERE id = ? AND parent_id > 0))"
|
||||||
|
args = append(args, monitorID, monitorID)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(
|
||||||
|
"SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows WHERE %s AND %s ORDER BY start_time",
|
||||||
|
timeClause, scopeClause,
|
||||||
|
)
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var windows []models.MaintenanceWindow
|
||||||
|
for rows.Next() {
|
||||||
|
mw, err := s.scanMaintenanceWindow(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
windows = append(windows, mw)
|
||||||
|
}
|
||||||
|
return windows, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SQLStore) AddMaintenanceWindow(ctx context.Context, mw models.MaintenanceWindow) error {
|
func (s *SQLStore) AddMaintenanceWindow(ctx context.Context, mw models.MaintenanceWindow) error {
|
||||||
if mw.StartTime.IsZero() {
|
if mw.StartTime.IsZero() {
|
||||||
mw.StartTime = time.Now()
|
mw.StartTime = time.Now()
|
||||||
|
|||||||
@@ -509,6 +509,103 @@ func TestPruneExpiredMaintenanceWindows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetOverlappingMaintenanceWindows(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
site := models.SiteConfig{Name: "web", URL: "https://example.com", Interval: 30}
|
||||||
|
if err := s.AddSite(ctx, site); err != nil {
|
||||||
|
t.Fatalf("AddSite: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
active := models.MaintenanceWindow{
|
||||||
|
MonitorID: 1,
|
||||||
|
Title: "Deploy v2",
|
||||||
|
Type: "maintenance",
|
||||||
|
StartTime: now.Add(-30 * time.Minute),
|
||||||
|
EndTime: now.Add(30 * time.Minute),
|
||||||
|
}
|
||||||
|
if err := s.AddMaintenanceWindow(ctx, active); err != nil {
|
||||||
|
t.Fatalf("AddMaintenanceWindow: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ended := models.MaintenanceWindow{
|
||||||
|
MonitorID: 1,
|
||||||
|
Title: "Old deploy",
|
||||||
|
Type: "maintenance",
|
||||||
|
StartTime: now.Add(-3 * time.Hour),
|
||||||
|
EndTime: now.Add(-2 * time.Hour),
|
||||||
|
}
|
||||||
|
if err := s.AddMaintenanceWindow(ctx, ended); err != nil {
|
||||||
|
t.Fatalf("AddMaintenanceWindow: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("same monitor overlaps", func(t *testing.T) {
|
||||||
|
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, now.Add(1*time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(overlaps) != 1 {
|
||||||
|
t.Fatalf("expected 1 overlap, got %d", len(overlaps))
|
||||||
|
}
|
||||||
|
if overlaps[0].Title != "Deploy v2" {
|
||||||
|
t.Errorf("expected 'Deploy v2', got %q", overlaps[0].Title)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("different monitor no overlap", func(t *testing.T) {
|
||||||
|
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 99, now, now.Add(1*time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(overlaps) != 0 {
|
||||||
|
t.Errorf("expected 0 overlaps, got %d", len(overlaps))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("global window overlaps all", func(t *testing.T) {
|
||||||
|
global := models.MaintenanceWindow{
|
||||||
|
MonitorID: 0,
|
||||||
|
Title: "Global freeze",
|
||||||
|
Type: "maintenance",
|
||||||
|
StartTime: now.Add(-10 * time.Minute),
|
||||||
|
EndTime: now.Add(2 * time.Hour),
|
||||||
|
}
|
||||||
|
if err := s.AddMaintenanceWindow(ctx, global); err != nil {
|
||||||
|
t.Fatalf("AddMaintenanceWindow: %v", err)
|
||||||
|
}
|
||||||
|
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, now.Add(1*time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(overlaps) != 2 {
|
||||||
|
t.Errorf("expected 2 overlaps (specific + global), got %d", len(overlaps))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("indefinite window overlaps", func(t *testing.T) {
|
||||||
|
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, time.Time{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(overlaps) < 1 {
|
||||||
|
t.Error("expected at least 1 overlap for indefinite window")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ended window excluded", func(t *testing.T) {
|
||||||
|
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now.Add(-4*time.Hour), now.Add(-3*time.Hour))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(overlaps) != 0 {
|
||||||
|
t.Errorf("expected 0 overlaps for past range, got %d", len(overlaps))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ImportData must encrypt alert settings (like AddAlert/UpdateAlert) so a
|
// ImportData must encrypt alert settings (like AddAlert/UpdateAlert) so a
|
||||||
// restore with UPTOP_ENCRYPTION_KEY set never lands secrets in plaintext.
|
// restore with UPTOP_ENCRYPTION_KEY set never lands secrets in plaintext.
|
||||||
func TestImportData_EncryptsAlertSettings(t *testing.T) {
|
func TestImportData_EncryptsAlertSettings(t *testing.T) {
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ type Store interface {
|
|||||||
// Maintenance Windows
|
// Maintenance Windows
|
||||||
GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error)
|
GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error)
|
||||||
GetAllMaintenanceWindows(ctx context.Context, limit int) ([]models.MaintenanceWindow, error)
|
GetAllMaintenanceWindows(ctx context.Context, limit int) ([]models.MaintenanceWindow, error)
|
||||||
|
GetOverlappingMaintenanceWindows(ctx context.Context, monitorID int, startTime, endTime time.Time) ([]models.MaintenanceWindow, error)
|
||||||
AddMaintenanceWindow(ctx context.Context, mw models.MaintenanceWindow) error
|
AddMaintenanceWindow(ctx context.Context, mw models.MaintenanceWindow) error
|
||||||
EndMaintenanceWindow(ctx context.Context, id int) error
|
EndMaintenanceWindow(ctx context.Context, id int) error
|
||||||
DeleteMaintenanceWindow(ctx context.Context, id int) error
|
DeleteMaintenanceWindow(ctx context.Context, id int) error
|
||||||
|
|||||||
@@ -229,6 +229,10 @@ func (m *BaseMock) GetAllMaintenanceWindows(ctx context.Context, limit int) ([]m
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *BaseMock) GetOverlappingMaintenanceWindows(_ context.Context, _ int, _, _ time.Time) ([]models.MaintenanceWindow, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *BaseMock) AddMaintenanceWindow(_ context.Context, _ models.MaintenanceWindow) error {
|
func (m *BaseMock) AddMaintenanceWindow(_ context.Context, _ models.MaintenanceWindow) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,6 +256,11 @@ func (m *Model) submitMaintForm() tea.Cmd {
|
|||||||
st := m.store
|
st := m.store
|
||||||
m.state = stateDashboard
|
m.state = stateDashboard
|
||||||
return writeCmd("Add maintenance window", func() error {
|
return writeCmd("Add maintenance window", func() error {
|
||||||
return st.AddMaintenanceWindow(context.Background(), mw)
|
ctx := context.Background()
|
||||||
|
overlaps, _ := st.GetOverlappingMaintenanceWindows(ctx, mw.MonitorID, mw.StartTime, mw.EndTime)
|
||||||
|
if len(overlaps) > 0 {
|
||||||
|
_ = st.SaveLog(ctx, fmt.Sprintf("Overlap: new window %q overlaps with existing %q", mw.Title, overlaps[0].Title))
|
||||||
|
}
|
||||||
|
return st.AddMaintenanceWindow(ctx, mw)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user