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 }