go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/tree_status/rpc/status.go (about)

     1  // Copyright 2024 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package rpc contains the RPC handlers for the tree status service.
    16  package rpc
    17  
    18  import (
    19  	"context"
    20  	"fmt"
    21  	"regexp"
    22  	"time"
    23  
    24  	"cloud.google.com/go/spanner"
    25  	"google.golang.org/grpc/codes"
    26  	"google.golang.org/protobuf/types/known/timestamppb"
    27  
    28  	"go.chromium.org/luci/auth/identity"
    29  	"go.chromium.org/luci/common/errors"
    30  	"go.chromium.org/luci/grpc/appstatus"
    31  	"go.chromium.org/luci/server/auth"
    32  	"go.chromium.org/luci/server/span"
    33  
    34  	"go.chromium.org/luci/tree_status/internal/status"
    35  	pb "go.chromium.org/luci/tree_status/proto/v1"
    36  	"go.chromium.org/luci/tree_status/rpc/paginator"
    37  )
    38  
    39  type treeStatusServer struct{}
    40  
    41  var _ pb.TreeStatusServer = &treeStatusServer{}
    42  
    43  // NewTreeStatusServer creates a new server to handle TreeStatus requests.
    44  func NewTreeStatusServer() *pb.DecoratedTreeStatus {
    45  	return &pb.DecoratedTreeStatus{
    46  		Prelude:  checkAllowedPrelude,
    47  		Service:  &treeStatusServer{},
    48  		Postlude: gRPCifyAndLogPostlude,
    49  	}
    50  }
    51  
    52  var listPaginator = paginator.Paginator{
    53  	DefaultPageSize: 50,
    54  	MaxPageSize:     1000,
    55  }
    56  
    57  // ListStatus lists all status values for a tree in reverse chronological order.
    58  func (*treeStatusServer) ListStatus(ctx context.Context, request *pb.ListStatusRequest) (*pb.ListStatusResponse, error) {
    59  	tree, err := parseStatusParent(request.Parent)
    60  	if err != nil {
    61  		return nil, invalidArgumentError(errors.Annotate(err, "parent").Err())
    62  	}
    63  	offset, err := listPaginator.Offset(request)
    64  	if err != nil {
    65  		return nil, err
    66  	}
    67  	options := status.ListOptions{
    68  		Offset: offset,
    69  		Limit:  int64(listPaginator.Limit(request.PageSize)),
    70  	}
    71  	includeUserInResponse, err := auth.IsMember(ctx, treeStatusAuditAccessGroup)
    72  	if err != nil {
    73  		return nil, errors.Annotate(err, "checking username access").Err()
    74  	}
    75  
    76  	values, hasNextPage, err := status.List(span.Single(ctx), tree, &options)
    77  	if err != nil {
    78  		return nil, errors.Annotate(err, "listing status values").Err()
    79  	}
    80  
    81  	nextPageToken := ""
    82  	if hasNextPage {
    83  		nextPageToken, err = listPaginator.NextPageToken(request, offset+options.Limit)
    84  		if err != nil {
    85  			return nil, err
    86  		}
    87  	}
    88  	response := &pb.ListStatusResponse{
    89  		Status:        []*pb.Status{},
    90  		NextPageToken: nextPageToken,
    91  	}
    92  
    93  	for _, value := range values {
    94  		response.Status = append(response.Status, toStatusProto(value, includeUserInResponse))
    95  	}
    96  	return response, nil
    97  }
    98  
    99  // toStatusProto converts a status.Status value to a pb.Status proto.
   100  // If includeUser is false, the CreateUser field will be left blank instead of being copied.
   101  func toStatusProto(value *status.Status, includeUser bool) *pb.Status {
   102  	user := ""
   103  	if includeUser {
   104  		user = value.CreateUser
   105  	}
   106  	return &pb.Status{
   107  		Name:         fmt.Sprintf("trees/%s/status/%s", value.TreeName, value.StatusID),
   108  		GeneralState: value.GeneralStatus,
   109  		Message:      value.Message,
   110  		CreateUser:   user,
   111  		CreateTime:   timestamppb.New(value.CreateTime),
   112  	}
   113  }
   114  
   115  // GetStatus gets a status for a tree.
   116  // Use the resource alias 'latest' to get just the current status.
   117  func (*treeStatusServer) GetStatus(ctx context.Context, request *pb.GetStatusRequest) (*pb.Status, error) {
   118  	tree, id, err := parseStatusName(request.Name)
   119  	if err != nil {
   120  		return nil, invalidArgumentError(errors.Annotate(err, "name").Err())
   121  	}
   122  
   123  	includeUserInResponse, err := auth.IsMember(ctx, treeStatusAuditAccessGroup)
   124  	if err != nil {
   125  		return nil, errors.Annotate(err, "checking username access").Err()
   126  	}
   127  
   128  	if id == "latest" {
   129  		latest, err := status.ReadLatest(span.Single(ctx), tree)
   130  		if errors.Is(err, status.NotExistsErr) {
   131  			return &pb.Status{
   132  				Name:         fmt.Sprintf("trees/%s/status/fallback", tree),
   133  				GeneralState: pb.GeneralState_OPEN,
   134  				Message:      "Tree is open (fallback due to no status updates in past 140 days)",
   135  				CreateUser:   "",
   136  				CreateTime:   timestamppb.New(time.Now()),
   137  			}, nil
   138  		} else if err != nil {
   139  			return nil, errors.Annotate(err, "reading latest status").Err()
   140  		}
   141  		return toStatusProto(latest, includeUserInResponse), nil
   142  	}
   143  	s, err := status.Read(span.Single(ctx), tree, id)
   144  	if errors.Is(err, status.NotExistsErr) {
   145  		return nil, notFoundError(err)
   146  	} else if err != nil {
   147  		return nil, errors.Annotate(err, "reading status").Err()
   148  	}
   149  
   150  	return toStatusProto(s, includeUserInResponse), nil
   151  }
   152  
   153  // CreateStatus creates a new status update for the tree.
   154  func (*treeStatusServer) CreateStatus(ctx context.Context, request *pb.CreateStatusRequest) (*pb.Status, error) {
   155  	hasWriteAccess, err := auth.IsMember(ctx, treeStatusWriteAccessGroup)
   156  	if err != nil {
   157  		return nil, errors.Annotate(err, "checking write group membership").Err()
   158  	}
   159  	if !hasWriteAccess {
   160  		if auth.CurrentIdentity(ctx).Kind() == identity.Anonymous {
   161  			return nil, permissionDeniedError(errors.New("please log in before updating the tree status"))
   162  		}
   163  		return nil, permissionDeniedError(errors.New("you do not have permission to update the tree status"))
   164  	}
   165  
   166  	tree, err := parseStatusParent(request.GetParent())
   167  	if err != nil {
   168  		return nil, invalidArgumentError(errors.Annotate(err, "parent").Err())
   169  	}
   170  	id, err := status.GenerateID()
   171  	if err != nil {
   172  		return nil, errors.Annotate(err, "generating status id").Err()
   173  	}
   174  	s := &status.Status{
   175  		TreeName:      tree,
   176  		StatusID:      id,
   177  		GeneralStatus: request.Status.GeneralState,
   178  		Message:       request.Status.Message,
   179  	}
   180  	user := auth.CurrentIdentity(ctx).Value()
   181  	m, err := status.Create(s, user)
   182  	if err != nil {
   183  		return nil, invalidArgumentError(errors.Annotate(err, "create status").Err())
   184  	}
   185  	ts, err := span.Apply(ctx, []*spanner.Mutation{m})
   186  	if err != nil {
   187  		return nil, errors.Annotate(err, "apply create status to spanner").Err()
   188  	}
   189  
   190  	return &pb.Status{
   191  		Name:         fmt.Sprintf("trees/%s/status/%s", tree, id),
   192  		GeneralState: s.GeneralStatus,
   193  		Message:      s.Message,
   194  		CreateUser:   user,
   195  		CreateTime:   timestamppb.New(ts),
   196  	}, nil
   197  }
   198  
   199  var statusParentRE = regexp.MustCompile(`^trees/(` + status.TreeNameExpression + `)/status$`)
   200  var statusNameRE = regexp.MustCompile(`^trees/(` + status.TreeNameExpression + `)/status/(` + status.StatusIDExpression + `|latest)$`)
   201  
   202  // parseStatusParent parses a status resource parent into its constituent ID
   203  // parts.
   204  func parseStatusParent(parent string) (tree string, err error) {
   205  	if parent == "" {
   206  		return "", errors.Reason("must be specified").Err()
   207  	}
   208  	match := statusParentRE.FindStringSubmatch(parent)
   209  	if match == nil {
   210  		return "", errors.Reason("expected format: %s", statusParentRE).Err()
   211  	}
   212  	return match[1], nil
   213  }
   214  
   215  // parseStatusName parses a status resource name into its constituent ID
   216  // parts.
   217  func parseStatusName(name string) (tree string, id string, err error) {
   218  	if name == "" {
   219  		return "", "", errors.Reason("must be specified").Err()
   220  	}
   221  	match := statusNameRE.FindStringSubmatch(name)
   222  	if match == nil {
   223  		return "", "", errors.Reason("expected format: %s", statusNameRE).Err()
   224  	}
   225  	return match[1], match[2], nil
   226  }
   227  
   228  // invalidArgumentError annotates err as having an invalid argument.
   229  // The error message is shared with the requester as is.
   230  //
   231  // Note that this differs from FailedPrecondition. It indicates arguments
   232  // that are problematic regardless of the state of the system
   233  // (e.g., a malformed file name).
   234  func invalidArgumentError(err error) error {
   235  	return appstatus.Attachf(err, codes.InvalidArgument, "%s", err)
   236  }
   237  
   238  // permissionDeniedError annotates err as being denied (HTTP 403).
   239  // The error message is shared with the requester as is.
   240  func permissionDeniedError(err error) error {
   241  	return appstatus.Attachf(err, codes.PermissionDenied, "%s", err)
   242  }
   243  
   244  // notFoundError annotates err as being not found (HTTP 404).
   245  // The error message is shared with the requester as is.
   246  func notFoundError(err error) error {
   247  	return appstatus.Attachf(err, codes.NotFound, "%s", err)
   248  }