Files
worker/app/models/maintenance.go
Gleb Tv 2c884c5612
Некоторые проверки не удались
CI / test (push) Successful in 2m5s
Docker / Build and publish worker image (push) Failing after 31s
refactor: adopt worker module path
2026-07-13 17:56:12 +03:00

325 строки
11 KiB
Go

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
}