feat: patchsched fire/skip decision and window arithmetic
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user