Netpulse_SasS/server/internal/cronx/cron.go
byrsapty c28852df66
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run
Syslog: транспорт до сервера і тригер позачергового бекапу
Приймач на зонді лежав без діла — тепер під'єднаний. Окремий стрім
StreamLogs, а не контрольний канал: сплеск логів під час аварії не має
заважати heartbeat і командам.

Хост зіставляється за адресою джерела на зонді: у сервера немає
контексту мережі клієнта, а один приватний діапазон трапляється в
десятках кабінетів. Невідома адреса не привід викинути подію.

Подія, що збіглася зі зразком у ncm.device_policies.syslog_match,
ставить позачерговий збір конфігу. Типовий зразок покриває Cisco,
HP/Huawei, Juniper і MikroTik — навмисно широкий: зайвий бекап коштує
секунд, пропущений — цілої зміни.

Заразом увесь репозиторій прогнано через gofmt: CI, написаний два
кроки тому, перевіряє це і впав би на 29 файлах із порушеннями,
накопиченими за весь проєкт.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 15:20:32 +03:00

225 lines
7.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package cronx — розбір розкладу у форматі cron і обчислення
// наступного запуску.
//
// Власна реалізація на п'ять полів. Бібліотеки вміють ще й секунди,
// роки, «@every 5m» і власні часові пояси — а тут потрібне рівно те,
// що людина пише в полі «розклад бекапу», і жодного способу
// прострелити собі ногу нестандартним синтаксисом.
package cronx
import (
"fmt"
"strconv"
"strings"
"time"
)
// Schedule — розібраний розклад.
//
// Кожне поле — множина дозволених значень. Бітова маска замість
// переліку: перевірка «чи підходить хвилина» стає одним зсувом, а
// шукати наступний запуск доводиться перебором хвилин.
type Schedule struct {
minute uint64 // 0..59
hour uint64 // 0..23
dom uint64 // 1..31
month uint64 // 1..12
dow uint64 // 0..6, неділя = 0
// domRestricted і dowRestricted кажуть, чи задане поле явно.
// Потрібні для правила cron: якщо обидва задані, збіг за будь-яким
// із них уже підходить. Це не примха, а спосіб записати «щоп'ятниці
// та 1-го числа» одним рядком.
domRestricted bool
dowRestricted bool
}
type fieldSpec struct {
min, max int
names map[string]int
}
var (
minuteSpec = fieldSpec{0, 59, nil}
hourSpec = fieldSpec{0, 23, nil}
domSpec = fieldSpec{1, 31, nil}
monthSpec = fieldSpec{1, 12, map[string]int{
"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
"jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12,
}}
dowSpec = fieldSpec{0, 6, map[string]int{
"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6,
}}
)
// Parse розбирає «хвилина година день місяць день_тижня».
func Parse(expr string) (*Schedule, error) {
fields := strings.Fields(strings.TrimSpace(expr))
if len(fields) != 5 {
return nil, fmt.Errorf("очікується п'ять полів, отримано %d", len(fields))
}
s := &Schedule{}
var err error
if s.minute, err = parseField(fields[0], minuteSpec); err != nil {
return nil, fmt.Errorf("хвилини: %w", err)
}
if s.hour, err = parseField(fields[1], hourSpec); err != nil {
return nil, fmt.Errorf("години: %w", err)
}
if s.dom, err = parseField(fields[2], domSpec); err != nil {
return nil, fmt.Errorf("день місяця: %w", err)
}
if s.month, err = parseField(fields[3], monthSpec); err != nil {
return nil, fmt.Errorf("місяць: %w", err)
}
if s.dow, err = parseField(fields[4], dowSpec); err != nil {
return nil, fmt.Errorf("день тижня: %w", err)
}
s.domRestricted = fields[2] != "*"
s.dowRestricted = fields[4] != "*"
return s, nil
}
func parseField(f string, spec fieldSpec) (uint64, error) {
var mask uint64
for _, part := range strings.Split(f, ",") {
m, err := parsePart(strings.TrimSpace(part), spec)
if err != nil {
return 0, err
}
mask |= m
}
if mask == 0 {
return 0, fmt.Errorf("порожній набір значень")
}
return mask, nil
}
func parsePart(p string, spec fieldSpec) (uint64, error) {
if p == "" {
return 0, fmt.Errorf("порожня частина")
}
step := 1
if base, stepStr, ok := strings.Cut(p, "/"); ok {
n, err := strconv.Atoi(stepStr)
if err != nil || n <= 0 {
return 0, fmt.Errorf("некоректний крок %q", stepStr)
}
step = n
p = base
}
lo, hi := spec.min, spec.max
switch {
case p == "*":
// уже lo..hi
case strings.Contains(p, "-"):
a, b, _ := strings.Cut(p, "-")
var err error
if lo, err = parseValue(a, spec); err != nil {
return 0, err
}
if hi, err = parseValue(b, spec); err != nil {
return 0, err
}
if lo > hi {
return 0, fmt.Errorf("діапазон %q іде навспак", p)
}
default:
v, err := parseValue(p, spec)
if err != nil {
return 0, err
}
lo, hi = v, v
// «5/10» без діапазону означає «від 5 і далі кроком», як у
// класичному cron.
if step > 1 {
hi = spec.max
}
}
var mask uint64
for v := lo; v <= hi; v += step {
mask |= 1 << uint(v)
}
return mask, nil
}
func parseValue(v string, spec fieldSpec) (int, error) {
v = strings.TrimSpace(v)
if spec.names != nil {
if n, ok := spec.names[strings.ToLower(v)]; ok {
return n, nil
}
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("не число й не назва: %q", v)
}
// Неділя як 7 — поширений запис, і відхиляти його було б
// причепливістю.
if spec.max == 6 && n == 7 {
n = 0
}
if n < spec.min || n > spec.max {
return 0, fmt.Errorf("%d поза межами %d..%d", n, spec.min, spec.max)
}
return n, nil
}
// Next повертає найближчий момент після from, який підходить розкладу.
//
// Перебором по хвилинах із обмеженням у чотири роки: високосний рік і
// «29 лютого» роблять аналітичне обчислення помітно складнішим за
// вигоду, а перебір знаходить відповідь за мілісекунди навіть у
// найгіршому випадку.
func (s *Schedule) Next(from time.Time) (time.Time, error) {
t := from.Truncate(time.Minute).Add(time.Minute)
limit := from.AddDate(4, 0, 0)
for t.Before(limit) {
if s.month&(1<<uint(t.Month())) == 0 {
// Місяць не підходить — стрибаємо на початок наступного.
t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()).AddDate(0, 1, 0)
continue
}
if !s.dayMatches(t) {
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, 1)
continue
}
if s.hour&(1<<uint(t.Hour())) == 0 {
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location()).
Add(time.Hour)
continue
}
if s.minute&(1<<uint(t.Minute())) == 0 {
t = t.Add(time.Minute)
continue
}
return t, nil
}
return time.Time{}, fmt.Errorf("розклад не має жодного запуску в найближчі роки")
}
// dayMatches реалізує правило cron про день місяця й день тижня.
//
// Якщо задане лише одне з полів — діє воно. Якщо обидва — підходить
// збіг за будь-яким. Це виглядає нелогічно, доки не спробуєш записати
// «щоп'ятниці та 1-го числа»: іншого способу немає.
func (s *Schedule) dayMatches(t time.Time) bool {
domOK := s.dom&(1<<uint(t.Day())) != 0
dowOK := s.dow&(1<<uint(t.Weekday())) != 0
switch {
case s.domRestricted && s.dowRestricted:
return domOK || dowOK
case s.domRestricted:
return domOK
case s.dowRestricted:
return dowOK
default:
return true
}
}