SOGOMS v1.0.3 - Admin UI, Cron, Config reload
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>
This commit is contained in:
238
internal/cron/scheduler.go
Normal file
238
internal/cron/scheduler.go
Normal file
@@ -0,0 +1,238 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user