diff --git a/cmd/patch-release-notify/README.md b/cmd/patch-release-notify/README.md new file mode 100644 index 00000000000..426fda2bb15 --- /dev/null +++ b/cmd/patch-release-notify/README.md @@ -0,0 +1,16 @@ +# Patch Release Notify + +This simple tool has the objective to send an notification email when we are closer to +the patch release cycle to let people know that the cherry pick deadline is approaching. + +## Install + +The simplest way to install the `patch-release-notify` CLI is via `go get`: + +``` +$ go get k8s.io/release/cmd/patch-release-notify +``` + +This will install `patch-release-notify` to `$(go env GOPATH)/bin/patch-release-notify`. + +Also if you have the `kubernetes/release` cloned you can run the `make release-tools` to build all the tools. diff --git a/cmd/patch-release-notify/cmd/root.go b/cmd/patch-release-notify/cmd/root.go new file mode 100644 index 00000000000..5112f58e3d7 --- /dev/null +++ b/cmd/patch-release-notify/cmd/root.go @@ -0,0 +1,295 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "bytes" + "embed" + "errors" + "fmt" + "html/template" + "io" + "math" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "k8s.io/release/cmd/schedule-builder/model" + "k8s.io/release/pkg/mail" + "sigs.k8s.io/release-utils/env" + "sigs.k8s.io/release-utils/log" + "sigs.k8s.io/yaml" +) + +//go:embed templates/*.tmpl +var tpls embed.FS + +// rootCmd represents the base command when called without any subcommands +var rootCmd = &cobra.Command{ + Use: "patch-release-notify --schedule-path /path/to/schedule.yaml", + Short: "patch-release-notify check the cherry pick deadline and send an email to notify", + Example: "patch-release-notify --schedule-path /path/to/schedule.yaml", + SilenceUsage: true, + SilenceErrors: true, + PersistentPreRunE: initLogging, + RunE: func(*cobra.Command, []string) error { + return run(opts) + }, +} + +type options struct { + sendgridAPIKey string + schedulePath string + dayToalert int + name string + email string + nomock bool + logLevel string +} + +var opts = &options{} + +const ( + sendgridAPIKeyEnvKey = "SENDGRID_API_KEY" //nolint:gosec // this will be provided via env vars + layout = "2006-01-02" + + schedulePathFlag = "schedule-path" + nameFlag = "name" + emailFlag = "email" + dayToalertFlag = "days-to-alert" +) + +var requiredFlags = []string{ + schedulePathFlag, +} + +type Template struct { + Releases []TemplateRelease +} + +type TemplateRelease struct { + Release string + CherryPickDeadline string +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + if err := rootCmd.Execute(); err != nil { + logrus.Fatal(err) + } +} + +func init() { + opts.sendgridAPIKey = env.Default(sendgridAPIKeyEnvKey, "") + + rootCmd.PersistentFlags().StringVar( + &opts.schedulePath, + schedulePathFlag, + "", + "path where can find the schedule.yaml file", + ) + + rootCmd.PersistentFlags().BoolVar( + &opts.nomock, + "nomock", + false, + "run the command to target the production environment", + ) + + rootCmd.PersistentFlags().StringVar( + &opts.logLevel, + "log-level", + "info", + fmt.Sprintf("the logging verbosity, either %s", log.LevelNames()), + ) + + rootCmd.PersistentFlags().StringVarP( + &opts.name, + nameFlag, + "n", + "", + "mail sender name", + ) + + rootCmd.PersistentFlags().IntVar( + &opts.dayToalert, + dayToalertFlag, + 3, + "day to before the deadline to send the notification. Defaults to 3 days.", + ) + + rootCmd.PersistentFlags().StringVarP( + &opts.email, + emailFlag, + "e", + "", + "email address", + ) + + for _, flag := range requiredFlags { + if err := rootCmd.MarkPersistentFlagRequired(flag); err != nil { + logrus.Fatal(err) + } + } +} + +func initLogging(*cobra.Command, []string) error { + return log.SetupGlobalLogger(opts.logLevel) +} + +func run(opts *options) error { + if err := opts.SetAndValidate(); err != nil { + return fmt.Errorf("validating schedule-path options: %w", err) + } + + if opts.sendgridAPIKey == "" { + return fmt.Errorf( + "$%s is not set", sendgridAPIKeyEnvKey, + ) + } + + data, err := loadFileOrURL(opts.schedulePath) + if err != nil { + return fmt.Errorf("failed to read the file: %w", err) + } + + patchSchedule := &model.PatchSchedule{} + + logrus.Info("Parsing the schedule...") + + if err := yaml.UnmarshalStrict(data, &patchSchedule); err != nil { + return fmt.Errorf("failed to decode the file: %w", err) + } + + output := &Template{} + + shouldSendEmail := false + + for _, patch := range patchSchedule.Schedules { + t, err := time.Parse(layout, patch.CherryPickDeadline) + if err != nil { + return fmt.Errorf("parsing schedule time: %w", err) + } + + currentTime := time.Now().UTC() + days := t.Sub(currentTime).Hours() / 24 + intDay, _ := math.Modf(days) + if int(intDay) == opts.dayToalert { + output.Releases = append(output.Releases, TemplateRelease{ + Release: patch.Release, + CherryPickDeadline: patch.CherryPickDeadline, + }) + shouldSendEmail = true + } + } + + tmpl, err := template.ParseFS(tpls, "templates/email.tmpl") + if err != nil { + return fmt.Errorf("parsing template: %w", err) + } + + var tmplBytes bytes.Buffer + err = tmpl.Execute(&tmplBytes, output) + if err != nil { + return fmt.Errorf("parsing values to the template: %w", err) + } + + if !shouldSendEmail { + logrus.Info("No email is needed to send") + return nil + } + + if !opts.nomock { + logrus.Info("This is a mock only, will print out the email before sending to a test mailing list") + fmt.Println(tmplBytes.String()) + } + + logrus.Info("Preparing mail sender") + m := mail.NewSender(opts.sendgridAPIKey) + + if opts.name != "" && opts.email != "" { + if err := m.SetSender(opts.name, opts.email); err != nil { + return fmt.Errorf("unable to set mail sender: %w", err) + } + } else { + logrus.Info("Retrieving default sender from sendgrid API") + if err := m.SetDefaultSender(); err != nil { + return fmt.Errorf("setting default sender: %w", err) + } + } + + groups := []mail.GoogleGroup{mail.KubernetesAnnounceTestGoogleGroup} + if opts.nomock { + groups = []mail.GoogleGroup{ + mail.KubernetesDevGoogleGroup, + } + } + logrus.Infof("Using Google Groups as announcement target: %v", groups) + + if err := m.SetGoogleGroupRecipients(groups...); err != nil { + return fmt.Errorf("unable to set mail recipients: %w", err) + } + + logrus.Info("Sending mail") + subject := "[Please Read] Patch Releases cherry-pick deadline" + + if err := m.Send(tmplBytes.String(), subject); err != nil { + return fmt.Errorf("unable to send mail: %w", err) + } + + return nil +} + +// SetAndValidate sets some default options and verifies if options are valid +func (o *options) SetAndValidate() error { + logrus.Info("Validating schedule-path options...") + + if o.schedulePath == "" { + return errors.New("need to set the schedule-path") + } + + return nil +} + +func loadFileOrURL(fileRef string) ([]byte, error) { + var raw []byte + var err error + if strings.HasPrefix(fileRef, "http://") || strings.HasPrefix(fileRef, "https://") { + // #nosec G107 + resp, err := http.Get(fileRef) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err = io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + } else { + raw, err = os.ReadFile(filepath.Clean(fileRef)) + if err != nil { + return nil, err + } + } + return raw, nil +} diff --git a/cmd/patch-release-notify/cmd/templates/email.tmpl b/cmd/patch-release-notify/cmd/templates/email.tmpl new file mode 100644 index 00000000000..6fbdd7704e9 --- /dev/null +++ b/cmd/patch-release-notify/cmd/templates/email.tmpl @@ -0,0 +1,32 @@ + + + +

Hello Kubernetes Community!

+{{range .Releases}} +

The cherry-pick deadline for the {{ .Release }} branches is {{ .CherryPickDeadline }} EOD PT.

+{{end}} +

Here are some quick links to search for cherry-pick PRs:

+{{range .Releases}} +

- release-{{ .Release }}: https://github.com/kubernetes/kubernetes/pulls?q=is%3Apr+is%3Aopen+base%3Arelease-{{ .Release }}+label%3Ado-not-merge%2Fcherry-pick-not-approved

+{{end}} +
+

For PRs that you intend to land for the upcoming patch sets, please +ensure they have:

+

- a release note in the PR description

+

- /sig

+

- /kind

+

- /priority

+

- /lgtm

+

- /approve

+

- passing tests

+
+

Details on the cherry-pick process can be found here:

+

https://git.k8s.io/community/contributors/devel/sig-release/cherry-picks.md

+

We keep general info and up-to-date timelines for patch releases here:

+

https://kubernetes.io/releases/patch-releases/#upcoming-monthly-releases

+

If you have any questions for the Release Managers, please feel free to +reach out to us at #release-management (Kubernetes Slack) or release-managers@kubernetes.io


+

We wish everyone a happy and safe week!

+

SIG-Release Team

+ + diff --git a/cmd/patch-release-notify/main.go b/cmd/patch-release-notify/main.go new file mode 100644 index 00000000000..89007975b78 --- /dev/null +++ b/cmd/patch-release-notify/main.go @@ -0,0 +1,23 @@ +/* +Copyright 2022 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import "k8s.io/release/cmd/patch-release-notify/cmd" + +func main() { + cmd.Execute() +} diff --git a/cmd/schedule-builder/cmd/markdown.go b/cmd/schedule-builder/cmd/markdown.go index c3365ef4593..06af2a4c0bf 100644 --- a/cmd/schedule-builder/cmd/markdown.go +++ b/cmd/schedule-builder/cmd/markdown.go @@ -25,13 +25,15 @@ import ( "github.com/olekukonko/tablewriter" "github.com/sirupsen/logrus" + + "k8s.io/release/cmd/schedule-builder/model" ) //go:embed templates/*.tmpl var tpls embed.FS // runs with `--type=patch` to retrun the patch schedule -func parseSchedule(patchSchedule PatchSchedule) string { +func parseSchedule(patchSchedule model.PatchSchedule) string { output := []string{} output = append(output, "### Timeline\n") for _, releaseSchedule := range patchSchedule.Schedules { @@ -68,11 +70,11 @@ func parseSchedule(patchSchedule PatchSchedule) string { } // runs with `--type=release` to retrun the release cycle schedule -func parseReleaseSchedule(releaseSchedule ReleaseSchedule) string { +func parseReleaseSchedule(releaseSchedule model.ReleaseSchedule) string { type RelSched struct { K8VersionWithDot string K8VersionWithoutDot string - Arr []Timeline + Arr []model.Timeline TimelineOutput string } @@ -80,7 +82,7 @@ func parseReleaseSchedule(releaseSchedule ReleaseSchedule) string { relSched.K8VersionWithDot = releaseSchedule.Releases[0].Version relSched.K8VersionWithoutDot = removeDotfromVersion(releaseSchedule.Releases[0].Version) - relSched.Arr = []Timeline{} + relSched.Arr = []model.Timeline{} for _, releaseSchedule := range releaseSchedule.Releases { for _, timeline := range releaseSchedule.Timeline { if timeline.Tldr { @@ -113,7 +115,7 @@ func parseReleaseSchedule(releaseSchedule ReleaseSchedule) string { return scheduleOut } -func patchReleaseInPreviousList(a string, previousPatches []PreviousPatches) bool { +func patchReleaseInPreviousList(a string, previousPatches []model.PreviousPatches) bool { for _, b := range previousPatches { if b.Release == a { return true diff --git a/cmd/schedule-builder/cmd/markdown_test.go b/cmd/schedule-builder/cmd/markdown_test.go index 4f84c910786..f811fe3bc04 100644 --- a/cmd/schedule-builder/cmd/markdown_test.go +++ b/cmd/schedule-builder/cmd/markdown_test.go @@ -21,6 +21,8 @@ import ( "testing" "github.com/stretchr/testify/require" + + "k8s.io/release/cmd/schedule-builder/model" ) const expectedPatchSchedule = `### Timeline @@ -124,19 +126,19 @@ Please refer to the [release phases document](../release_phases.md). func TestParseSchedule(t *testing.T) { testcases := []struct { name string - schedule PatchSchedule + schedule model.PatchSchedule }{ { name: "next patch is not in previous patch list", - schedule: PatchSchedule{ - Schedules: []Schedule{ + schedule: model.PatchSchedule{ + Schedules: []model.Schedule{ { Release: "X.Y", Next: "X.Y.ZZZ", CherryPickDeadline: "2020-06-12", TargetDate: "2020-06-17", EndOfLifeDate: "NOW", - PreviousPatches: []PreviousPatches{ + PreviousPatches: []model.PreviousPatches{ { Release: "X.Y.XXX", CherryPickDeadline: "2020-05-15", @@ -155,15 +157,15 @@ func TestParseSchedule(t *testing.T) { }, { name: "next patch is in previous patch list", - schedule: PatchSchedule{ - Schedules: []Schedule{ + schedule: model.PatchSchedule{ + Schedules: []model.Schedule{ { Release: "X.Y", Next: "X.Y.ZZZ", CherryPickDeadline: "2020-06-12", TargetDate: "2020-06-17", EndOfLifeDate: "NOW", - PreviousPatches: []PreviousPatches{ + PreviousPatches: []model.PreviousPatches{ { Release: "X.Y.ZZZ", CherryPickDeadline: "2020-06-12", @@ -197,15 +199,15 @@ func TestParseSchedule(t *testing.T) { func TestParseReleaseSchedule(t *testing.T) { testcases := []struct { name string - schedule ReleaseSchedule + schedule model.ReleaseSchedule }{ { name: "test of release cycle of X.Y version", - schedule: ReleaseSchedule{ - Releases: []Release{ + schedule: model.ReleaseSchedule{ + Releases: []model.Release{ { Version: "X.Y", - Timeline: []Timeline{ + Timeline: []model.Timeline{ { What: "Testing-A", Who: "tester", diff --git a/cmd/schedule-builder/cmd/root.go b/cmd/schedule-builder/cmd/root.go index deb0bf48b8c..50537a00baf 100644 --- a/cmd/schedule-builder/cmd/root.go +++ b/cmd/schedule-builder/cmd/root.go @@ -24,6 +24,7 @@ import ( "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "k8s.io/release/cmd/schedule-builder/model" "sigs.k8s.io/release-utils/log" "sigs.k8s.io/yaml" ) @@ -121,8 +122,8 @@ func run(opts *options) error { } var ( - patchSchedule PatchSchedule - releaseSchedule ReleaseSchedule + patchSchedule model.PatchSchedule + releaseSchedule model.ReleaseSchedule scheduleOut string ) diff --git a/cmd/schedule-builder/cmd/model.go b/cmd/schedule-builder/model/model.go similarity index 96% rename from cmd/schedule-builder/cmd/model.go rename to cmd/schedule-builder/model/model.go index 2b8b7106db1..49cb1dfc17c 100644 --- a/cmd/schedule-builder/cmd/model.go +++ b/cmd/schedule-builder/model/model.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package cmd +package model // PatchSchedule main struct to hold the schedules type PatchSchedule struct { @@ -32,6 +32,7 @@ type PreviousPatches struct { // Schedule struct to define the release schedule for a specific version type Schedule struct { Release string `yaml:"release"` + ReleaseDate string `yaml:"releaseDate"` Next string `yaml:"next"` CherryPickDeadline string `yaml:"cherryPickDeadline"` TargetDate string `yaml:"targetDate"` diff --git a/compile-release-tools b/compile-release-tools index 7f15c95224e..9f27bc59f14 100755 --- a/compile-release-tools +++ b/compile-release-tools @@ -22,6 +22,7 @@ RELEASE_TOOLS=( krel kubepkg schedule-builder + patch-release-notify ) setup_env() { diff --git a/gcb/patch-notify/cloudbuild.yaml b/gcb/patch-notify/cloudbuild.yaml new file mode 100644 index 00000000000..61a6cb29da5 --- /dev/null +++ b/gcb/patch-notify/cloudbuild.yaml @@ -0,0 +1,76 @@ +timeout: 14400s + +#### SECURITY NOTICE #### +# Google Cloud Build (GCB) supports the usage of secrets for build requests. +# Secrets appear within GCB configs as base64-encoded strings. +# These secrets are GCP Cloud KMS-encrypted and cannot be decrypted by any human or system +# outside of GCP Cloud KMS for the GCP project this encrypted resource was created for. +# Seeing the base64-encoded encrypted blob here is not a security event for the project. +# +# More details on using encrypted resources on Google Cloud Build can be found here: +# https://cloud.google.com/cloud-build/docs/securing-builds/use-encrypted-secrets-credentials +# +# (Please do not remove this security notice.) +secrets: +- kmsKeyName: projects/k8s-releng-prod/locations/global/keyRings/release/cryptoKeys/encrypt-0 + secretEnv: + SENDGRID_API_KEY: CiQAIkWjMHgA5DO+n4mn2PjTt39Y8tyksfNsPzUPr9vXzmJj/hQSbwBLz1D+xN/e6YvhYL0ePeMcuuTEF+A/Pq3cT7/lk3YGD2NVKB6t6ZMtcwu1fFmFK7dmsybZsiqYy/tE0v45ElOHTOH9hjLfiKgsqLth/aB7nSH7f/7Z+P6aaHeVBLf4zKp2s1uGBOMZRZBNU3Lzeg== + +steps: +- name: gcr.io/cloud-builders/git + dir: "go/src/k8s.io" + args: + - "clone" + - "https://github.com/${_TOOL_ORG}/${_TOOL_REPO}" + +- name: gcr.io/cloud-builders/git + dir: "go/src/k8s.io" + args: + - "clone" + - "https://github.com/kubernetes/website" + +- name: gcr.io/cloud-builders/git + entrypoint: "bash" + dir: "go/src/k8s.io/release" + args: + - '-c' + - | + git fetch + echo "Checking out ${_TOOL_REF}" + git checkout ${_TOOL_REF} + +- name: gcr.io/k8s-staging-releng/k8s-cloud-builder:${_KUBE_CROSS_VERSION_LATEST} + dir: "go/src/k8s.io/release" + env: + - "GOPATH=/workspace/go" + - "GOBIN=/workspace/bin" + args: + - "./compile-release-tools" + - "patch-release-notify" + +- name: gcr.io/k8s-staging-releng/k8s-cloud-builder:${_KUBE_CROSS_VERSION} + dir: "/workspace" + secretEnv: + - GITHUB_TOKEN + args: + - "bin/patch-release-notify" + - "--schedule-path" + - "go/src/k8s.io/website/data/releases/schedule.yaml" + - "--email" + - "release-managers@kubernetes.io" + - "--name" + - "Release Managers" + - "--days-to-alert" + - "3" + +tags: +- ${_GCP_USER_TAG} +- PATCH-RELEASE-NOTIFY + +options: + machineType: N1_HIGHCPU_32 + +substitutions: + # _GIT_TAG will be filled with a git-based tag of the form vYYYYMMDD-hash, and + # can be used as a substitution + _GIT_TAG: '12345'