Некоторые проверки не удались
CI / test (push) Successful in 2m5s
Docker / Build and publish worker image (push) Failing after 31s
325 строки
11 KiB
Go
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
|
|
}
|