blob: ac4236bfad6f00b68eb817412c9fe9f1ce0c406c [file] [edit]
// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"time"
ptypes "github.com/golang/protobuf/ptypes"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"go.chromium.org/luci/common/clock"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/gae/service/datastore"
"go.chromium.org/luci/server/router"
rpb "go.chromium.org/infra/appengine/rotation-proxy/proto"
)
// Rotation is used to store rpb.Rotation in Datastore.
type Rotation struct {
Name string `gae:"$id"`
Proto rpb.Rotation `gae:"proto,noindex"`
// The time where this entity is expired and may be deleted from datastore.
// This field is set to be 7 days from the last updated time of the entity,
// so it can be eligible to be deleted after 7 days.
ExpiryAt time.Time `gae:"expiry_at"`
}
// RotationProxyServer implements the proto service RotationProxyService.
type RotationProxyServer struct{}
// GetRotation returns shift information for a single rotation.
func (rps *RotationProxyServer) GetRotation(ctx context.Context, request *rpb.GetRotationRequest) (*rpb.Rotation, error) {
rotation, err := getRotationByName(ctx, request.Name)
if err == datastore.ErrNoSuchEntity {
return nil, status.Errorf(codes.NotFound, "rotation %q not found: %v", request.Name, err)
}
return rotation, err
}
func getRotationByName(ctx context.Context, name string) (*rpb.Rotation, error) {
rotation := &Rotation{Name: name}
err := datastore.RunInTransaction(ctx, func(ctx context.Context) error {
return datastore.Get(ctx, rotation)
}, nil)
if err != nil {
return nil, err
}
if err := processShiftsForRotation(ctx, &rotation.Proto); err != nil {
return nil, err
}
return &rotation.Proto, nil
}
// BatchGetRotations returns shift information for multiple rotations.
func (rps *RotationProxyServer) BatchGetRotations(ctx context.Context, request *rpb.BatchGetRotationsRequest) (*rpb.BatchGetRotationsResponse, error) {
rotations := make([]*Rotation, len(request.Names))
for i, rotationName := range request.Names {
rotations[i] = &Rotation{
Name: rotationName,
}
}
err := datastore.RunInTransaction(ctx, func(ctx context.Context) error {
return datastore.Get(ctx, rotations)
}, nil)
if err != nil {
// err should be MultiError, according to https://godoc.org/go.chromium.org/luci/gae/service/datastore#Get
if firstErr := err.(errors.MultiError).First(); firstErr == datastore.ErrNoSuchEntity {
return nil, status.Errorf(codes.NotFound, "rotation not found: %v", firstErr)
}
return nil, err
}
for _, rotation := range rotations {
if err := processShiftsForRotation(ctx, &rotation.Proto); err != nil {
return nil, err
}
}
// Compose the response
rots := make([]*rpb.Rotation, len(rotations))
for i, rotation := range rotations {
rots[i] = &rotation.Proto
}
return &rpb.BatchGetRotationsResponse{
Rotations: rots,
}, nil
}
// BatchUpdateRotations updates rotation information in Rotation Proxy.
func (rps *RotationProxyServer) BatchUpdateRotations(ctx context.Context, request *rpb.BatchUpdateRotationsRequest) (*rpb.BatchUpdateRotationsResponse, error) {
entities := make([]*Rotation, len(request.Requests))
for i, req := range request.Requests {
entities[i] = &Rotation{
Name: req.Rotation.Name,
Proto: *req.Rotation,
ExpiryAt: clock.Now(ctx).Add(7 * 24 * time.Hour),
}
}
err := datastore.RunInTransaction(ctx, func(ctx context.Context) error {
return datastore.Put(ctx, entities)
}, nil)
if err != nil {
return nil, err
}
// Construct the response
var rotations []*rpb.Rotation
for _, req := range request.Requests {
rotations = append(rotations, req.Rotation)
}
return &rpb.BatchUpdateRotationsResponse{
Rotations: rotations,
}, nil
}
// processShiftsForRotation filters out past shifts and sort shifts based on start time.
func processShiftsForRotation(ctx context.Context, rotation *rpb.Rotation) error {
now := clock.Now(ctx)
n := 0
for _, shift := range rotation.Shifts {
var shiftEndTime time.Time
var err error
if shift.EndTime != nil {
shiftEndTime, err = ptypes.Timestamp(shift.EndTime)
if err != nil {
return err
}
}
if shift.EndTime == nil || shiftEndTime.After(now) {
rotation.Shifts[n] = shift
n++
}
}
rotation.Shifts = rotation.Shifts[:n]
sort.Slice(rotation.Shifts, func(i, j int) bool {
return rotation.Shifts[i].StartTime.Seconds < rotation.Shifts[j].StartTime.Seconds
})
return nil
}
func errStatus(c context.Context, w http.ResponseWriter, status int, msg string) {
logging.Errorf(c, "Status %d msg %s", status, msg)
w.WriteHeader(status)
w.Write([]byte(msg))
}
// GetCurrentShiftHandler handles API requests for current shift.
// Note that this is a JSON endpoint (NOT pRPC) to handle HTTP requests.
func GetCurrentShiftHandler(ctx *router.Context) {
c, w, p := ctx.Request.Context(), ctx.Writer, ctx.Params
w.Header().Set("Access-Control-Allow-Origin", "*")
rotationName := p.ByName("name")
emails, err := getCurrentOncallEmails(c, rotationName)
if err != nil {
if err == datastore.ErrNoSuchEntity {
errStatus(c, w, http.StatusNotFound, err.Error())
} else {
errStatus(c, w, http.StatusInternalServerError, err.Error())
}
return
}
shift := make(map[string]any)
shift["updated_unix_timestamp"] = time.Now().Unix()
shift["emails"] = emails
data, err := json.Marshal(shift)
if err != nil {
errStatus(c, w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(data)
}
func getCurrentOncallEmails(c context.Context, rotationName string) ([]string, error) {
rotation, err := getRotationByName(c, rotationName)
if err != nil {
return nil, err
}
if len(rotation.Shifts) == 0 {
return nil, fmt.Errorf("cannot find shift for rotation %q", rotationName)
}
if shiftIsCurrent(c, rotation.Shifts[0]) {
ret := []string{}
for _, oncall := range rotation.Shifts[0].Oncalls {
ret = append(ret, oncall.Email)
}
return ret, nil
}
return []string{}, nil
}
func shiftIsCurrent(c context.Context, shift *rpb.Shift) bool {
now := clock.Now(c)
if startTime, err := ptypes.Timestamp(shift.StartTime); err != nil || startTime.After(now) {
return false
}
// There might be no end time, in which case this shift extends to
// infinity (so it should be treated as current).
if endTime, err := ptypes.Timestamp(shift.EndTime); err == nil && endTime.Before(now) {
return false
}
return true
}