|
| 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 | +} |
0 commit comments