feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
324
app/models/maintenance.go
Обычный файл
324
app/models/maintenance.go
Обычный файл
@@ -0,0 +1,324 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
"github.com/robfig/cron/v3"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/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
|
||||
}
|
||||
Ссылка в новой задаче
Block a user