package models import ( "fmt" "strings" "time" "github.com/lib/pq" "github.com/robfig/cron/v3" "gorm.io/gorm" "rocketgit.ru/rsmon/worker/app/models/concerns" ) const ( MaintenanceManual = "manual" MaintenanceSingle = "single" MaintenanceCron = "cron" MaintenanceRecurringInterval = "recurring-interval" MaintenanceRecurringWeekday = "recurring-weekday" MaintenanceRecurringDayOfMonth = "recurring-day-of-month" ) // Maintenance is account-owned planned downtime. Times are stored as UTC; // Timezone only defines how recurring wall-clock fields are interpreted. type Maintenance struct { concerns.Model AccountID int64 `gorm:"type:bigint REFERENCES accounts(id) ON DELETE CASCADE;not null;index" json:"account_id"` Account *Account `json:"-"` Title string `gorm:"size:200;not null" json:"title"` Description string `gorm:"type:text;not null;default:''" json:"description"` Strategy string `gorm:"size:32;not null" json:"strategy"` Cron string `gorm:"type:text;not null;default:''" json:"cron"` DurationSec int `gorm:"not null;default:0" json:"duration_sec"` StartDate *time.Time `json:"start_date,omitempty"` EndDate *time.Time `json:"end_date,omitempty"` StartTime string `gorm:"size:5;not null;default:''" json:"start_time"` EndTime string `gorm:"size:5;not null;default:''" json:"end_time"` Weekdays pq.Int64Array `gorm:"type:bigint[];not null;default:'{}'" json:"weekdays"` DaysOfMonth pq.StringArray `gorm:"type:text[];not null;default:'{}'" json:"days_of_month"` IntervalDay int `gorm:"not null;default:1" json:"interval_day"` Timezone string `gorm:"size:64;not null;default:'UTC'" json:"timezone"` Active bool `gorm:"not null;default:true" json:"active"` LastStartDate *time.Time `json:"last_start_date,omitempty"` LegacyStatusPageMaintenanceID *int64 `gorm:"uniqueIndex" json:"-"` ShowOnAllStatusPages bool `gorm:"not null;default:true" json:"show_on_all_status_pages"` Monitors []Monitor `gorm:"many2many:maintenance_monitors;constraint:OnDelete:CASCADE" json:"monitors,omitempty"` StatusPages []StatusPage `gorm:"many2many:maintenance_status_pages;constraint:OnDelete:CASCADE" json:"status_pages,omitempty"` concerns.Timestamped Audited DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` } func (Maintenance) TableName() string { return "maintenances" } func (m *Maintenance) location() (*time.Location, error) { if m.Timezone == "" || m.Timezone == "SAME_AS_SERVER" { return time.UTC, nil } return time.LoadLocation(m.Timezone) } func (m *Maintenance) generatedCron() (string, error) { if m.Strategy == MaintenanceCron { return m.Cron, nil } if m.Strategy == MaintenanceManual || m.Strategy == MaintenanceSingle { return "", nil } parts := strings.Split(m.StartTime, ":") if len(parts) != 2 { return "", fmt.Errorf("start_time must be HH:MM") } base := parts[1] + " " + parts[0] switch m.Strategy { case MaintenanceRecurringInterval: return "", nil case MaintenanceRecurringWeekday: if len(m.Weekdays) == 0 { return "", fmt.Errorf("at least one weekday is required") } values := make([]string, len(m.Weekdays)) for i, day := range m.Weekdays { if day < 0 || day > 6 { return "", fmt.Errorf("weekday must be 0 through 6") } values[i] = fmt.Sprint(day) } return base + " * * " + strings.Join(values, ","), nil case MaintenanceRecurringDayOfMonth: if len(m.DaysOfMonth) == 0 { return "", fmt.Errorf("at least one day of month is required") } values := make([]string, 0, len(m.DaysOfMonth)) for _, day := range m.DaysOfMonth { if day == "lastDay1" { values = append(values, "28-31") } else { values = append(values, day) } } return base + " " + strings.Join(values, ",") + " * *", nil default: return "", fmt.Errorf("unknown maintenance strategy %q", m.Strategy) } } // Validate normalizes generated schedules and rejects ambiguous or invalid // input before it can reach the scheduler. func (m *Maintenance) Validate() error { m.Title = strings.TrimSpace(m.Title) if m.Title == "" || len(m.Title) > 200 { return fmt.Errorf("title is required and must be at most 200 characters") } if _, err := m.location(); err != nil { return fmt.Errorf("invalid timezone: %w", err) } switch m.Strategy { case MaintenanceManual: return nil case MaintenanceSingle: if m.StartDate == nil || m.EndDate == nil || !m.EndDate.After(*m.StartDate) { return fmt.Errorf("single maintenance requires end_date after start_date") } m.DurationSec = int(m.EndDate.Sub(*m.StartDate).Seconds()) return nil case MaintenanceRecurringInterval: if m.DurationSec <= 0 || m.IntervalDay <= 0 || (m.IntervalDay > 1 && m.StartDate == nil) { return fmt.Errorf("recurring interval requires positive duration_sec and interval_day; intervals over one day require start_date") } if _, _, err := parseMaintenanceTime(m.StartTime); err != nil { return err } m.Cron = "" return nil case MaintenanceCron, MaintenanceRecurringWeekday, MaintenanceRecurringDayOfMonth: if m.DurationSec <= 0 { return fmt.Errorf("duration_sec must be positive") } cronText, err := m.generatedCron() if err != nil { return err } if _, err := cron.ParseStandard(cronText); err != nil { return fmt.Errorf("invalid cron: %w", err) } m.Cron = cronText return nil default: return fmt.Errorf("unknown maintenance strategy %q", m.Strategy) } } func (m *Maintenance) BeforeSave(_ *gorm.DB) error { return m.Validate() } // IsUnderMaintenance evaluates durable data only. This intentionally avoids // scheduler-owned state so a process restart and multiple web pods agree. func (m *Maintenance) IsUnderMaintenance(now time.Time) bool { if !m.Active { return false } if m.Strategy == MaintenanceManual { return true } if m.Strategy == MaintenanceSingle { return m.StartDate != nil && m.EndDate != nil && !now.Before(*m.StartDate) && now.Before(*m.EndDate) } if m.Strategy == MaintenanceRecurringInterval { return m.isUnderInterval(now) } if m.DurationSec <= 0 { return false } loc, err := m.location() if err != nil { return false } schedule, err := cron.ParseStandard(m.Cron) if err != nil { return false } // Ask cron for each candidate since the earliest possible active start. // Cron is minute-granular, hence the extra minute catches exact boundaries. from := now.In(loc).Add(-time.Duration(m.DurationSec)*time.Second - time.Minute) to := now.In(loc) for candidate := schedule.Next(from); !candidate.After(to); candidate = schedule.Next(candidate) { if !m.allowsRecurringCandidate(candidate.In(loc)) { continue } start := candidate.UTC() if !now.Before(start) && now.Before(start.Add(time.Duration(m.DurationSec)*time.Second)) { return true } } return false } func parseMaintenanceTime(value string) (int, int, error) { parsed, err := time.Parse("15:04", value) if err != nil { return 0, 0, fmt.Errorf("start_time must be HH:MM") } return parsed.Hour(), parsed.Minute(), nil } func (m *Maintenance) intervalStartOn(date time.Time, loc *time.Location) (time.Time, bool) { if m.IntervalDay <= 0 { return time.Time{}, false } hour, minute, err := parseMaintenanceTime(m.StartTime) if err != nil { return time.Time{}, false } if m.IntervalDay == 1 && m.StartDate == nil { return time.Date(date.Year(), date.Month(), date.Day(), hour, minute, 0, 0, loc), true } if m.StartDate == nil { return time.Time{}, false } anchor := m.StartDate.In(loc) anchorDay := time.Date(anchor.Year(), anchor.Month(), anchor.Day(), 0, 0, 0, 0, loc) candidateDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, loc) // Compare civil dates rather than elapsed hours: a local day can be 23 or // 25 hours when the maintenance timezone crosses a DST boundary. days := civilDaysBetween(anchorDay, candidateDay) if days < 0 || days%m.IntervalDay != 0 { return time.Time{}, false } return time.Date(date.Year(), date.Month(), date.Day(), hour, minute, 0, 0, loc), true } func civilDaysBetween(from, to time.Time) int { fromDay := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC) toDay := time.Date(to.Year(), to.Month(), to.Day(), 0, 0, 0, 0, time.UTC) return int(toDay.Sub(fromDay) / (24 * time.Hour)) } func (m *Maintenance) isUnderInterval(now time.Time) bool { loc, err := m.location() if err != nil || m.DurationSec <= 0 { return false } localNow := now.In(loc) for day := 0; day <= int(time.Duration(m.DurationSec)/24/time.Hour)+1; day++ { start, ok := m.intervalStartOn(localNow.AddDate(0, 0, -day), loc) if ok && !now.Before(start.UTC()) && now.Before(start.UTC().Add(time.Duration(m.DurationSec)*time.Second)) { return true } } return false } // robfig/cron cannot express "last day". The generated 28-31 range is only // a candidate generator; this final predicate makes lastDay1 exact. func (m *Maintenance) allowsRecurringCandidate(candidate time.Time) bool { if m.Strategy != MaintenanceRecurringDayOfMonth { return true } lastDay := candidate.AddDate(0, 0, 1).Month() != candidate.Month() for _, value := range m.DaysOfMonth { if value == "lastDay1" && lastDay { return true } if value == fmt.Sprint(candidate.Day()) { return true } } return false } func (m *Maintenance) NextRun(now time.Time) *time.Time { if !m.Active || m.Strategy == MaintenanceManual { return nil } if m.Strategy == MaintenanceSingle { if m.StartDate != nil && m.StartDate.After(now) { return m.StartDate } return nil } if m.Strategy == MaintenanceRecurringInterval { loc, err := m.location() if err != nil { return nil } localNow := now.In(loc) for day := 0; day <= m.IntervalDay; day++ { if next, ok := m.intervalStartOn(localNow.AddDate(0, 0, day), loc); ok && next.After(localNow) { result := next.UTC() return &result } } return nil } loc, err := m.location() if err != nil { return nil } s, err := cron.ParseStandard(m.Cron) if err != nil { return nil } for candidate := s.Next(now.In(loc)); ; candidate = s.Next(candidate) { if m.allowsRecurringCandidate(candidate.In(loc)) { next := candidate.UTC() return &next } } } // MonitorUnderMaintenance is the notifier/public-page lookup. func MonitorUnderMaintenance(monitorID int64, now time.Time) (bool, error) { var rows []Maintenance err := DB().Joins("JOIN maintenance_monitors mm ON mm.maintenance_id = maintenances.id").Where("mm.monitor_id = ? AND maintenances.active = TRUE", monitorID).Find(&rows).Error if err != nil { return false, err } for i := range rows { if rows[i].IsUnderMaintenance(now) { return true, nil } } return false, nil }