feat: patchsched fire/skip decision and window arithmetic

This commit is contained in:
2026-09-15 08:44:52 +00:00
parent b60daf0461
commit c0e26d0493
2 changed files with 121 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
// Package patchsched fires patch policies at the start of their maintenance
// window and advances running patch runs. Like workflowsched it runs under the
// housekeeping leader lock and must not import services: services imports
// this package for NextStart and WindowEnd.
package patchsched
import (
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/workflowsched"
)
type Decision string
const (
Fire Decision = "fire"
SkipMissed Decision = "missed"
SkipRunning Decision = "already_running"
SkipNoTargets Decision = "no_targets"
)
// Decide is the whole fire or skip rule for one due policy. Missed comes
// first, as in workflowsched: a stale occurrence is recorded as missed
// whatever else is true. A window that has already closed is missed even
// inside the hour of grace, because there is no time left to patch in.
func Decide(due, windowEnd, now time.Time, runActive bool, targets int) Decision {
if !now.Before(windowEnd) || now.Sub(due) > workflowsched.GraceWindow {
return SkipMissed
}
if runActive {
return SkipRunning
}
if targets == 0 {
return SkipNoTargets
}
return Fire
}
func WindowEnd(start time.Time, durationMinutes int) time.Time {
return start.Add(time.Duration(durationMinutes) * time.Minute)
}
// NextStart is the first window start strictly after from. Callers pass
// Later(now, currentWindowEnd) so windows never overlap, including across a
// daylight-saving fall-back where the same wall-clock time occurs twice.
func NextStart(cron, tz string, from time.Time) (time.Time, error) {
return workflowsched.NextOccurrence(cron, tz, from)
}
func Later(a, b time.Time) time.Time {
if a.After(b) {
return a
}
return b
}
+66
View File
@@ -0,0 +1,66 @@
package patchsched
import (
"testing"
"time"
)
var due = time.Date(2026, 9, 20, 2, 0, 0, 0, time.UTC)
func TestDecide(t *testing.T) {
end := due.Add(2 * time.Hour)
cases := []struct {
name string
now time.Time
end time.Time
running bool
targets int
want Decision
}{
{"on time", due, end, false, 3, Fire},
{"late within grace", due.Add(59 * time.Minute), end, false, 3, Fire},
{"past grace", due.Add(61 * time.Minute), end, false, 3, SkipMissed},
{"window already over", due.Add(20 * time.Minute), due.Add(15 * time.Minute), false, 3, SkipMissed},
{"missed wins over running", due.Add(2 * time.Hour), end, true, 3, SkipMissed},
{"previous run active", due, end, true, 3, SkipRunning},
{"no targets", due, end, false, 0, SkipNoTargets},
}
for _, c := range cases {
if got := Decide(due, c.end, c.now, c.running, c.targets); got != c.want {
t.Errorf("%s: got %s, want %s", c.name, got, c.want)
}
}
}
// Europe/London falls back on 25 October 2026, so 01:30 happens twice. The
// next window is computed from the end of the current one, so a two-hour
// window starting at the first 01:30 cannot fire again at the second.
func TestNextStartAcrossFallBack(t *testing.T) {
loc, _ := time.LoadLocation("Europe/London")
from := time.Date(2026, 10, 24, 12, 0, 0, 0, time.UTC)
first, err := NextStart("30 1 * * 0", "Europe/London", from)
if err != nil {
t.Fatal(err)
}
if d := first.In(loc); d.Day() != 25 || d.Month() != time.October || d.Hour() != 1 || d.Minute() != 30 {
t.Fatalf("first = %s", d)
}
end := WindowEnd(first, 120)
next, err := NextStart("30 1 * * 0", "Europe/London", Later(first.Add(time.Minute), end))
if err != nil {
t.Fatal(err)
}
if d := next.In(loc); d.Day() != 1 || d.Month() != time.November || d.Hour() != 1 || d.Minute() != 30 {
t.Fatalf("next = %s, want 2026-11-01 01:30 London", d)
}
}
func TestWindowEndAndLater(t *testing.T) {
if got := WindowEnd(due, 90); !got.Equal(due.Add(90 * time.Minute)) {
t.Fatalf("WindowEnd = %s", got)
}
a, b := due, due.Add(time.Second)
if !Later(a, b).Equal(b) || !Later(b, a).Equal(b) {
t.Fatal("Later must return the later time")
}
}