Skip to content

Commit 3b3ce90

Browse files
committed
add aws lambda function to send patch cherry pick notification
Signed-off-by: cpanato <[email protected]>
1 parent f294861 commit 3b3ce90

File tree

14 files changed

+597
-49
lines changed

14 files changed

+597
-49
lines changed
+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Patch Release Notification
2+
3+
This simple tool has the objective to send an notification email when we are closer to
4+
the patch release cycle to let people know that the cherry pick deadline is approaching.
+318
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
/*
2+
Copyright 2024 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package main
18+
19+
import (
20+
"bytes"
21+
"context"
22+
"embed"
23+
"fmt"
24+
"io"
25+
"log"
26+
"math"
27+
"net/http"
28+
"os"
29+
"path/filepath"
30+
"strings"
31+
"text/template"
32+
"time"
33+
34+
"gomodules.xyz/envconfig"
35+
"gopkg.in/gomail.v2"
36+
"gopkg.in/yaml.v3"
37+
38+
"github.com/aws/aws-lambda-go/events"
39+
"github.com/aws/aws-lambda-go/lambda"
40+
"github.com/aws/aws-sdk-go/aws"
41+
"github.com/aws/aws-sdk-go/aws/session"
42+
"github.com/aws/aws-sdk-go/service/ses"
43+
"github.com/sirupsen/logrus"
44+
45+
"k8s.io/release/cmd/schedule-builder/model"
46+
)
47+
48+
//go:embed templates/email.tmpl
49+
var tpls embed.FS
50+
51+
type Config struct {
52+
FromEmail string `envconfig:"FROM_EMAIL" required:"true"`
53+
ToEmail string `envconfig:"TO_EMAIL" required:"true"`
54+
SchedulePath string `envconfig:"SCHEDULE_PATH" required:"true"`
55+
DaysToAlert int `envconfig:"DAYS_TO_ALERT" required:"true"`
56+
57+
NoMock bool `default:"false" envconfig:"NO_MOCK" required:"true"`
58+
59+
AWSRegion string `envconfig:"AWS_REGION" required:"true"`
60+
}
61+
62+
type Options struct {
63+
AWSSess *session.Session
64+
Config *Config
65+
Context context.Context
66+
}
67+
68+
const (
69+
layout = "2006-01-02"
70+
)
71+
72+
type Template struct {
73+
Releases []TemplateRelease
74+
}
75+
76+
type TemplateRelease struct {
77+
Release string
78+
CherryPickDeadline string
79+
}
80+
81+
func main() {
82+
lambda.Start(handler)
83+
}
84+
85+
func getConfig() (*Config, error) {
86+
var c Config
87+
err := envconfig.Process("", &c)
88+
if err != nil {
89+
return nil, err
90+
}
91+
return &c, nil
92+
}
93+
94+
func New(ctx context.Context) (*Options, error) {
95+
config, err := getConfig()
96+
if err != nil {
97+
return nil, fmt.Errorf("failed to get config: %w", err)
98+
}
99+
100+
// create new AWS session
101+
sess, err := session.NewSession(&aws.Config{
102+
Region: aws.String(config.AWSRegion),
103+
})
104+
if err != nil {
105+
log.Println("Error occurred while creating aws session", err)
106+
return nil, err
107+
}
108+
109+
return &Options{
110+
AWSSess: sess,
111+
Config: config,
112+
Context: ctx,
113+
}, nil
114+
}
115+
116+
func handler(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { //nolint: gocritic
117+
o, err := New(ctx)
118+
if err != nil {
119+
return events.APIGatewayProxyResponse{
120+
Body: `{"status": "nok"}`,
121+
StatusCode: http.StatusInternalServerError,
122+
}, err
123+
}
124+
125+
data, err := loadFileOrURL(o.Config.SchedulePath)
126+
if err != nil {
127+
return events.APIGatewayProxyResponse{
128+
Body: `{"status": "nok"}`,
129+
StatusCode: http.StatusInternalServerError,
130+
}, fmt.Errorf("failed to read the file: %w", err)
131+
}
132+
133+
patchSchedule := &model.PatchSchedule{}
134+
135+
logrus.Info("Parsing the schedule...")
136+
137+
if err := yaml.Unmarshal(data, &patchSchedule); err != nil {
138+
return events.APIGatewayProxyResponse{
139+
Body: `{"status": "nok"}`,
140+
StatusCode: http.StatusInternalServerError,
141+
}, fmt.Errorf("failed to decode the file: %w", err)
142+
}
143+
144+
output := &Template{}
145+
146+
shouldSendEmail := false
147+
148+
for _, patch := range patchSchedule.Schedules {
149+
t, err := time.Parse(layout, patch.Next.CherryPickDeadline)
150+
if err != nil {
151+
return events.APIGatewayProxyResponse{
152+
Body: `{"status": "nok"}`,
153+
StatusCode: http.StatusInternalServerError,
154+
}, fmt.Errorf("parsing schedule time: %w", err)
155+
}
156+
157+
currentTime := time.Now().UTC()
158+
days := t.Sub(currentTime).Hours() / 24
159+
intDay, _ := math.Modf(days)
160+
logrus.Infof("cherry pick deadline: %d, days to alert: %d", int(intDay), o.Config.DaysToAlert)
161+
if int(intDay) == o.Config.DaysToAlert {
162+
output.Releases = append(output.Releases, TemplateRelease{
163+
Release: patch.Release,
164+
CherryPickDeadline: patch.Next.CherryPickDeadline,
165+
})
166+
shouldSendEmail = true
167+
}
168+
}
169+
170+
tmpl, err := template.ParseFS(tpls, "templates/email.tmpl")
171+
if err != nil {
172+
return events.APIGatewayProxyResponse{
173+
Body: `{"status": "nok"}`,
174+
StatusCode: http.StatusInternalServerError,
175+
}, fmt.Errorf("parsing template: %w", err)
176+
}
177+
178+
var tmplBytes bytes.Buffer
179+
err = tmpl.Execute(&tmplBytes, output)
180+
if err != nil {
181+
return events.APIGatewayProxyResponse{
182+
Body: `{"status": "nok"}`,
183+
StatusCode: http.StatusInternalServerError,
184+
}, fmt.Errorf("parsing values to the template: %w", err)
185+
}
186+
187+
if !shouldSendEmail {
188+
logrus.Info("No email is needed to send")
189+
return events.APIGatewayProxyResponse{
190+
Body: `{"status": "ok"}`,
191+
StatusCode: http.StatusOK,
192+
}, nil
193+
}
194+
195+
logrus.Info("Sending mail")
196+
subject := "[Please Read] Patch Releases cherry-pick deadline"
197+
fromEmail := o.Config.FromEmail
198+
199+
recipient := Recipient{
200+
toEmails: []string{o.Config.ToEmail},
201+
}
202+
203+
if !o.Config.NoMock {
204+
logrus.Info("This is a mock only, will print out the email before sending to a test mailing list")
205+
fmt.Println(tmplBytes.String())
206+
// if is a mock we send the email to ourselves to test
207+
recipient.toEmails = []string{o.Config.FromEmail}
208+
}
209+
210+
err = o.SendEmailRawSES(tmplBytes.String(), subject, fromEmail, recipient)
211+
if err != nil {
212+
return events.APIGatewayProxyResponse{
213+
Body: `{"status": "nok"}`,
214+
StatusCode: http.StatusInternalServerError,
215+
}, fmt.Errorf("parsing values to the template: %w", err)
216+
}
217+
218+
return events.APIGatewayProxyResponse{
219+
Body: `{"status": "ok"}`,
220+
StatusCode: 200,
221+
}, nil
222+
}
223+
224+
// Recipient struct to hold email IDs
225+
type Recipient struct {
226+
toEmails []string
227+
ccEmails []string
228+
bccEmails []string
229+
}
230+
231+
// SendEmailSES sends email to specified email IDs
232+
func (o *Options) SendEmailRawSES(messageBody, subject, fromEmail string, recipient Recipient) error {
233+
// create raw message
234+
msg := gomail.NewMessage()
235+
236+
// set to section
237+
recipients := make([]*string, 0, len(recipient.toEmails))
238+
for _, r := range recipient.toEmails {
239+
recipient := r
240+
recipients = append(recipients, &recipient)
241+
}
242+
243+
// Set to emails
244+
msg.SetHeader("To", recipient.toEmails...)
245+
246+
// cc mails mentioned
247+
if len(recipient.ccEmails) != 0 {
248+
// Need to add cc mail IDs also in recipient list
249+
for _, r := range recipient.ccEmails {
250+
recipient := r
251+
recipients = append(recipients, &recipient)
252+
}
253+
msg.SetHeader("cc", recipient.ccEmails...)
254+
}
255+
256+
// bcc mails mentioned
257+
if len(recipient.bccEmails) != 0 {
258+
// Need to add bcc mail IDs also in recipient list
259+
for _, r := range recipient.bccEmails {
260+
recipient := r
261+
recipients = append(recipients, &recipient)
262+
}
263+
msg.SetHeader("bcc", recipient.bccEmails...)
264+
}
265+
266+
// create an SES session.
267+
svc := ses.New(o.AWSSess)
268+
269+
msg.SetAddressHeader("From", fromEmail, "Release Managers")
270+
msg.SetHeader("To", recipient.toEmails...)
271+
msg.SetHeader("Subject", subject)
272+
msg.SetBody("text/html", messageBody)
273+
274+
// create a new buffer to add raw data
275+
var emailRaw bytes.Buffer
276+
_, err := msg.WriteTo(&emailRaw)
277+
if err != nil {
278+
log.Printf("failed to write mail: %v\n", err)
279+
return err
280+
}
281+
282+
// create new raw message
283+
message := ses.RawMessage{Data: emailRaw.Bytes()}
284+
285+
input := &ses.SendRawEmailInput{Source: &fromEmail, Destinations: recipients, RawMessage: &message}
286+
287+
// send raw email
288+
_, err = svc.SendRawEmail(input)
289+
if err != nil {
290+
log.Println("Error sending mail - ", err)
291+
return err
292+
}
293+
294+
log.Println("Email sent successfully to: ", recipient.toEmails)
295+
return nil
296+
}
297+
298+
func loadFileOrURL(fileRef string) ([]byte, error) {
299+
var raw []byte
300+
var err error
301+
if strings.HasPrefix(fileRef, "http://") || strings.HasPrefix(fileRef, "https://") {
302+
resp, err := http.Get(fileRef) //nolint:gosec // we are not using user input we set via env var
303+
if err != nil {
304+
return nil, err
305+
}
306+
defer resp.Body.Close()
307+
raw, err = io.ReadAll(resp.Body)
308+
if err != nil {
309+
return nil, err
310+
}
311+
} else {
312+
raw, err = os.ReadFile(filepath.Clean(fileRef))
313+
if err != nil {
314+
return nil, err
315+
}
316+
}
317+
return raw, nil
318+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<body>
4+
<p>Hello Kubernetes Community!</p>
5+
{{range .Releases}}
6+
<p>The cherry-pick deadline for the <b>{{ .Release }}</b> branches is <b>{{ .CherryPickDeadline }} EOD PT.</b></p>
7+
{{end}}
8+
<br><p>Here are some quick links to search for cherry-pick PRs:</p>
9+
{{range .Releases}}
10+
<p> - release-{{ .Release }}: https://github.com/kubernetes/kubernetes/pulls?q=is%3Apr+is%3Aopen+base%3Arelease-{{ .Release }}+label%3Ado-not-merge%2Fcherry-pick-not-approved</p>
11+
{{end}}
12+
<br>
13+
<p>For PRs that you intend to land for the upcoming patch sets, please
14+
ensure they have:</p>
15+
<p> - a release note in the PR description</p>
16+
<p> - /sig</p>
17+
<p> - /kind</p>
18+
<p> - /priority</p>
19+
<p> - /lgtm</p>
20+
<p> - /approve</p>
21+
<p> - passing tests</p>
22+
<br>
23+
<p>Details on the cherry-pick process can be found here:</p>
24+
<p>https://git.k8s.io/community/contributors/devel/sig-release/cherry-picks.md</p>
25+
<p>We keep general info and up-to-date timelines for patch releases here:</p>
26+
<p>https://kubernetes.io/releases/patch-releases/#upcoming-monthly-releases</p>
27+
<p>If you have any questions for the Release Managers, please feel free to
28+
reach out to us at #release-management (Kubernetes Slack) or [email protected]</p><br>
29+
<p>We wish everyone a happy and safe week!</p>
30+
<p>SIG-Release Team</p>
31+
</body>
32+
</html>

0 commit comments

Comments
 (0)