Skip to content

Commit 9e3980c

Browse files
authored
Merge pull request #4413 from palnabarun/fix-schedule-builder-for-maintenance-windows
schedule-builder: optionally extend upcoming patch window to 4 during maintenance overlap
2 parents 55537e8 + 8995cc8 commit 9e3980c

2 files changed

Lines changed: 196 additions & 2 deletions

File tree

cmd/schedule-builder/cmd/markdown.go

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@ limitations under the License.
1717
package cmd
1818

1919
import (
20+
"bufio"
2021
"bytes"
2122
"embed"
23+
"errors"
2224
"fmt"
25+
"io"
2326
"os"
2427
"slices"
2528
"strings"
@@ -188,8 +191,52 @@ const (
188191
# schedule-builder -uc data/releases/schedule.yaml -e data/releases/eol.yaml
189192
---
190193
`
194+
defaultUpcomingReleases = 3
195+
extendedUpcomingReleases = 4
191196
)
192197

198+
// isInMaintenanceWindow reports whether refTime sits between sched's
199+
// maintenanceModeStartDate (inclusive) and endOfLifeDate (exclusive).
200+
// Unparseable values (commonly "TBD") return false.
201+
func isInMaintenanceWindow(refTime time.Time, sched *Schedule) bool {
202+
if sched == nil || sched.MaintenanceModeStartDate == "" || sched.EndOfLifeDate == "" {
203+
return false
204+
}
205+
206+
mms, err := time.Parse(refDate, sched.MaintenanceModeStartDate)
207+
if err != nil {
208+
return false
209+
}
210+
211+
eol, err := time.Parse(refDate, sched.EndOfLifeDate)
212+
if err != nil {
213+
return false
214+
}
215+
216+
return !refTime.Before(mms) && refTime.Before(eol)
217+
}
218+
219+
// confirmExtraUpcoming asks the operator whether to extend the upcoming
220+
// patch window to 4 entries during a maintenance-mode overlap. Overridable
221+
// in tests.
222+
var confirmExtraUpcoming = func(oldest *Schedule) (bool, error) {
223+
fmt.Fprintf(os.Stderr,
224+
"Oldest supported release %s is in its maintenance window "+
225+
"(maintenanceModeStartDate=%s, endOfLifeDate=%s).\n"+
226+
"Generate an additional upcoming patch release entry (%d total instead of %d)? [y/N]: ",
227+
oldest.Release, oldest.MaintenanceModeStartDate, oldest.EndOfLifeDate,
228+
extendedUpcomingReleases, defaultUpcomingReleases)
229+
230+
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
231+
if err != nil && !errors.Is(err, io.EOF) {
232+
return false, fmt.Errorf("read confirmation: %w", err)
233+
}
234+
235+
answer := strings.TrimSpace(strings.ToLower(line))
236+
237+
return answer == "y" || answer == "yes", nil
238+
}
239+
193240
//nolint:maintidx // complex but acceptable
194241
func updatePatchSchedule(refTime time.Time, schedule PatchSchedule, eolBranches EolBranches, filePath, eolFilePath string) error {
195242
removeSchedules := []int{}
@@ -321,6 +368,23 @@ func updatePatchSchedule(refTime time.Time, schedule PatchSchedule, eolBranches
321368

322369
schedule.Schedules = newSchedules
323370

371+
upcomingCap := defaultUpcomingReleases
372+
373+
if len(schedule.Schedules) > 0 {
374+
oldest := schedule.Schedules[len(schedule.Schedules)-1]
375+
if isInMaintenanceWindow(refTime, oldest) {
376+
ok, err := confirmExtraUpcoming(oldest)
377+
if err != nil {
378+
return err
379+
}
380+
381+
if ok {
382+
upcomingCap = extendedUpcomingReleases
383+
logrus.Infof("Extending upcoming patch window to %d entries while %s is in maintenance mode", extendedUpcomingReleases, oldest.Release)
384+
}
385+
}
386+
}
387+
324388
newUpcomingReleases := []*PatchRelease{}
325389
latestDate := refTime
326390

@@ -342,8 +406,8 @@ func updatePatchSchedule(refTime time.Time, schedule PatchSchedule, eolBranches
342406
}
343407

344408
for {
345-
if len(newUpcomingReleases) >= 3 {
346-
logrus.Infof("Got 3 new upcoming releases, not adding any more")
409+
if len(newUpcomingReleases) >= upcomingCap {
410+
logrus.Infof("Got %d new upcoming releases, not adding any more", upcomingCap)
347411

348412
break
349413
}

cmd/schedule-builder/cmd/markdown_test.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package cmd
1818

1919
import (
20+
"errors"
2021
"fmt"
2122
"os"
2223
"testing"
@@ -482,3 +483,132 @@ func TestUpdatePatchSchedule(t *testing.T) {
482483
})
483484
}
484485
}
486+
487+
func TestUpdatePatchScheduleMaintenanceWindow(t *testing.T) {
488+
refTime := time.Date(2026, 5, 24, 0, 0, 0, 0, time.UTC)
489+
490+
// Four supported minors with the oldest (1.30) sitting in its
491+
// maintenance window relative to refTime. Each Next.TargetDate is
492+
// after refTime so the per-schedule patch-bump loop short-circuits.
493+
baseSchedule := func() PatchSchedule {
494+
return PatchSchedule{
495+
Schedules: []*Schedule{
496+
{
497+
Release: "1.33",
498+
Next: &PatchRelease{
499+
Release: "1.33.1",
500+
CherryPickDeadline: "2026-06-05",
501+
TargetDate: "2026-06-09",
502+
},
503+
EndOfLifeDate: "2027-06-01",
504+
MaintenanceModeStartDate: "2027-03-01",
505+
},
506+
{
507+
Release: "1.32",
508+
Next: &PatchRelease{
509+
Release: "1.32.5",
510+
CherryPickDeadline: "2026-06-05",
511+
TargetDate: "2026-06-09",
512+
},
513+
EndOfLifeDate: "2027-02-01",
514+
MaintenanceModeStartDate: "2026-11-01",
515+
},
516+
{
517+
Release: "1.31",
518+
Next: &PatchRelease{
519+
Release: "1.31.10",
520+
CherryPickDeadline: "2026-06-05",
521+
TargetDate: "2026-06-09",
522+
},
523+
EndOfLifeDate: "2026-10-01",
524+
MaintenanceModeStartDate: "2026-08-01",
525+
},
526+
{
527+
Release: "1.30",
528+
Next: &PatchRelease{
529+
Release: "1.30.15",
530+
CherryPickDeadline: "2026-06-05",
531+
TargetDate: "2026-06-09",
532+
},
533+
EndOfLifeDate: "2026-07-01",
534+
MaintenanceModeStartDate: "2026-04-01",
535+
},
536+
},
537+
}
538+
}
539+
540+
for _, tc := range []struct {
541+
name string
542+
confirm func(*Schedule) (bool, error)
543+
mutate func(PatchSchedule) PatchSchedule
544+
wantUpcomingCount int
545+
wantErr string
546+
}{
547+
{
548+
name: "overlap accepted - extends upcoming to 4",
549+
confirm: func(*Schedule) (bool, error) { return true, nil },
550+
wantUpcomingCount: 4,
551+
},
552+
{
553+
name: "overlap declined - upcoming stays at 3",
554+
confirm: func(*Schedule) (bool, error) { return false, nil },
555+
wantUpcomingCount: 3,
556+
},
557+
{
558+
name: "TBD maintenance date - prompter never called",
559+
confirm: func(*Schedule) (bool, error) {
560+
t.Fatal("confirmExtraUpcoming must not be called when MaintenanceModeStartDate is TBD")
561+
562+
return false, nil
563+
},
564+
mutate: func(s PatchSchedule) PatchSchedule {
565+
s.Schedules[3].MaintenanceModeStartDate = "TBD"
566+
567+
return s
568+
},
569+
wantUpcomingCount: 3,
570+
},
571+
{
572+
name: "prompt error bubbles up",
573+
confirm: func(*Schedule) (bool, error) { return false, errors.New("boom") },
574+
wantErr: "boom",
575+
},
576+
} {
577+
t.Run(tc.name, func(t *testing.T) {
578+
orig := confirmExtraUpcoming
579+
580+
t.Cleanup(func() {
581+
confirmExtraUpcoming = orig
582+
})
583+
584+
confirmExtraUpcoming = tc.confirm
585+
586+
sched := baseSchedule()
587+
if tc.mutate != nil {
588+
sched = tc.mutate(sched)
589+
}
590+
591+
scheduleFile, err := os.CreateTemp(t.TempDir(), "schedule-")
592+
require.NoError(t, err)
593+
require.NoError(t, scheduleFile.Close())
594+
595+
err = updatePatchSchedule(refTime, sched, EolBranches{}, scheduleFile.Name(), "")
596+
if tc.wantErr != "" {
597+
require.ErrorContains(t, err, tc.wantErr)
598+
599+
return
600+
}
601+
602+
require.NoError(t, err)
603+
604+
scheduleYamlBytes, err := os.ReadFile(scheduleFile.Name()) //nolint:gosec // G304 - temp file path is safe
605+
require.NoError(t, err)
606+
607+
patchRes := PatchSchedule{}
608+
require.NoError(t, yaml.UnmarshalStrict(scheduleYamlBytes, &patchRes))
609+
610+
assert.Len(t, patchRes.UpcomingReleases, tc.wantUpcomingCount)
611+
assert.Len(t, patchRes.Schedules, 4, "schedules[] count must be unchanged")
612+
})
613+
}
614+
}

0 commit comments

Comments
 (0)