Приймач на зонді лежав без діла — тепер під'єднаний. Окремий стрім StreamLogs, а не контрольний канал: сплеск логів під час аварії не має заважати heartbeat і командам. Хост зіставляється за адресою джерела на зонді: у сервера немає контексту мережі клієнта, а один приватний діапазон трапляється в десятках кабінетів. Невідома адреса не привід викинути подію. Подія, що збіглася зі зразком у ncm.device_policies.syslog_match, ставить позачерговий збір конфігу. Типовий зразок покриває Cisco, HP/Huawei, Juniper і MikroTik — навмисно широкий: зайвий бекап коштує секунд, пропущений — цілої зміни. Заразом увесь репозиторій прогнано через gofmt: CI, написаний два кроки тому, перевіряє це і впав би на 29 файлах із порушеннями, накопиченими за весь проєкт. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
225 lines
7.3 KiB
Go
225 lines
7.3 KiB
Go
// 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
|
||
}
|
||
}
|