go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/resultdb/internal/config/project_config.go (about) 1 // Copyright 2022 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 config validates and gives access to ResultDB LUCI config. 16 package config 17 18 import ( 19 "context" 20 "fmt" 21 "time" 22 23 "google.golang.org/protobuf/proto" 24 25 "go.chromium.org/luci/common/errors" 26 "go.chromium.org/luci/common/logging" 27 "go.chromium.org/luci/common/tsmon/field" 28 "go.chromium.org/luci/common/tsmon/metric" 29 "go.chromium.org/luci/config" 30 "go.chromium.org/luci/config/cfgclient" 31 "go.chromium.org/luci/config/validation" 32 "go.chromium.org/luci/gae/service/datastore" 33 "go.chromium.org/luci/server/caching" 34 35 configpb "go.chromium.org/luci/resultdb/proto/config" 36 ) 37 38 var projectCacheSlot = caching.RegisterCacheSlot() 39 40 const projectConfigKind = "resultdb.ProjectConfig" 41 42 var ( 43 importAttemptCounter = metric.NewCounter( 44 "resultdb/project_config/import_attempt", 45 "The number of import attempts of project config", 46 nil, 47 // status can be "success" or "failure". 48 field.String("project"), field.String("status")) 49 ) 50 51 var ( 52 // Returned if configuration for a given project does not exist. 53 ErrNotFoundProjectConfig = fmt.Errorf("no project config found") 54 ) 55 56 type cachedProjectConfig struct { 57 _extra datastore.PropertyMap `gae:"-,extra"` 58 _kind string `gae:"$kind,resultdb.ProjectConfig"` 59 60 ID string `gae:"$id"` // The name of the project for which the config is. 61 Config []byte `gae:",noindex"` 62 Meta config.Meta `gae:",noindex"` 63 } 64 65 func init() { 66 // Registers validation of the given configuration paths with cfgmodule. 67 validation.Rules.Add("regex:projects/.*", "${appid}.cfg", func(ctx *validation.Context, configSet, path string, content []byte) error { 68 // Discard the returned deserialized message. 69 validateProjectConfigRaw(ctx, string(content)) 70 return nil 71 }) 72 } 73 74 // UpdateProjects fetches fresh project-level configuration from LUCI Config 75 // service and stores it in datastore. 76 func UpdateProjects(ctx context.Context) error { 77 // Fetch freshest configs from the LUCI Config. 78 fetchedConfigs, err := fetchLatestProjectConfigs(ctx) 79 if err != nil { 80 return err 81 } 82 83 var errs []error 84 parsedConfigs := make(map[string]*fetchedProjectConfig) 85 for project, fetch := range fetchedConfigs { 86 valCtx := validation.Context{Context: ctx} 87 valCtx.SetFile(fetch.Path) 88 msg := validateProjectConfigRaw(&valCtx, fetch.Content) 89 if err := valCtx.Finalize(); err != nil { 90 blocking := err.(*validation.Error).WithSeverity(validation.Blocking) 91 if blocking != nil { 92 // Continue through validation errors to ensure a validation 93 // error in one project does not affect other projects. 94 errs = append(errs, errors.Annotate(blocking, "validation errors for %q", project).Err()) 95 msg = nil 96 } 97 } 98 // We create an entry even for invalid config (where msg == nil), 99 // because we want to signal that config for this project still exists 100 // and existing config should be retained instead of being deleted. 101 parsedConfigs[project] = &fetchedProjectConfig{ 102 Config: msg, 103 Meta: fetch.Meta, 104 } 105 } 106 forceUpdate := false 107 success := true 108 if err := updateStoredConfig(ctx, parsedConfigs, forceUpdate); err != nil { 109 errs = append(errs, err) 110 success = false 111 } 112 // Report success for all projects that passed validation, assuming the 113 // update succeeded. 114 for project, config := range parsedConfigs { 115 status := "success" 116 if !success || config.Config == nil { 117 status = "failure" 118 } 119 importAttemptCounter.Add(ctx, 1, project, status) 120 } 121 122 if len(errs) > 0 { 123 return errors.NewMultiError(errs...) 124 } 125 return nil 126 } 127 128 type fetchedProjectConfig struct { 129 // config is the project-level configuration, if it has passed validation, 130 // and nil otherwise. 131 Config *configpb.ProjectConfig 132 // meta is populated with config metadata. 133 Meta config.Meta 134 } 135 136 // updateStoredConfig updates the config stored in datastore. fetchedConfigs 137 // contains the new configs to store, forceUpdate forces overwrite of existing 138 // configuration (ignoring whether the config revision is newer). 139 func updateStoredConfig(ctx context.Context, fetchedConfigs map[string]*fetchedProjectConfig, forceUpdate bool) error { 140 // Drop out of any existing datastore transactions. 141 ctx = cleanContext(ctx) 142 143 currentConfigs, err := fetchProjectConfigEntities(ctx) 144 if err != nil { 145 return err 146 } 147 148 var errs []error 149 var toPut []*cachedProjectConfig 150 for project, fetch := range fetchedConfigs { 151 if fetch.Config == nil { 152 // Config did not pass validation. 153 continue 154 } 155 blob, err := proto.Marshal(fetch.Config) 156 if err != nil { 157 // Continue through errors to ensure bad config for one project 158 // does not affect others. 159 errs = append(errs, errors.Annotate(err, "").Err()) 160 continue 161 } 162 cur, ok := currentConfigs[project] 163 if !ok { 164 cur = &cachedProjectConfig{ 165 ID: project, 166 } 167 } 168 if !forceUpdate && cur.Meta.Revision == fetch.Meta.Revision { 169 logging.Infof(ctx, "Cached config %s is up-to-date at rev %q", cur.ID, cur.Meta.Revision) 170 continue 171 } 172 logging.Infof(ctx, "Updating cached config %s: %q -> %q", cur.ID, cur.Meta.Revision, fetch.Meta.Revision) 173 toPut = append(toPut, &cachedProjectConfig{ 174 ID: cur.ID, 175 Config: blob, 176 Meta: fetch.Meta, 177 }) 178 } 179 if err := datastore.Put(ctx, toPut); err != nil { 180 errs = append(errs, errors.Annotate(err, "updating project configs").Err()) 181 } 182 183 var toDelete []*datastore.Key 184 for project, cur := range currentConfigs { 185 if _, ok := fetchedConfigs[project]; ok { 186 continue 187 } 188 toDelete = append(toDelete, datastore.KeyForObj(ctx, cur)) 189 } 190 191 if err := datastore.Delete(ctx, toDelete); err != nil { 192 errs = append(errs, errors.Annotate(err, "deleting stale project configs").Err()) 193 } 194 195 if len(errs) > 0 { 196 return errors.NewMultiError(errs...) 197 } 198 return nil 199 } 200 201 func fetchLatestProjectConfigs(ctx context.Context) (map[string]config.Config, error) { 202 configs, err := cfgclient.Client(ctx).GetProjectConfigs(ctx, "${appid}.cfg", false) 203 if err != nil { 204 return nil, err 205 } 206 result := make(map[string]config.Config) 207 for _, cfg := range configs { 208 project := cfg.ConfigSet.Project() 209 if project != "" { 210 result[project] = cfg 211 } 212 } 213 return result, nil 214 } 215 216 // fetchProjectConfigEntities retrieves project configuration entities 217 // from datastore, including metadata. 218 func fetchProjectConfigEntities(ctx context.Context) (map[string]*cachedProjectConfig, error) { 219 var configs []*cachedProjectConfig 220 err := datastore.GetAll(ctx, datastore.NewQuery(projectConfigKind), &configs) 221 if err != nil { 222 return nil, errors.Annotate(err, "fetching project configs from datastore").Err() 223 } 224 result := make(map[string]*cachedProjectConfig) 225 for _, cfg := range configs { 226 result[cfg.ID] = cfg 227 } 228 return result, nil 229 } 230 231 // Projects returns all project configurations, in a map by project name. 232 // Uses in-memory cache to avoid hitting datastore all the time. 233 // Note that the config may be stale by up to 1 minute. 234 func Projects(ctx context.Context) (map[string]*configpb.ProjectConfig, error) { 235 val, err := projectCacheSlot.Fetch(ctx, func(any) (val any, exp time.Duration, err error) { 236 var pc map[string]*configpb.ProjectConfig 237 if pc, err = fetchProjects(ctx); err != nil { 238 return nil, 0, err 239 } 240 return pc, time.Minute, nil 241 }) 242 switch { 243 case err == caching.ErrNoProcessCache: 244 // A fallback useful in unit tests that may not have the process cache 245 // available. Production environments usually have the cache installed 246 // by the framework code that initializes the root context. 247 return fetchProjects(ctx) 248 case err != nil: 249 return nil, err 250 default: 251 pc := val.(map[string]*configpb.ProjectConfig) 252 return pc, nil 253 } 254 } 255 256 // fetchProjects retrieves all project configurations from datastore. 257 func fetchProjects(ctx context.Context) (map[string]*configpb.ProjectConfig, error) { 258 ctx = cleanContext(ctx) 259 260 cachedCfgs, err := fetchProjectConfigEntities(ctx) 261 if err != nil { 262 return nil, errors.Annotate(err, "fetching cached config").Err() 263 } 264 result := make(map[string]*configpb.ProjectConfig) 265 for project, cached := range cachedCfgs { 266 cfg := &configpb.ProjectConfig{} 267 if err := proto.Unmarshal(cached.Config, cfg); err != nil { 268 return nil, errors.Annotate(err, "unmarshalling cached config").Err() 269 } 270 result[project] = cfg 271 } 272 return result, nil 273 } 274 275 // cleanContext returns a context with datastore and not using transactions. 276 func cleanContext(ctx context.Context) context.Context { 277 return datastore.WithoutTransaction(ctx) 278 } 279 280 // SetTestProjectConfig sets test project configuration in datastore. 281 // It should be used from unit/integration tests only. 282 func SetTestProjectConfig(ctx context.Context, cfg map[string]*configpb.ProjectConfig) error { 283 fetchedConfigs := make(map[string]*fetchedProjectConfig) 284 for project, pcfg := range cfg { 285 fetchedConfigs[project] = &fetchedProjectConfig{ 286 Config: pcfg, 287 Meta: config.Meta{}, 288 } 289 } 290 forceUpdate := true 291 if err := updateStoredConfig(ctx, fetchedConfigs, forceUpdate); err != nil { 292 return err 293 } 294 testable := datastore.GetTestable(ctx) 295 if testable == nil { 296 return errors.New("SetTestProjectConfig should only be used with testable datastore implementations") 297 } 298 // An up-to-date index is required for fetch to retrieve the project 299 // entities we just saved. 300 testable.CatchupIndexes() 301 return nil 302 } 303 304 // Project returns the configurations of the requested project. 305 // Returns an ErrNotFoundProjectConfig error if config for the given project 306 // does not exist. 307 func Project(ctx context.Context, project string) (*configpb.ProjectConfig, error) { 308 configs, err := Projects(ctx) 309 if err != nil { 310 return nil, err 311 } 312 if c, ok := configs[project]; ok { 313 return c, nil 314 } 315 return nil, ErrNotFoundProjectConfig 316 }