go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/config_service/internal/model/model.go (about)

     1  // Copyright 2023 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 model package model contains Datastore models Config Service uses.
    16  package model
    17  
    18  import (
    19  	"bytes"
    20  	"context"
    21  	"fmt"
    22  	"io"
    23  	"time"
    24  
    25  	"github.com/klauspost/compress/gzip"
    26  
    27  	"go.chromium.org/luci/common/errors"
    28  	"go.chromium.org/luci/common/gcloud/gs"
    29  	cfgcommonpb "go.chromium.org/luci/common/proto/config"
    30  	"go.chromium.org/luci/config"
    31  	"go.chromium.org/luci/gae/service/datastore"
    32  
    33  	"go.chromium.org/luci/config_service/internal/clients"
    34  )
    35  
    36  const (
    37  	// ConfigSetKind is the Datastore entity kind for ConfigSet.
    38  	ConfigSetKind = "ConfigSetV2"
    39  
    40  	// RevisionKind is the Datastore entity kind for Revision.
    41  	RevisionKind = "RevisionV2"
    42  
    43  	// FileKind is the Datastore entity kind for File.
    44  	FileKind = "FileV2"
    45  
    46  	// ImportAttemptKind is the Datastore entity kind for ImportAttempt.
    47  	ImportAttemptKind = "ImportAttemptV2"
    48  
    49  	// ServiceKind is the Datastore entity kind for Service.
    50  	ServiceKind = "Service"
    51  
    52  	// CurrentCfgSetVersion is a global version for all ConfigSet entities. It can
    53  	// be used to force a global refresh.
    54  	CurrentCfgSetVersion = 2
    55  )
    56  
    57  // ConfigSet is a versioned collection of config files.
    58  type ConfigSet struct {
    59  	_kind string `gae:"$kind,ConfigSetV2"`
    60  
    61  	// ID is the name of a config set.
    62  	// Examples: services/luci-config, projects/chromium.
    63  	ID config.Set `gae:"$id"`
    64  
    65  	// LatestRevision contains the latest revision info for this ConfigSet.
    66  	LatestRevision RevisionInfo `gae:"latest_revision"`
    67  	// Location is the source location which points the root of this ConfigSet.
    68  	Location *cfgcommonpb.Location `gae:"location"`
    69  	// Version is the global version of the config set.
    70  	// It may be used to decide to force a refresh.
    71  	Version int64 `gae:"version,noindex"`
    72  }
    73  
    74  var _ datastore.PropertyLoadSaver = (*ConfigSet)(nil)
    75  
    76  // Save implements datastore.PropertyLoadSaver. It makes sure ConfigSet.Version
    77  // always set to CurrentCfgSetVersion when saving into Datastore.
    78  func (cs *ConfigSet) Save(withMeta bool) (datastore.PropertyMap, error) {
    79  	cs.Version = CurrentCfgSetVersion
    80  	return datastore.GetPLS(cs).Save(withMeta)
    81  }
    82  
    83  // Load implements datastore.PropertyLoadSaver.
    84  func (cs *ConfigSet) Load(p datastore.PropertyMap) error {
    85  	return datastore.GetPLS(cs).Load(p)
    86  }
    87  
    88  // File represents a single config file. Immutable.
    89  //
    90  // TODO(vadimsh): `Content` can be moved to a child entity to allow listing
    91  // file metadata without pulling large blob from the datastore. This will be
    92  // useful in GetConfigSet and DeleteStaleConfigs implementations.
    93  type File struct {
    94  	_kind string `gae:"$kind,FileV2"`
    95  
    96  	//  Path is the file path relative to its config set root path.
    97  	Path string `gae:"$id"`
    98  	// Revision is a key for parent Revision.
    99  	Revision *datastore.Key `gae:"$parent"`
   100  	// CreateTime is the timestamp when this File entity is imported.
   101  	CreateTime time.Time `gae:"create_time,noindex"`
   102  	// Content is the gzipped raw content of the small config file.
   103  	Content []byte `gae:"content,noindex"`
   104  	// GcsURI is a Google Cloud Storage URI where it stores large gzipped file.
   105  	// The format is "gs://<bucket>/<object_name>"
   106  	// Note: Either Content field or GcsUri field will be set, but not both.
   107  	GcsURI gs.Path `gae:"gcs_uri,noindex"`
   108  	// ContentSHA256 is the SHA256 hash of the file content.
   109  	ContentSHA256 string `gae:"content_sha256"`
   110  	// Size is the raw file size in bytes.
   111  	Size int64 `gae:"size,noindex"`
   112  	// Location is a pinned, fully resolved source location to this file.
   113  	Location *cfgcommonpb.Location `gae:"location"`
   114  
   115  	// rawContent caches the result of `GetRawContent`. Not saved in datastore.
   116  	rawContent []byte `gae:"-"`
   117  }
   118  
   119  // GetPath returns that path to the File.
   120  func (f *File) GetPath() string {
   121  	return f.Path
   122  }
   123  
   124  // GetGSPath returns the GCS path to where the config file is stored.
   125  func (f *File) GetGSPath() gs.Path {
   126  	return f.GcsURI
   127  }
   128  
   129  // GetRawContent returns the raw and uncompressed content of this config.
   130  //
   131  // May download content from Google Cloud Storage if content is not
   132  // stored inside the entity due to its size.
   133  // The result will be cached so the next GetRawContent call will not pay
   134  // the cost to fetch and decompress.
   135  func (f *File) GetRawContent(ctx context.Context) ([]byte, error) {
   136  	switch {
   137  	case f.rawContent != nil:
   138  		break // rawContent is fetched and cached before.
   139  	case len(f.Content) > 0:
   140  		rawContent, err := decompressData(f.Content)
   141  		if err != nil {
   142  			return nil, err
   143  		}
   144  		f.rawContent = rawContent
   145  	case f.GcsURI != "":
   146  		compressed, err := clients.GetGsClient(ctx).Read(ctx, f.GcsURI.Bucket(), f.GcsURI.Filename(), false)
   147  		if err != nil {
   148  			return nil, errors.Annotate(err, "failed to read from %s", f.GcsURI).Err()
   149  		}
   150  		rawContent, err := decompressData(compressed)
   151  		if err != nil {
   152  			return nil, err
   153  		}
   154  		f.rawContent = rawContent
   155  	default:
   156  		return nil, errors.New("both content and gcs_uri are empty")
   157  	}
   158  	return f.rawContent, nil
   159  }
   160  
   161  func decompressData(data []byte) ([]byte, error) {
   162  	gr, err := gzip.NewReader(bytes.NewReader(data))
   163  	if err != nil {
   164  		return nil, errors.Annotate(err, "failed to create gzip reader").Err()
   165  	}
   166  	ret, err := io.ReadAll(gr)
   167  	if err != nil {
   168  		_ = gr.Close()
   169  		return nil, errors.Annotate(err, "failed to decompress the data").Err()
   170  	}
   171  	if err := gr.Close(); err != nil {
   172  		return nil, errors.Annotate(err, "errors closing gzip reader").Err()
   173  	}
   174  	return ret, nil
   175  }
   176  
   177  // ImportAttempt describes what happened last time we tried to import a config
   178  // set.
   179  type ImportAttempt struct {
   180  	_kind string `gae:"$kind,ImportAttemptV2"`
   181  
   182  	// ID is always the string "last" because we only need last attempt info.
   183  	ID string `gae:"$id,last"`
   184  
   185  	// ConfigSet is a key for parent ConfigSet.
   186  	ConfigSet *datastore.Key `gae:"$parent"`
   187  	// Revision refers to the revision info.
   188  	Revision RevisionInfo `gae:"revision,noindex"`
   189  	// Success indicates whether this attempt is succeeded.
   190  	Success bool `gae:"success,noindex"`
   191  	// Message is a human-readable message about this import attempt.
   192  	Message string `gae:"message,noindex"`
   193  	// ValidationResult is the result of validating the config set.
   194  	ValidationResult *cfgcommonpb.ValidationResult `gae:"validation_result"`
   195  }
   196  
   197  // RevisionInfo contains a revision metadata.
   198  // Referred by ConfigSet and ImportAttempt.
   199  type RevisionInfo struct {
   200  	// ID is a revision name. If imported from Git, it is a commit hash.
   201  	ID string `gae:"id"`
   202  	// Location is a pinned location with revision info in the source repo.
   203  	Location *cfgcommonpb.Location `gae:"location"`
   204  	// CommitTime is the commit time of this revision.
   205  	CommitTime time.Time `gae:"commit_time,noindex"`
   206  	// CommitterEmail is the committer's email.
   207  	CommitterEmail string `gae:"committer_email,noindex"`
   208  	// AuthorEmail is the email of the commit author.
   209  	AuthorEmail string `gae:"author_email,noindex"`
   210  }
   211  
   212  // Service contains information about a registered service.
   213  type Service struct {
   214  	// Name is the name of the service.
   215  	Name string `gae:"$id"`
   216  	// Info contains information  for LUCI Config to interact with the service.
   217  	Info *cfgcommonpb.Service `gae:"info"`
   218  	// Metadata describes the metadata of a service.
   219  	Metadata *cfgcommonpb.ServiceMetadata `gae:"metadata"`
   220  	// LegacyMetadata is returned by the service that is still talking in
   221  	// legacy LUCI Config protocol (i.e. REST based).
   222  	//
   223  	// TODO: crbug/1232565 - Remove this support once all backend services are
   224  	// able to talk in the new LUCI Config protocol (i.e. expose
   225  	// `cfgcommonpb.Consumer` interface)
   226  	LegacyMetadata *cfgcommonpb.ServiceDynamicMetadata `gae:"legacy_metadata"`
   227  	// UpdateTime is the time this entity is updated.
   228  	UpdateTime time.Time `gae:"update_time"`
   229  }
   230  
   231  // NoSuchConfigError captures the error caused by unknown config set or file.
   232  type NoSuchConfigError struct {
   233  	unknownConfigSet  string
   234  	unknownConfigFile struct {
   235  		configSet, revision, file, hash string
   236  	}
   237  }
   238  
   239  // Error implements error interface.
   240  func (e *NoSuchConfigError) Error() string {
   241  	switch {
   242  	case e.unknownConfigSet != "":
   243  		return fmt.Sprintf("can not find config set entity %q from datastore", e.unknownConfigSet)
   244  	case e.unknownConfigFile.file != "":
   245  		return fmt.Sprintf("can not find file entity %q from datastore for config set: %s, revision: %s", e.unknownConfigFile.file, e.unknownConfigFile.configSet, e.unknownConfigFile.revision)
   246  	case e.unknownConfigFile.hash != "":
   247  		return fmt.Sprintf("can not find matching file entity from datastore with hash %q", e.unknownConfigFile.hash)
   248  	default:
   249  		return ""
   250  	}
   251  }
   252  
   253  // IsUnknownConfigSet returns true the error is caused by unknown config set.
   254  func (e *NoSuchConfigError) IsUnknownConfigSet() bool {
   255  	return e.unknownConfigSet != ""
   256  }
   257  
   258  // IsUnknownFile returns true the error is caused by unknown file name.
   259  func (e *NoSuchConfigError) IsUnknownFile() bool {
   260  	return e.unknownConfigFile.file != ""
   261  }
   262  
   263  // IsUnknownFile returns true the error is caused by unknown file hash.
   264  func (e *NoSuchConfigError) IsUnknownFileHash() bool {
   265  	return e.unknownConfigFile.hash != ""
   266  }
   267  
   268  // GetLatestConfigFile returns the latest File entity as is for the given
   269  // config set.
   270  //
   271  // Returns NoSuchConfigError when the config set or file can not be found.
   272  func GetLatestConfigFile(ctx context.Context, configSet config.Set, filePath string) (*File, error) {
   273  	cfgSet := &ConfigSet{ID: configSet}
   274  	switch err := datastore.Get(ctx, cfgSet); {
   275  	case err == datastore.ErrNoSuchEntity:
   276  		return nil, &NoSuchConfigError{unknownConfigSet: string(configSet)}
   277  	case err != nil:
   278  		return nil, errors.Annotate(err, "failed to fetch ConfigSet %q", configSet).Err()
   279  	}
   280  	f := &File{
   281  		Path:     filePath,
   282  		Revision: datastore.MakeKey(ctx, ConfigSetKind, string(configSet), RevisionKind, cfgSet.LatestRevision.ID),
   283  	}
   284  	switch err := datastore.Get(ctx, f); {
   285  	case err == datastore.ErrNoSuchEntity:
   286  		return nil, &NoSuchConfigError{
   287  			unknownConfigFile: struct {
   288  				configSet, revision, file, hash string
   289  			}{
   290  				configSet: f.Revision.Root().StringID(),
   291  				revision:  f.Revision.StringID(),
   292  				file:      f.Path,
   293  			}}
   294  	case err != nil:
   295  		return nil, errors.Annotate(err, "failed to fetch file %q", f.Path).Err()
   296  	}
   297  	return f, nil
   298  }
   299  
   300  // GetConfigFileByHash fetches a file entity by content hash for the given
   301  // config set. If multiple file entities are found, the most recently created
   302  // one will be returned.
   303  //
   304  // Returns NoSuchConfigError when the matching file can not be found in the
   305  // storage.
   306  func GetConfigFileByHash(ctx context.Context, configSet config.Set, contentSha256 string) (*File, error) {
   307  	var latestFile *File
   308  	err := datastore.Run(ctx, datastore.NewQuery(FileKind).Eq("content_sha256", contentSha256), func(file *File) error {
   309  		if file.Revision.Root().StringID() == string(configSet) &&
   310  			(latestFile == nil || file.CreateTime.After(latestFile.CreateTime)) {
   311  			latestFile = file
   312  		}
   313  		return nil
   314  	})
   315  	switch {
   316  	case err != nil:
   317  		return nil, errors.Annotate(err, "failed to query file by sha256 hash %q", contentSha256).Err()
   318  	case latestFile == nil:
   319  		return nil, &NoSuchConfigError{
   320  			unknownConfigFile: struct {
   321  				configSet, revision, file, hash string
   322  			}{hash: contentSha256}}
   323  	}
   324  	return latestFile, nil
   325  }