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  }