No One Can Stop Me Now
This commit is contained in:
280
services/setec-manager/internal/scheduler/cron.go
Normal file
280
services/setec-manager/internal/scheduler/cron.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CronExpr represents a parsed 5-field cron expression.
|
||||
// Each field is expanded into a sorted slice of valid integer values.
|
||||
type CronExpr struct {
|
||||
Minutes []int // 0-59
|
||||
Hours []int // 0-23
|
||||
DaysOfMonth []int // 1-31
|
||||
Months []int // 1-12
|
||||
DaysOfWeek []int // 0-6 (0 = Sunday)
|
||||
}
|
||||
|
||||
// fieldBounds defines the min/max for each cron field.
|
||||
var fieldBounds = [5][2]int{
|
||||
{0, 59}, // minute
|
||||
{0, 23}, // hour
|
||||
{1, 31}, // day of month
|
||||
{1, 12}, // month
|
||||
{0, 6}, // day of week
|
||||
}
|
||||
|
||||
// ParseCron parses a standard 5-field cron expression into a CronExpr.
|
||||
//
|
||||
// Supported syntax per field:
|
||||
// - * all values in range
|
||||
// - N single number
|
||||
// - N-M range from N to M inclusive
|
||||
// - N-M/S range with step S
|
||||
// - */S full range with step S
|
||||
// - N,M,O list of values (each element can be a number or range)
|
||||
func ParseCron(expr string) (*CronExpr, error) {
|
||||
fields := strings.Fields(strings.TrimSpace(expr))
|
||||
if len(fields) != 5 {
|
||||
return nil, fmt.Errorf("cron: expected 5 fields, got %d in %q", len(fields), expr)
|
||||
}
|
||||
|
||||
ce := &CronExpr{}
|
||||
targets := []*[]int{&ce.Minutes, &ce.Hours, &ce.DaysOfMonth, &ce.Months, &ce.DaysOfWeek}
|
||||
|
||||
for i, field := range fields {
|
||||
vals, err := parseField(field, fieldBounds[i][0], fieldBounds[i][1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cron field %d (%q): %w", i+1, field, err)
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
return nil, fmt.Errorf("cron field %d (%q): produced no values", i+1, field)
|
||||
}
|
||||
*targets[i] = vals
|
||||
}
|
||||
|
||||
return ce, nil
|
||||
}
|
||||
|
||||
// parseField parses a single cron field into a sorted slice of ints.
|
||||
func parseField(field string, min, max int) ([]int, error) {
|
||||
// Handle lists: "1,3,5" or "1-3,7,10-12"
|
||||
parts := strings.Split(field, ",")
|
||||
seen := make(map[int]bool)
|
||||
|
||||
for _, part := range parts {
|
||||
vals, err := parsePart(part, min, max)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, v := range vals {
|
||||
seen[v] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Collect and sort.
|
||||
result := make([]int, 0, len(seen))
|
||||
for v := range seen {
|
||||
result = append(result, v)
|
||||
}
|
||||
sortInts(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parsePart parses a single element that may be *, a number, a range, or have a step.
|
||||
func parsePart(part string, min, max int) ([]int, error) {
|
||||
// Split on "/" for step.
|
||||
var stepStr string
|
||||
base := part
|
||||
if idx := strings.Index(part, "/"); idx >= 0 {
|
||||
base = part[:idx]
|
||||
stepStr = part[idx+1:]
|
||||
}
|
||||
|
||||
// Determine the range.
|
||||
var lo, hi int
|
||||
if base == "*" {
|
||||
lo, hi = min, max
|
||||
} else if idx := strings.Index(base, "-"); idx >= 0 {
|
||||
var err error
|
||||
lo, err = strconv.Atoi(base[:idx])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid number %q: %w", base[:idx], err)
|
||||
}
|
||||
hi, err = strconv.Atoi(base[idx+1:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid number %q: %w", base[idx+1:], err)
|
||||
}
|
||||
} else {
|
||||
n, err := strconv.Atoi(base)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid number %q: %w", base, err)
|
||||
}
|
||||
if stepStr == "" {
|
||||
// Single value, no step.
|
||||
if n < min || n > max {
|
||||
return nil, fmt.Errorf("value %d out of range [%d, %d]", n, min, max)
|
||||
}
|
||||
return []int{n}, nil
|
||||
}
|
||||
// e.g., "5/10" means starting at 5, step 10, up to max.
|
||||
lo, hi = n, max
|
||||
}
|
||||
|
||||
// Validate bounds.
|
||||
if lo < min || lo > max {
|
||||
return nil, fmt.Errorf("value %d out of range [%d, %d]", lo, min, max)
|
||||
}
|
||||
if hi < min || hi > max {
|
||||
return nil, fmt.Errorf("value %d out of range [%d, %d]", hi, min, max)
|
||||
}
|
||||
if lo > hi {
|
||||
return nil, fmt.Errorf("range start %d > end %d", lo, hi)
|
||||
}
|
||||
|
||||
step := 1
|
||||
if stepStr != "" {
|
||||
var err error
|
||||
step, err = strconv.Atoi(stepStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid step %q: %w", stepStr, err)
|
||||
}
|
||||
if step < 1 {
|
||||
return nil, fmt.Errorf("step must be >= 1, got %d", step)
|
||||
}
|
||||
}
|
||||
|
||||
var vals []int
|
||||
for v := lo; v <= hi; v += step {
|
||||
vals = append(vals, v)
|
||||
}
|
||||
return vals, nil
|
||||
}
|
||||
|
||||
// NextRun computes the next run time for a cron expression after the given time.
|
||||
// It searches up to 2 years ahead before giving up.
|
||||
func NextRun(schedule string, from time.Time) (time.Time, error) {
|
||||
ce, err := ParseCron(schedule)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return ce.Next(from)
|
||||
}
|
||||
|
||||
// Next finds the earliest time after "from" that matches the cron expression.
|
||||
func (ce *CronExpr) Next(from time.Time) (time.Time, error) {
|
||||
// Start from the next whole minute.
|
||||
t := from.Truncate(time.Minute).Add(time.Minute)
|
||||
|
||||
// Search limit: 2 years of minutes (~1,051,200). We iterate by
|
||||
// advancing fields intelligently rather than minute-by-minute.
|
||||
deadline := t.Add(2 * 365 * 24 * time.Hour)
|
||||
|
||||
for t.Before(deadline) {
|
||||
// Check month.
|
||||
if !contains(ce.Months, int(t.Month())) {
|
||||
// Advance to next valid month.
|
||||
t = advanceMonth(t, ce.Months)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check day of month.
|
||||
dom := t.Day()
|
||||
domOk := contains(ce.DaysOfMonth, dom)
|
||||
dowOk := contains(ce.DaysOfWeek, int(t.Weekday()))
|
||||
if !domOk || !dowOk {
|
||||
// Advance one day.
|
||||
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, t.Location())
|
||||
continue
|
||||
}
|
||||
|
||||
// Check hour.
|
||||
if !contains(ce.Hours, t.Hour()) {
|
||||
// Advance to next valid hour today.
|
||||
nextH := nextVal(ce.Hours, t.Hour())
|
||||
if nextH == -1 {
|
||||
// No more valid hours today, go to next day.
|
||||
t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, t.Location())
|
||||
} else {
|
||||
t = time.Date(t.Year(), t.Month(), t.Day(), nextH, 0, 0, 0, t.Location())
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check minute.
|
||||
if !contains(ce.Minutes, t.Minute()) {
|
||||
nextM := nextVal(ce.Minutes, t.Minute())
|
||||
if nextM == -1 {
|
||||
// No more valid minutes this hour, advance hour.
|
||||
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour()+1, 0, 0, 0, t.Location())
|
||||
} else {
|
||||
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), nextM, 0, 0, t.Location())
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// All fields match.
|
||||
return t, nil
|
||||
}
|
||||
|
||||
return time.Time{}, fmt.Errorf("cron: no matching time found within 2 years for %q", ce.String())
|
||||
}
|
||||
|
||||
// String reconstructs a human-readable representation of the cron expression.
|
||||
func (ce *CronExpr) String() string {
|
||||
return fmt.Sprintf("%v %v %v %v %v",
|
||||
ce.Minutes, ce.Hours, ce.DaysOfMonth, ce.Months, ce.DaysOfWeek)
|
||||
}
|
||||
|
||||
// contains checks if val is in the sorted slice.
|
||||
func contains(vals []int, val int) bool {
|
||||
for _, v := range vals {
|
||||
if v == val {
|
||||
return true
|
||||
}
|
||||
if v > val {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// nextVal returns the smallest value in vals that is > current, or -1.
|
||||
func nextVal(vals []int, current int) int {
|
||||
for _, v := range vals {
|
||||
if v > current {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// advanceMonth jumps to day 1, hour 0, minute 0 of the next valid month.
|
||||
func advanceMonth(t time.Time, months []int) time.Time {
|
||||
cur := int(t.Month())
|
||||
year := t.Year()
|
||||
|
||||
// Find next valid month in this year.
|
||||
for _, m := range months {
|
||||
if m > cur {
|
||||
return time.Date(year, time.Month(m), 1, 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
}
|
||||
// Wrap to first valid month of next year.
|
||||
return time.Date(year+1, time.Month(months[0]), 1, 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
|
||||
// sortInts performs an insertion sort on a small slice.
|
||||
func sortInts(a []int) {
|
||||
for i := 1; i < len(a); i++ {
|
||||
key := a[i]
|
||||
j := i - 1
|
||||
for j >= 0 && a[j] > key {
|
||||
a[j+1] = a[j]
|
||||
j--
|
||||
}
|
||||
a[j+1] = key
|
||||
}
|
||||
}
|
||||
279
services/setec-manager/internal/scheduler/scheduler.go
Normal file
279
services/setec-manager/internal/scheduler/scheduler.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"setec-manager/internal/db"
|
||||
)
|
||||
|
||||
// Job type constants.
|
||||
const (
|
||||
JobSSLRenew = "ssl_renew"
|
||||
JobBackup = "backup"
|
||||
JobGitPull = "git_pull"
|
||||
JobRestart = "restart"
|
||||
JobCleanup = "cleanup"
|
||||
)
|
||||
|
||||
// Job represents a scheduled job stored in the cron_jobs table.
|
||||
type Job struct {
|
||||
ID int64 `json:"id"`
|
||||
SiteID *int64 `json:"site_id"`
|
||||
JobType string `json:"job_type"`
|
||||
Schedule string `json:"schedule"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastRun *time.Time `json:"last_run"`
|
||||
NextRun *time.Time `json:"next_run"`
|
||||
}
|
||||
|
||||
// HandlerFunc is the signature for job handler functions.
|
||||
// siteID may be nil for global jobs (e.g., cleanup).
|
||||
type HandlerFunc func(siteID *int64) error
|
||||
|
||||
// Scheduler manages cron-like scheduled jobs backed by a SQLite database.
|
||||
type Scheduler struct {
|
||||
db *db.DB
|
||||
handlers map[string]HandlerFunc
|
||||
mu sync.RWMutex
|
||||
stop chan struct{}
|
||||
running bool
|
||||
}
|
||||
|
||||
// New creates a new Scheduler attached to the given database.
|
||||
func New(database *db.DB) *Scheduler {
|
||||
return &Scheduler{
|
||||
db: database,
|
||||
handlers: make(map[string]HandlerFunc),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterHandler registers a function to handle a given job type.
|
||||
// Must be called before Start.
|
||||
func (s *Scheduler) RegisterHandler(jobType string, fn HandlerFunc) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.handlers[jobType] = fn
|
||||
log.Printf("[scheduler] registered handler for job type %q", jobType)
|
||||
}
|
||||
|
||||
// Start begins the scheduler's ticker goroutine that fires every minute.
|
||||
func (s *Scheduler) Start() {
|
||||
s.mu.Lock()
|
||||
if s.running {
|
||||
s.mu.Unlock()
|
||||
log.Printf("[scheduler] already running")
|
||||
return
|
||||
}
|
||||
s.running = true
|
||||
s.stop = make(chan struct{})
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Printf("[scheduler] starting — checking for due jobs every 60s")
|
||||
go s.loop()
|
||||
}
|
||||
|
||||
// Stop shuts down the scheduler ticker.
|
||||
func (s *Scheduler) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.running {
|
||||
return
|
||||
}
|
||||
close(s.stop)
|
||||
s.running = false
|
||||
log.Printf("[scheduler] stopped")
|
||||
}
|
||||
|
||||
// loop runs the main ticker. It fires immediately on start, then every minute.
|
||||
func (s *Scheduler) loop() {
|
||||
// Run once immediately on start.
|
||||
s.tick()
|
||||
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.tick()
|
||||
case <-s.stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tick queries for all enabled jobs whose next_run <= now and executes them.
|
||||
func (s *Scheduler) tick() {
|
||||
now := time.Now().UTC()
|
||||
|
||||
rows, err := s.db.Conn().Query(`
|
||||
SELECT id, site_id, job_type, schedule, enabled, last_run, next_run
|
||||
FROM cron_jobs
|
||||
WHERE enabled = TRUE AND next_run IS NOT NULL AND next_run <= ?
|
||||
ORDER BY next_run ASC`, now)
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] error querying due jobs: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var due []Job
|
||||
for rows.Next() {
|
||||
var j Job
|
||||
var siteID sql.NullInt64
|
||||
var lastRun, nextRun sql.NullTime
|
||||
if err := rows.Scan(&j.ID, &siteID, &j.JobType, &j.Schedule, &j.Enabled, &lastRun, &nextRun); err != nil {
|
||||
log.Printf("[scheduler] error scanning job row: %v", err)
|
||||
continue
|
||||
}
|
||||
if siteID.Valid {
|
||||
id := siteID.Int64
|
||||
j.SiteID = &id
|
||||
}
|
||||
if lastRun.Valid {
|
||||
j.LastRun = &lastRun.Time
|
||||
}
|
||||
if nextRun.Valid {
|
||||
j.NextRun = &nextRun.Time
|
||||
}
|
||||
due = append(due, j)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
if len(due) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[scheduler] %d job(s) due", len(due))
|
||||
|
||||
for _, job := range due {
|
||||
s.executeJob(job, now)
|
||||
}
|
||||
}
|
||||
|
||||
// executeJob runs a single job's handler and updates the database.
|
||||
func (s *Scheduler) executeJob(job Job, now time.Time) {
|
||||
s.mu.RLock()
|
||||
handler, ok := s.handlers[job.JobType]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
log.Printf("[scheduler] no handler for job type %q (job %d), skipping", job.JobType, job.ID)
|
||||
// Still advance next_run so we don't re-fire every minute.
|
||||
s.advanceJob(job, now)
|
||||
return
|
||||
}
|
||||
|
||||
siteLabel := "global"
|
||||
if job.SiteID != nil {
|
||||
siteLabel = fmt.Sprintf("site %d", *job.SiteID)
|
||||
}
|
||||
log.Printf("[scheduler] executing job %d: type=%s %s schedule=%s", job.ID, job.JobType, siteLabel, job.Schedule)
|
||||
|
||||
if err := handler(job.SiteID); err != nil {
|
||||
log.Printf("[scheduler] job %d (%s) failed: %v", job.ID, job.JobType, err)
|
||||
} else {
|
||||
log.Printf("[scheduler] job %d (%s) completed successfully", job.ID, job.JobType)
|
||||
}
|
||||
|
||||
s.advanceJob(job, now)
|
||||
}
|
||||
|
||||
// advanceJob updates last_run to now and computes the next next_run.
|
||||
func (s *Scheduler) advanceJob(job Job, now time.Time) {
|
||||
next, err := NextRun(job.Schedule, now)
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] cannot compute next run for job %d (%q): %v — disabling", job.ID, job.Schedule, err)
|
||||
_, _ = s.db.Conn().Exec(`UPDATE cron_jobs SET enabled = FALSE, last_run = ? WHERE id = ?`, now, job.ID)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.db.Conn().Exec(
|
||||
`UPDATE cron_jobs SET last_run = ?, next_run = ? WHERE id = ?`,
|
||||
now, next, job.ID)
|
||||
if err != nil {
|
||||
log.Printf("[scheduler] error updating job %d: %v", job.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// AddJob inserts a new scheduled job and returns its ID.
|
||||
// siteID may be nil for global jobs.
|
||||
func (s *Scheduler) AddJob(siteID *int64, jobType, schedule string) (int64, error) {
|
||||
// Validate the schedule before inserting.
|
||||
next, err := NextRun(schedule, time.Now().UTC())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid schedule %q: %w", schedule, err)
|
||||
}
|
||||
|
||||
var sid sql.NullInt64
|
||||
if siteID != nil {
|
||||
sid = sql.NullInt64{Int64: *siteID, Valid: true}
|
||||
}
|
||||
|
||||
res, err := s.db.Conn().Exec(
|
||||
`INSERT INTO cron_jobs (site_id, job_type, schedule, enabled, next_run) VALUES (?, ?, ?, TRUE, ?)`,
|
||||
sid, jobType, schedule, next)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert cron job: %w", err)
|
||||
}
|
||||
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get insert id: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[scheduler] added job %d: type=%s schedule=%s next_run=%s", id, jobType, schedule, next.Format(time.RFC3339))
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// RemoveJob deletes a scheduled job by ID.
|
||||
func (s *Scheduler) RemoveJob(id int64) error {
|
||||
res, err := s.db.Conn().Exec(`DELETE FROM cron_jobs WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete cron job %d: %w", id, err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("cron job %d not found", id)
|
||||
}
|
||||
log.Printf("[scheduler] removed job %d", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListJobs returns all cron jobs with their current state.
|
||||
func (s *Scheduler) ListJobs() ([]Job, error) {
|
||||
rows, err := s.db.Conn().Query(`
|
||||
SELECT id, site_id, job_type, schedule, enabled, last_run, next_run
|
||||
FROM cron_jobs
|
||||
ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list cron jobs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var jobs []Job
|
||||
for rows.Next() {
|
||||
var j Job
|
||||
var siteID sql.NullInt64
|
||||
var lastRun, nextRun sql.NullTime
|
||||
if err := rows.Scan(&j.ID, &siteID, &j.JobType, &j.Schedule, &j.Enabled, &lastRun, &nextRun); err != nil {
|
||||
return nil, fmt.Errorf("scan cron job: %w", err)
|
||||
}
|
||||
if siteID.Valid {
|
||||
id := siteID.Int64
|
||||
j.SiteID = &id
|
||||
}
|
||||
if lastRun.Valid {
|
||||
j.LastRun = &lastRun.Time
|
||||
}
|
||||
if nextRun.Valid {
|
||||
j.NextRun = &nextRun.Time
|
||||
}
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
return jobs, rows.Err()
|
||||
}
|
||||
Reference in New Issue
Block a user