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 }