diff --git a/server/internal/patchsched/decide.go b/server/internal/patchsched/decide.go new file mode 100644 index 0000000..55bfb21 --- /dev/null +++ b/server/internal/patchsched/decide.go @@ -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 +} diff --git a/server/internal/patchsched/decide_test.go b/server/internal/patchsched/decide_test.go new file mode 100644 index 0000000..0547960 --- /dev/null +++ b/server/internal/patchsched/decide_test.go @@ -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") + } +}