From e12f3c020e0f8f275f4f40bd1f6b8727b460220e Mon Sep 17 00:00:00 2001 From: Sascha Grunert Date: Wed, 3 Apr 2024 13:10:53 +0200 Subject: [PATCH] Add `--update` flag to `schedule-builder` The schedule builder is now able to update the schedule based on the current date. The logic is also able to backfill missed releases and will stop adding more if the next target date is in the future. Signed-off-by: Sascha Grunert --- cmd/schedule-builder/cmd/markdown.go | 101 ++++++++++++++++++++-- cmd/schedule-builder/cmd/markdown_test.go | 97 +++++++++++++++++++-- cmd/schedule-builder/cmd/model.go | 28 +++--- cmd/schedule-builder/cmd/root.go | 49 ++++++++--- 4 files changed, 234 insertions(+), 41 deletions(-) diff --git a/cmd/schedule-builder/cmd/markdown.go b/cmd/schedule-builder/cmd/markdown.go index 4de410d5861..115b96a3a12 100644 --- a/cmd/schedule-builder/cmd/markdown.go +++ b/cmd/schedule-builder/cmd/markdown.go @@ -20,18 +20,22 @@ import ( "bytes" "embed" "fmt" + "os" "strings" "text/template" + "time" "github.com/olekukonko/tablewriter" "github.com/sirupsen/logrus" + "sigs.k8s.io/release-utils/util" + "sigs.k8s.io/yaml" ) //go:embed templates/*.tmpl var tpls embed.FS // runs with `--type=patch` to return the patch schedule -func parseSchedule(patchSchedule PatchSchedule) string { +func parsePatchSchedule(patchSchedule PatchSchedule) string { output := []string{} output = append(output, "### Timeline\n") for _, releaseSchedule := range patchSchedule.Schedules { @@ -65,8 +69,6 @@ func parseSchedule(patchSchedule PatchSchedule) string { scheduleOut := strings.Join(output, "\n") logrus.Info("Schedule parsed") - println(scheduleOut) - return scheduleOut } @@ -108,15 +110,13 @@ func parseReleaseSchedule(releaseSchedule ReleaseSchedule) string { relSched.TimelineOutput = tableString.String() } - scheduleOut := ProcessFile("templates/rel-schedule.tmpl", relSched) + scheduleOut := processFile("templates/rel-schedule.tmpl", relSched) logrus.Info("Release Schedule parsed") - println(scheduleOut) - return scheduleOut } -func patchReleaseInPreviousList(a string, previousPatches []PatchRelease) bool { +func patchReleaseInPreviousList(a string, previousPatches []*PatchRelease) bool { for _, b := range previousPatches { if b.Release == a { return true @@ -141,10 +141,95 @@ func process(t *template.Template, vars interface{}) string { return tmplBytes.String() } -func ProcessFile(fileName string, vars interface{}) string { +func processFile(fileName string, vars interface{}) string { tmpl, err := template.ParseFS(tpls, fileName) if err != nil { panic(err) } return process(tmpl, vars) } + +func updatePatchSchedule(refTime time.Time, schedule PatchSchedule, filePath string) error { + const refDate = "2006-01-02" + + for _, schedule := range schedule.Schedules { + for { + eolDate, err := time.Parse(refDate, schedule.EndOfLifeDate) + if err != nil { + return fmt.Errorf("parse end of life date: %w", err) + } + + if refTime.After(eolDate) { + logrus.Infof("Skipping end of life release: %s", schedule.Release) + break + } + + targetDate, err := time.Parse(refDate, schedule.Next.TargetDate) + if err != nil { + return fmt.Errorf("parse target date: %w", err) + } + + if targetDate.After(refTime) { + break + } + + // Copy the release to the previousPatches section + schedule.PreviousPatches = append([]*PatchRelease{schedule.Next}, schedule.PreviousPatches...) + + // Create a new next release + nextReleaseVersion, err := util.TagStringToSemver(schedule.Next.Release) + if err != nil { + return fmt.Errorf("parse semver version: %w", err) + } + if err := nextReleaseVersion.IncrementPatch(); err != nil { + return fmt.Errorf("increment patch version: %w", err) + } + + cherryPickDeadline, err := time.Parse(refDate, schedule.Next.CherryPickDeadline) + if err != nil { + return fmt.Errorf("parse cherry pick deadline: %w", err) + } + cherryPickDeadlinePlusOneMonth := cherryPickDeadline.AddDate(0, 1, 0) + cherryPickDay := firstFriday(cherryPickDeadlinePlusOneMonth) + newCherryPickDeadline := time.Date(cherryPickDeadlinePlusOneMonth.Year(), cherryPickDeadlinePlusOneMonth.Month(), cherryPickDay, 0, 0, 0, 0, time.UTC) + + targetDatePlusOneMonth := targetDate.AddDate(0, 1, 0) + targetDateDay := secondTuesday(targetDatePlusOneMonth) + newTargetDate := time.Date(targetDatePlusOneMonth.Year(), targetDatePlusOneMonth.Month(), targetDateDay, 0, 0, 0, 0, time.UTC) + + schedule.Next = &PatchRelease{ + Release: nextReleaseVersion.String(), + CherryPickDeadline: newCherryPickDeadline.Format(refDate), + TargetDate: newTargetDate.Format(refDate), + } + + logrus.Infof("Adding release schedule: %+v", schedule.Next) + } + } + + yamlBytes, err := yaml.Marshal(schedule) + if err != nil { + return fmt.Errorf("marshal schedule YAML: %w", err) + } + + //nolint:gocritic,gosec + if err := os.WriteFile(filePath, yamlBytes, 0o644); err != nil { + return fmt.Errorf("write schedule YAML: %w", err) + } + + logrus.Infof("Wrote schedule YAML to: %v", filePath) + return nil +} + +func secondTuesday(t time.Time) int { + return firstMonday(t) + 8 +} + +func firstFriday(t time.Time) int { + return firstMonday(t) + 4 +} + +func firstMonday(from time.Time) int { + t := time.Date(from.Year(), from.Month(), 1, 0, 0, 0, 0, time.UTC) + return (8-int(t.Weekday()))%7 + 1 +} diff --git a/cmd/schedule-builder/cmd/markdown_test.go b/cmd/schedule-builder/cmd/markdown_test.go index ade1910383f..4de092253d7 100644 --- a/cmd/schedule-builder/cmd/markdown_test.go +++ b/cmd/schedule-builder/cmd/markdown_test.go @@ -18,9 +18,13 @@ package cmd import ( "fmt" + "os" "testing" + "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" ) const expectedPatchSchedule = `### Timeline @@ -121,7 +125,7 @@ Please refer to the [release phases document](../release_phases.md). [release phases document]: ../release_phases.md ` -func TestParseSchedule(t *testing.T) { +func TestParsePatchSchedule(t *testing.T) { testcases := []struct { name string schedule PatchSchedule @@ -129,7 +133,7 @@ func TestParseSchedule(t *testing.T) { { name: "next patch is not in previous patch list", schedule: PatchSchedule{ - Schedules: []Schedule{ + Schedules: []*Schedule{ { Release: "X.Y", Next: &PatchRelease{ @@ -139,7 +143,7 @@ func TestParseSchedule(t *testing.T) { }, EndOfLifeDate: "NOW", MaintenanceModeStartDate: "THEN", - PreviousPatches: []PatchRelease{ + PreviousPatches: []*PatchRelease{ { Release: "X.Y.XXX", CherryPickDeadline: "2020-05-15", @@ -159,7 +163,7 @@ func TestParseSchedule(t *testing.T) { { name: "next patch is in previous patch list", schedule: PatchSchedule{ - Schedules: []Schedule{ + Schedules: []*Schedule{ { Release: "X.Y", Next: &PatchRelease{ @@ -169,7 +173,7 @@ func TestParseSchedule(t *testing.T) { }, EndOfLifeDate: "NOW", MaintenanceModeStartDate: "THEN", - PreviousPatches: []PatchRelease{ + PreviousPatches: []*PatchRelease{ { Release: "X.Y.ZZZ", CherryPickDeadline: "2020-06-12", @@ -195,7 +199,7 @@ func TestParseSchedule(t *testing.T) { for _, tc := range testcases { fmt.Printf("Test case: %s\n", tc.name) - out := parseSchedule(tc.schedule) + out := parsePatchSchedule(tc.schedule) require.Equal(t, out, expectedPatchSchedule) } } @@ -319,3 +323,84 @@ func TestParseReleaseSchedule(t *testing.T) { require.Equal(t, out, expectedReleaseSchedule) } } + +func TestUpdatePatchSchedule(t *testing.T) { + for _, tc := range []struct { + name string + refTime time.Time + givenSchedule, expectedSchedule PatchSchedule + }{ + { + name: "succeed to update the schedule", + refTime: time.Date(2024, 4, 3, 0, 0, 0, 0, time.UTC), + givenSchedule: PatchSchedule{ + Schedules: []*Schedule{ + { // Needs multiple updates + Release: "1.30", + Next: &PatchRelease{ + Release: "1.30.1", + CherryPickDeadline: "2024-01-05", + TargetDate: "2024-01-09", + }, + EndOfLifeDate: "2025-01-01", + MaintenanceModeStartDate: "2024-12-01", + }, + { // EOL + Release: "1.20", + EndOfLifeDate: "2023-01-01", + }, + }, + }, + expectedSchedule: PatchSchedule{ + Schedules: []*Schedule{ + { + Release: "1.30", + Next: &PatchRelease{ + Release: "1.30.4", + CherryPickDeadline: "2024-04-05", + TargetDate: "2024-04-09", + }, + EndOfLifeDate: "2025-01-01", + MaintenanceModeStartDate: "2024-12-01", + PreviousPatches: []*PatchRelease{ + { + Release: "1.30.3", + CherryPickDeadline: "2024-03-08", + TargetDate: "2024-03-12", + }, + { + Release: "1.30.2", + CherryPickDeadline: "2024-02-09", + TargetDate: "2024-02-13", + }, + { + Release: "1.30.1", + CherryPickDeadline: "2024-01-05", + TargetDate: "2024-01-09", + }, + }, + }, + { + Release: "1.20", + EndOfLifeDate: "2023-01-01", + }, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + file, err := os.CreateTemp("", "schedule-") + require.NoError(t, err) + require.NoError(t, file.Close()) + + require.NoError(t, updatePatchSchedule(tc.refTime, tc.givenSchedule, file.Name())) + + yamlBytes, err := os.ReadFile(file.Name()) + require.NoError(t, err) + res := PatchSchedule{} + require.NoError(t, yaml.UnmarshalStrict(yamlBytes, &res)) + + assert.Equal(t, tc.expectedSchedule, res) + }) + } +} diff --git a/cmd/schedule-builder/cmd/model.go b/cmd/schedule-builder/cmd/model.go index c8b991870d6..b0f2caf2c2e 100644 --- a/cmd/schedule-builder/cmd/model.go +++ b/cmd/schedule-builder/cmd/model.go @@ -16,27 +16,27 @@ limitations under the License. package cmd -// PatchSchedule main struct to hold the schedules +// PatchSchedule main struct to hold the schedules. type PatchSchedule struct { - Schedules []Schedule `yaml:"schedules"` + Schedules []*Schedule `json:"schedules,omitempty" yaml:"schedules,omitempty"` } -// PatchRelease struct to define the patch schedules +// PatchRelease struct to define the patch schedules. type PatchRelease struct { - Release string `yaml:"release"` - CherryPickDeadline string `yaml:"cherryPickDeadline"` - TargetDate string `yaml:"targetDate"` - Note string `yaml:"note"` + Release string `json:"release,omitempty" yaml:"release,omitempty"` + CherryPickDeadline string `json:"cherryPickDeadline,omitempty" yaml:"cherryPickDeadline,omitempty"` + TargetDate string `json:"targetDate,omitempty" yaml:"targetDate,omitempty"` + Note string `json:"note,omitempty" yaml:"note,omitempty"` } -// Schedule struct to define the release schedule for a specific version +// Schedule struct to define the release schedule for a specific version. type Schedule struct { - Release string `yaml:"release"` - ReleaseDate string `yaml:"releaseDate"` - Next *PatchRelease `yaml:"next"` - EndOfLifeDate string `yaml:"endOfLifeDate"` - MaintenanceModeStartDate string `yaml:"maintenanceModeStartDate"` - PreviousPatches []PatchRelease `yaml:"previousPatches"` + Release string `json:"release,omitempty" yaml:"release,omitempty"` + ReleaseDate string `json:"releaseDate,omitempty" yaml:"releaseDate,omitempty"` + Next *PatchRelease `json:"next,omitempty" yaml:"next,omitempty"` + EndOfLifeDate string `json:"endOfLifeDate,omitempty" yaml:"endOfLifeDate,omitempty"` + MaintenanceModeStartDate string `json:"maintenanceModeStartDate,omitempty" yaml:"maintenanceModeStartDate,omitempty"` + PreviousPatches []*PatchRelease `json:"previousPatches,omitempty" yaml:"previousPatches,omitempty"` } type ReleaseSchedule struct { diff --git a/cmd/schedule-builder/cmd/root.go b/cmd/schedule-builder/cmd/root.go index 52036f564ab..63fa78afed4 100644 --- a/cmd/schedule-builder/cmd/root.go +++ b/cmd/schedule-builder/cmd/root.go @@ -19,6 +19,7 @@ package cmd import ( "fmt" "os" + "time" "github.com/sirupsen/logrus" "github.com/spf13/cobra" @@ -45,6 +46,7 @@ type options struct { outputFile string logLevel string typeFile string + update bool } var opts = &options{} @@ -53,6 +55,7 @@ const ( configPathFlag = "config-path" outputFileFlag = "output-file" typeFlag = "type" + updateFlag = "update" typePatch = "patch" typeRelease = "release" ) @@ -102,6 +105,14 @@ func init() { fmt.Sprintf("type of file to be produced - release cycle schedule or patch schedule. To be set to '%s' or '%s' and respective yaml needs to be supplied with '--%s'", typeRelease, typePatch, configPathFlag), ) + rootCmd.PersistentFlags().BoolVarP( + &opts.update, + updateFlag, + "u", + false, + fmt.Sprintf("update the '--%s' based on the latest available data (or date). Right now only supported if '--%s' is set to '%s'", configPathFlag, typeFlag, typePatch), + ) + for _, flag := range requiredFlags { if err := rootCmd.MarkPersistentFlagRequired(flag); err != nil { logrus.Fatal(err) @@ -115,10 +126,10 @@ func initLogging(*cobra.Command, []string) error { func run(opts *options) error { if err := opts.SetAndValidate(); err != nil { - return fmt.Errorf("validating schedule-path options: %w", err) + return fmt.Errorf("validating options: %w", err) } - logrus.Infof("Reading the schedule file %s...", opts.configPath) + logrus.Infof("Reading schedule file: %s", opts.configPath) data, err := os.ReadFile(opts.configPath) if err != nil { return fmt.Errorf("failed to read the file: %w", err) @@ -130,35 +141,43 @@ func run(opts *options) error { scheduleOut string ) - logrus.Info("Parsing the schedule...") + logrus.Info("Parsing schedule") switch opts.typeFile { - case "patch": + case typePatch: if err := yaml.UnmarshalStrict(data, &patchSchedule); err != nil { return fmt.Errorf("failed to decode the file: %w", err) } - logrus.Info("Generating the markdown output...") - scheduleOut = parseSchedule(patchSchedule) + if opts.update { + logrus.Info("Updating schedule") + if err := updatePatchSchedule(time.Now(), patchSchedule, opts.configPath); err != nil { + return fmt.Errorf("update patch schedule: %w", err) + } + } else { + logrus.Infof("Generating markdown output for type %q", typePatch) + scheduleOut = parsePatchSchedule(patchSchedule) + println(scheduleOut) + } - case "release": + case typeRelease: if err := yaml.UnmarshalStrict(data, &releaseSchedule); err != nil { return fmt.Errorf("failed to decode the file: %w", err) } - logrus.Info("Generating the markdown output...") + logrus.Infof("Generating markdown output for type %q", typeRelease) scheduleOut = parseReleaseSchedule(releaseSchedule) + println(scheduleOut) default: return fmt.Errorf("type must be either %q or %q", typeRelease, typePatch) } - if opts.outputFile != "" { - logrus.Infof("Saving schedule to a file %s.", opts.outputFile) + if opts.outputFile != "" && scheduleOut != "" { + logrus.Infof("Saving schedule to file: %s", opts.outputFile) //nolint:gosec // TODO(gosec): G306: Expect WriteFile permissions to be // 0600 or less - err := os.WriteFile(opts.outputFile, []byte(scheduleOut), 0o644) - if err != nil { + if err := os.WriteFile(opts.outputFile, []byte(scheduleOut), 0o644); err != nil { return fmt.Errorf("failed to save schedule to the file: %w", err) } logrus.Info("File saved") @@ -169,11 +188,15 @@ func run(opts *options) error { // SetAndValidate sets some default options and verifies if options are valid func (o *options) SetAndValidate() error { - logrus.Info("Validating schedule-path options...") + logrus.Info("Validating options") if o.configPath == "" { return fmt.Errorf("need to set the '--%s' flag", configPathFlag) } + if o.update && o.typeFile != typePatch { + return fmt.Errorf("'--%s' is only supported for '--%s=%s', not '%s'", updateFlag, typeFlag, typePatch, o.typeFile) + } + return nil }