go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/tree_status/internal/status/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 status manages status values. 16 package status 17 18 import ( 19 "context" 20 "crypto/rand" 21 "encoding/hex" 22 "fmt" 23 "regexp" 24 "time" 25 "unicode" 26 "unicode/utf8" 27 28 "cloud.google.com/go/spanner" 29 "golang.org/x/text/unicode/norm" 30 "google.golang.org/api/iterator" 31 "google.golang.org/grpc/codes" 32 33 "go.chromium.org/luci/common/errors" 34 "go.chromium.org/luci/server/span" 35 36 pb "go.chromium.org/luci/tree_status/proto/v1" 37 ) 38 39 const ( 40 // TreeNameExpression is a partial regular expression that validates tree identifiers. 41 TreeNameExpression = `[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?` 42 // StatusIDExpression is a partial regular expression that validates status identifiers. 43 StatusIDExpression = `[0-9a-f]{32}` 44 ) 45 46 // NotExistsErr is returned when the requested object was not found in the database. 47 var NotExistsErr error = errors.New("status value was not found") 48 49 // Status mirrors the structure of status values in the database. 50 type Status struct { 51 // The name of the tree this status is part of. 52 TreeName string 53 // The unique identifier for the status. This is a randomly generated 54 // 128-bit ID, encoded as 32 lowercase hexadecimal characters. 55 StatusID string 56 // The general state of the tree. 57 GeneralStatus pb.GeneralState 58 // The message explaining details about the status. 59 Message string 60 // The username of the user who added this. Will be 61 // set to 'user' after the username TTL (of 30 days). 62 CreateUser string 63 // The time the status update was made. 64 // If filling this in from commit timestamp, make sure to call .UTC(), i.e. 65 // status.CreateTime = timestamp.UTC() 66 CreateTime time.Time 67 } 68 69 // Validate validates a status value. 70 // It ignores the CreateUser and CreateTime fields. 71 // Reported field names are as they appear in the RPC documentation rather than the Go struct. 72 func Validate(status *Status) error { 73 if err := validateTreeName(status.TreeName); err != nil { 74 return errors.Annotate(err, "tree").Err() 75 } 76 if err := validateID(status.StatusID); err != nil { 77 return errors.Annotate(err, "id").Err() 78 } 79 if err := validateGeneralStatus(status.GeneralStatus); err != nil { 80 return errors.Annotate(err, "general_state").Err() 81 } 82 if err := validateMessage(status.Message); err != nil { 83 return errors.Annotate(err, "message").Err() 84 } 85 return nil 86 } 87 88 var treeNameRE = regexp.MustCompile(`^` + TreeNameExpression + `$`) 89 90 func validateTreeName(treeName string) error { 91 if treeName == "" { 92 return errors.Reason("must be specified").Err() 93 } 94 if !treeNameRE.MatchString(treeName) { 95 return errors.Reason("expected format: %s", treeNameRE).Err() 96 } 97 return nil 98 } 99 100 var statusIDRE = regexp.MustCompile(`^` + StatusIDExpression + `$`) 101 102 func validateID(id string) error { 103 if id == "" { 104 return errors.Reason("must be specified").Err() 105 } 106 if !statusIDRE.MatchString(id) { 107 return errors.Reason("expected format: %s", statusIDRE).Err() 108 } 109 return nil 110 } 111 112 func validateGeneralStatus(state pb.GeneralState) error { 113 if state == pb.GeneralState_GENERAL_STATE_UNSPECIFIED { 114 return errors.Reason("must be specified").Err() 115 } 116 if _, ok := pb.GeneralState_name[int32(state)]; !ok { 117 return errors.Reason("invalid enum value").Err() 118 } 119 return nil 120 } 121 122 func validateMessage(message string) error { 123 if message == "" { 124 return errors.Reason("must be specified").Err() 125 } 126 if len(message) > 1024 { 127 return errors.Reason("longer than 1024 bytes").Err() 128 } 129 if !utf8.ValidString(message) { 130 return errors.Reason("not a valid utf8 string").Err() 131 } 132 if !norm.NFC.IsNormalString(message) { 133 return errors.Reason("not in unicode normalized form C").Err() 134 } 135 for i, rune := range message { 136 if !unicode.IsPrint(rune) { 137 return fmt.Errorf("non-printable rune %+q at byte index %d", rune, i) 138 } 139 } 140 return nil 141 } 142 143 // Create creates a status entry in the Spanner Database. 144 // Must be called with an active RW transaction in the context. 145 // CreateUser and CreateTime in the passed in status will be ignored 146 // in favour of the commit time and the currentUser argument. 147 func Create(status *Status, currentUser string) (*spanner.Mutation, error) { 148 if err := Validate(status); err != nil { 149 return nil, err 150 } 151 152 row := map[string]any{ 153 "TreeName": status.TreeName, 154 "StatusId": status.StatusID, 155 "GeneralStatus": int64(status.GeneralStatus), 156 "Message": status.Message, 157 "CreateUser": currentUser, 158 "CreateTime": spanner.CommitTimestamp, 159 } 160 return spanner.InsertOrUpdateMap("Status", row), nil 161 } 162 163 // Read retrieves a status update from the database given the exact time the status update was made. 164 func Read(ctx context.Context, treeName, statusID string) (*Status, error) { 165 row, err := span.ReadRow(ctx, "Status", spanner.Key{treeName, statusID}, []string{"StatusId", "GeneralStatus", "Message", "CreateUser", "CreateTime"}) 166 if err != nil { 167 if spanner.ErrCode(err) == codes.NotFound { 168 return nil, NotExistsErr 169 } 170 return nil, errors.Annotate(err, "get status").Err() 171 } 172 return fromRow(treeName, row) 173 } 174 175 // ReadLatest retrieves the most recent status update for a tree from the database. 176 func ReadLatest(ctx context.Context, treeName string) (*Status, error) { 177 stmt := spanner.NewStatement(` 178 SELECT 179 StatusId, 180 GeneralStatus, 181 Message, 182 CreateUser, 183 CreateTime 184 FROM Status 185 WHERE 186 TreeName = @treeName 187 ORDER BY CreateTime DESC 188 LIMIT 1`) 189 stmt.Params["treeName"] = treeName 190 iter := span.Query(ctx, stmt) 191 row, err := iter.Next() 192 defer iter.Stop() 193 if errors.Is(err, iterator.Done) { 194 return nil, NotExistsErr 195 } else if err != nil { 196 return nil, errors.Annotate(err, "get latest status").Err() 197 } 198 return fromRow(treeName, row) 199 } 200 201 // ListOptions allows specifying extra options to the List method. 202 type ListOptions struct { 203 // The offset into the list of statuses in the database to start reading at. 204 Offset int64 205 // The maximum number of items to return from the database. 206 // If left as 0, the value of 100 will be used. 207 Limit int64 208 } 209 210 // List retrieves a list of status values from the database. Status values are listed in reverse 211 // CreateTime order (i.e. most recently created first). 212 // The options argument may be nil to use default options. 213 func List(ctx context.Context, treeName string, options *ListOptions) ([]*Status, bool, error) { 214 if options == nil { 215 options = &ListOptions{} 216 } 217 if options.Limit == 0 { 218 options.Limit = 100 219 } 220 221 stmt := spanner.NewStatement(` 222 SELECT 223 StatusId, 224 GeneralStatus, 225 Message, 226 CreateUser, 227 CreateTime 228 FROM Status 229 WHERE 230 TreeName = @treeName 231 ORDER BY CreateTime DESC 232 LIMIT @limit 233 OFFSET @offset`) 234 stmt.Params["treeName"] = treeName 235 // Read one extra result to detect whether there is a next page. 236 stmt.Params["limit"] = options.Limit + 1 237 stmt.Params["offset"] = options.Offset 238 239 iter := span.Query(ctx, stmt) 240 241 statusList := []*Status{} 242 if err := iter.Do(func(row *spanner.Row) error { 243 status, err := fromRow(treeName, row) 244 statusList = append(statusList, status) 245 return err 246 }); err != nil { 247 return nil, false, errors.Annotate(err, "list status").Err() 248 } 249 hasNextPage := false 250 if int64(len(statusList)) > options.Limit { 251 statusList = statusList[:options.Limit] 252 hasNextPage = true 253 } 254 return statusList, hasNextPage, nil 255 } 256 257 func fromRow(treeName string, row *spanner.Row) (*Status, error) { 258 status := &Status{TreeName: treeName} 259 generalStatus := int64(0) 260 if err := row.Column(0, &status.StatusID); err != nil { 261 return nil, errors.Annotate(err, "reading StatusID column").Err() 262 } 263 if err := row.Column(1, &generalStatus); err != nil { 264 return nil, errors.Annotate(err, "reading GeneralStatus column").Err() 265 } 266 status.GeneralStatus = pb.GeneralState(generalStatus) 267 if err := row.Column(2, &status.Message); err != nil { 268 return nil, errors.Annotate(err, "reading Message column").Err() 269 } 270 if err := row.Column(3, &status.CreateUser); err != nil { 271 return nil, errors.Annotate(err, "reading CreateUser column").Err() 272 } 273 if err := row.Column(4, &status.CreateTime); err != nil { 274 return nil, errors.Annotate(err, "reading CreateTime column").Err() 275 } 276 return status, nil 277 } 278 279 // GenerateID returns a random 128-bit status ID, encoded as 280 // 32 lowercase hexadecimal characters. 281 func GenerateID() (string, error) { 282 randomBytes := make([]byte, 16) 283 _, err := rand.Read(randomBytes) 284 if err != nil { 285 return "", err 286 } 287 return hex.EncodeToString(randomBytes), nil 288 }