Phase 13 : sogoms-cron
- Jobs planifiés avec schedule cron standard
- Types: query_email, http, service
- Actions: list, trigger, status
Phase 16 : Réorganisation config/apps/{app}/
- Tous les fichiers d'une app dans un seul dossier
- Migration prokov vers nouvelle structure
Phase 17 : sogoms-admin
- Interface web d'administration (Go templates + htmx)
- Auth sessions cookies signées HMAC-SHA256
- Rôles super_admin / app_admin avec permissions
Phase 19 : Création d'app via Admin UI
- Formulaire création app avec config DB/auth
- Bouton "Scanner la base" : introspection + schema.yaml
- Rechargement automatique sogoway via SIGHUP
Infrastructure :
- sogoctl : socket de contrôle /run/sogoctl.sock
- sogoway : reload config sur SIGHUP sans restart
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
239 lines
5.9 KiB
Go
239 lines
5.9 KiB
Go
// Package cron fournit un scheduler pour les tâches planifiées.
|
|
// Supporte le format cron standard (* * * * *) avec timezone.
|
|
package cron
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Schedule représente une expression cron parsée.
|
|
type Schedule struct {
|
|
Minute []int // 0-59
|
|
Hour []int // 0-23
|
|
DayOfMonth []int // 1-31
|
|
Month []int // 1-12
|
|
DayOfWeek []int // 0-6 (0=dimanche)
|
|
Location *time.Location
|
|
}
|
|
|
|
// ParseSchedule parse une expression cron standard.
|
|
// Format: "minute hour day month weekday"
|
|
// Exemples:
|
|
// - "0 8 * * 1-5" : 8h00 du lundi au vendredi
|
|
// - "*/15 * * * *" : toutes les 15 minutes
|
|
// - "0 9 1 * *" : 9h00 le premier de chaque mois
|
|
func ParseSchedule(expr string, location *time.Location) (*Schedule, error) {
|
|
if location == nil {
|
|
location = time.UTC
|
|
}
|
|
|
|
parts := strings.Fields(expr)
|
|
if len(parts) != 5 {
|
|
return nil, fmt.Errorf("invalid cron expression: expected 5 fields, got %d", len(parts))
|
|
}
|
|
|
|
s := &Schedule{Location: location}
|
|
var err error
|
|
|
|
// Minute (0-59)
|
|
s.Minute, err = parseField(parts[0], 0, 59)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("minute: %w", err)
|
|
}
|
|
|
|
// Hour (0-23)
|
|
s.Hour, err = parseField(parts[1], 0, 23)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hour: %w", err)
|
|
}
|
|
|
|
// Day of month (1-31)
|
|
s.DayOfMonth, err = parseField(parts[2], 1, 31)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("day of month: %w", err)
|
|
}
|
|
|
|
// Month (1-12)
|
|
s.Month, err = parseField(parts[3], 1, 12)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("month: %w", err)
|
|
}
|
|
|
|
// Day of week (0-6, 0=Sunday)
|
|
s.DayOfWeek, err = parseField(parts[4], 0, 6)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("day of week: %w", err)
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// parseField parse un champ cron individuel.
|
|
// Supporte: *, */n, n, n-m, n,m,o
|
|
func parseField(field string, min, max int) ([]int, error) {
|
|
var values []int
|
|
|
|
// Gérer les listes (ex: "1,3,5")
|
|
for _, part := range strings.Split(field, ",") {
|
|
part = strings.TrimSpace(part)
|
|
|
|
// Step (*/n ou n-m/s)
|
|
step := 1
|
|
if idx := strings.Index(part, "/"); idx != -1 {
|
|
stepStr := part[idx+1:]
|
|
var err error
|
|
step, err = strconv.Atoi(stepStr)
|
|
if err != nil || step < 1 {
|
|
return nil, fmt.Errorf("invalid step: %s", stepStr)
|
|
}
|
|
part = part[:idx]
|
|
}
|
|
|
|
// Range ou valeur
|
|
var rangeMin, rangeMax int
|
|
if part == "*" {
|
|
rangeMin, rangeMax = min, max
|
|
} else if idx := strings.Index(part, "-"); idx != -1 {
|
|
var err error
|
|
rangeMin, err = strconv.Atoi(part[:idx])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid range start: %s", part[:idx])
|
|
}
|
|
rangeMax, err = strconv.Atoi(part[idx+1:])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid range end: %s", part[idx+1:])
|
|
}
|
|
} else {
|
|
val, err := strconv.Atoi(part)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid value: %s", part)
|
|
}
|
|
rangeMin, rangeMax = val, val
|
|
}
|
|
|
|
// Valider les bornes
|
|
if rangeMin < min || rangeMax > max || rangeMin > rangeMax {
|
|
return nil, fmt.Errorf("value out of range [%d-%d]: %d-%d", min, max, rangeMin, rangeMax)
|
|
}
|
|
|
|
// Générer les valeurs
|
|
for v := rangeMin; v <= rangeMax; v += step {
|
|
values = append(values, v)
|
|
}
|
|
}
|
|
|
|
if len(values) == 0 {
|
|
return nil, fmt.Errorf("no values")
|
|
}
|
|
|
|
return values, nil
|
|
}
|
|
|
|
// Next calcule la prochaine exécution après 'from'.
|
|
func (s *Schedule) Next(from time.Time) time.Time {
|
|
// Convertir dans la timezone du schedule
|
|
t := from.In(s.Location)
|
|
|
|
// Commencer à la minute suivante
|
|
t = t.Truncate(time.Minute).Add(time.Minute)
|
|
|
|
// Limite de recherche (1 an)
|
|
limit := t.Add(366 * 24 * time.Hour)
|
|
|
|
for t.Before(limit) {
|
|
// Vérifier le mois
|
|
if !contains(s.Month, int(t.Month())) {
|
|
// Passer au premier jour du mois suivant
|
|
t = time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, s.Location)
|
|
continue
|
|
}
|
|
|
|
// Vérifier le jour du mois ET le jour de la semaine
|
|
// En cron standard, si les deux sont spécifiés (non-*), c'est un OR
|
|
dayMatch := contains(s.DayOfMonth, t.Day())
|
|
weekdayMatch := contains(s.DayOfWeek, int(t.Weekday()))
|
|
|
|
// Si les deux champs sont "*", les deux sont vrais
|
|
// Si l'un est spécifié et pas l'autre, seul celui spécifié compte
|
|
// Si les deux sont spécifiés, c'est OR (comportement cron standard)
|
|
bothWildcard := len(s.DayOfMonth) == 31 && len(s.DayOfWeek) == 7
|
|
if bothWildcard {
|
|
// Les deux sont *, donc on accepte tous les jours
|
|
} else if len(s.DayOfMonth) == 31 {
|
|
// Jour du mois est *, seul le jour de semaine compte
|
|
if !weekdayMatch {
|
|
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, s.Location)
|
|
continue
|
|
}
|
|
} else if len(s.DayOfWeek) == 7 {
|
|
// Jour de semaine est *, seul le jour du mois compte
|
|
if !dayMatch {
|
|
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, s.Location)
|
|
continue
|
|
}
|
|
} else {
|
|
// Les deux sont spécifiés : OR
|
|
if !dayMatch && !weekdayMatch {
|
|
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, s.Location)
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Vérifier l'heure
|
|
if !contains(s.Hour, t.Hour()) {
|
|
// Passer à l'heure suivante
|
|
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour()+1, 0, 0, 0, s.Location)
|
|
continue
|
|
}
|
|
|
|
// Vérifier la minute
|
|
if !contains(s.Minute, t.Minute()) {
|
|
// Passer à la minute suivante
|
|
t = t.Add(time.Minute)
|
|
continue
|
|
}
|
|
|
|
// Trouvé !
|
|
return t
|
|
}
|
|
|
|
// Pas trouvé dans l'année, retourner zero
|
|
return time.Time{}
|
|
}
|
|
|
|
// NextN retourne les N prochaines exécutions.
|
|
func (s *Schedule) NextN(from time.Time, n int) []time.Time {
|
|
var times []time.Time
|
|
t := from
|
|
for i := 0; i < n; i++ {
|
|
next := s.Next(t)
|
|
if next.IsZero() {
|
|
break
|
|
}
|
|
times = append(times, next)
|
|
t = next
|
|
}
|
|
return times
|
|
}
|
|
|
|
// contains vérifie si une valeur est dans une liste.
|
|
func contains(list []int, val int) bool {
|
|
for _, v := range list {
|
|
if v == val {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// LoadLocation charge une timezone par nom (ex: "Europe/Paris").
|
|
func LoadLocation(name string) (*time.Location, error) {
|
|
if name == "" || name == "UTC" {
|
|
return time.UTC, nil
|
|
}
|
|
return time.LoadLocation(name)
|
|
}
|