No One Can Stop Me Now

This commit is contained in:
DigiJ
2026-03-13 23:48:47 -07:00
parent 4d3570781e
commit 1a138a2bd0
428 changed files with 519668 additions and 259 deletions

View 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
}
}

View 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()
}